From 8e0beabdbee36bb87282fb7bb158af2b067c5b25 Mon Sep 17 00:00:00 2001 From: iSecloud <869820505@qq.com> Date: Fri, 3 Jan 2025 20:44:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(backend):=20bkchat=E4=B8=93=E5=B1=9Eproces?= =?UTF-8?q?s=20todo=20#8755?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dbm-ui/backend/core/notify/handlers.py | 57 +++++++++++-------- .../backend/db_services/plugin/constants.py | 2 +- .../db_services/plugin/ticket/serializers.py | 11 +++- .../db_services/plugin/ticket/views.py | 42 +++++++++++++- dbm-ui/backend/ticket/exceptions.py | 8 ++- dbm-ui/backend/ticket/flow_manager/manager.py | 19 +++++-- .../backend/ticket/flow_manager/resource.py | 2 + dbm-ui/backend/ticket/handler.py | 11 +++- dbm-ui/backend/ticket/todos/__init__.py | 4 +- dbm-ui/backend/ticket/todos/pipeline_todo.py | 2 +- dbm-ui/backend/utils/time.py | 2 +- dbm-ui/scripts/ci/install.sh | 1 + 12 files changed, 118 insertions(+), 43 deletions(-) diff --git a/dbm-ui/backend/core/notify/handlers.py b/dbm-ui/backend/core/notify/handlers.py index d215b3b161..b211d4eeda 100644 --- a/dbm-ui/backend/core/notify/handlers.py +++ b/dbm-ui/backend/core/notify/handlers.py @@ -24,12 +24,12 @@ from backend.core.notify.exceptions import NotifyBaseException from backend.core.notify.template import FAILED_TEMPLATE, FINISHED_TEMPLATE, TERMINATE_TEMPLATE, TODO_TEMPLATE from backend.db_meta.models import AppCache +from backend.exceptions import ApiResultError from backend.ticket.builders import BuilderFactory -from backend.ticket.constants import TicketStatus, TicketType +from backend.ticket.constants import TicketStatus, TicketType, TodoStatus from backend.ticket.models import Flow, Ticket from backend.ticket.todos import ActionType from backend.utils.cache import func_cache_decorator -from backend.utils.time import datetime2str logger = logging.getLogger("root") @@ -78,20 +78,25 @@ def get_actions(msg_type, ticket): """获取bkchat操作按钮""" if ticket.status not in [TicketStatus.APPROVE, TicketStatus.TODO]: return [] + + todo = ticket.todo_of_ticket.filter(status=TodoStatus.TODO).first() + if not todo: + return [] + # 增加回调按钮,执行和终止 agree_action = { "name": _("同意") if ticket.status == TicketStatus.APPROVE else _("确认执行"), "color": "green", - "callback_url": f"{env.BK_DBM_APIGATEWAY}/tickets/batch_process_ticket/", - "callback_data": {"action": ActionType.APPROVE.value, "ticket_ids": [ticket.id]}, + "callback_url": f"{env.BK_DBM_APIGATEWAY}/tickets/bkchat_process_todo/", + "callback_data": {"action": ActionType.APPROVE.value, "todo_id": todo.id, "params": {}}, } refuse_action = { "name": _("拒绝") if ticket.status == TicketStatus.APPROVE else _("终止单据"), "color": "red", - "callback_url": f"{env.BK_DBM_APIGATEWAY}/tickets/batch_process_ticket/", + "callback_url": f"{env.BK_DBM_APIGATEWAY}/tickets/bkchat_process_todo/", "callback_data": { "action": ActionType.TERMINATE.value, - "ticket_ids": [ticket.id], + "todo_id": todo.id, "params": {"remark": _("使用「蓝鲸审批助手」终止单据")}, }, } @@ -193,7 +198,9 @@ def send_rtx(self): self._cmsi_send_msg(MsgType.RTX.value) def send_sms(self): - """发送企微消息""" + """发送短信消息""" + # 短信消息没有标题参数,直接把标题和内容放在一起 + self.content = f"{self.title}\n{self.content}" self._cmsi_send_msg(MsgType.SMS.value) def send_wecom_robot(self): @@ -238,7 +245,11 @@ def __init__(self, ticket_id: int, flow_id: int = None): def get_support_msg_types(cls): # 获取当前环境下支持的通知类型 # 所有的拓展方式都需要接入CMSI,所以直接返回CMSI支持方式即可 - return CmsiApi.get_msg_type() + # 暂不暴露微信的通知方式 + msg_types = CmsiApi.get_msg_type() + msg_type_map = {msg["type"]: msg for msg in msg_types} + msg_type_map[MsgType.WEIXIN.value]["is_active"] = False + return list(msg_type_map.values()) def get_notify_class(self, msg_type: str): # 根据通知类型获取通知类,以及通知所需的上下文 @@ -253,15 +264,17 @@ def get_receivers(self): biz_helpers = BizSettings.get_assistance(self.bk_biz_id) creator = [self.ticket.creator] # 待审批:审批人 - # 待执行、待补货、待确认、已失败、已完成、已终止: 提单人、协助人 + # 待执行、待补货、待确认、已失败、已完成、已终止:提单人、协助人 # 暂不通知DBA if self.phase in [TicketStatus.PENDING]: - return creator + receivers = creator elif self.phase in [TicketStatus.APPROVE]: itsm_builder = BuilderFactory.get_builder_cls(self.ticket.ticket_type).itsm_flow_builder(self.ticket) - return itsm_builder.get_approvers().split(",") + receivers = itsm_builder.get_approvers().split(",") else: - return creator + biz_helpers + receivers = creator + biz_helpers + # 去重后返回 + return list(set(receivers)) def render_msg_template(self, msg_type: str): # 获取标题,在群机器人通知则加上@人 @@ -289,8 +302,8 @@ def render_msg_template(self, msg_type: str): "cluster_domains": ",".join(self.clusters), "remark": self.ticket.remark, "creator": self.ticket.creator, - "submit_time": datetime2str(self.ticket.create_at), - "update_time": datetime2str(self.ticket.update_at), + "submit_time": self.ticket.create_at.astimezone().strftime("%Y-%m-%d %H:%M:%S%z"), + "update_time": self.ticket.update_at.astimezone().strftime("%Y-%m-%d %H:%M:%S%z"), "status": TicketStatus.get_choice_label(self.phase), "operators": ",".join(self.ticket.get_current_operators()), "detail_address": self.ticket.url, @@ -325,17 +338,13 @@ def send_msg(self): if msg_type == MsgType.WECOM_ROBOT: self.receivers = send_msg_config.get(MsgType.WECOM_ROBOT.value, []) - notify_class(title, content, self.receivers).send_msg(msg_type, context=context) + try: + notify_class(title, content, self.receivers).send_msg(msg_type, context=context) + except (ApiResultError, Exception) as e: + logger.error(_("[{}]消息发送失败,错误信息: {}").format(MsgType.get_choice_label(msg_type), e)) @shared_task -def send_msg(ticket_id: int, flow_id: int = None, raise_exception: bool = False): +def send_msg(ticket_id: int, flow_id: int = None): # 可异步发送消息,非阻塞路径默认不抛出异常 - try: - NotifyAdapter(ticket_id, flow_id).send_msg() - except Exception as e: - err_msg = _("消息发送失败,错误信息:{}").format(e) - if not raise_exception: - logger.error(err_msg) - else: - raise NotifyBaseException(err_msg) + NotifyAdapter(ticket_id, flow_id).send_msg() diff --git a/dbm-ui/backend/db_services/plugin/constants.py b/dbm-ui/backend/db_services/plugin/constants.py index bc29cd9419..f3868d829c 100644 --- a/dbm-ui/backend/db_services/plugin/constants.py +++ b/dbm-ui/backend/db_services/plugin/constants.py @@ -9,4 +9,4 @@ specific language governing permissions and limitations under the License. """ -SWAGGER_TAG = "plugin" +SWAGGER_TAG = "OpenAPI" diff --git a/dbm-ui/backend/db_services/plugin/ticket/serializers.py b/dbm-ui/backend/db_services/plugin/ticket/serializers.py index 92b0a4c725..ba1272af61 100644 --- a/dbm-ui/backend/db_services/plugin/ticket/serializers.py +++ b/dbm-ui/backend/db_services/plugin/ticket/serializers.py @@ -12,8 +12,17 @@ from django.utils.translation import gettext_lazy as _ from rest_framework import serializers -from backend.ticket.serializers import BatchTicketOperateSerializer +from backend.ticket.serializers import BatchTicketOperateSerializer, TodoOperateSerializer class OpenAPIBatchTicketOperateSerializer(BatchTicketOperateSerializer): username = serializers.CharField(help_text=_("操作者")) + + +class OpenAPIBkChatProcessTodoSerializer(TodoOperateSerializer): + username = serializers.CharField(help_text=_("操作者")) + + +class OpenAPIBkChatProcessTodoResponseSerializer(serializers.Serializer): + response_msg = serializers.CharField(help_text=_("返回信息")) + response_color = serializers.CharField(help_text=_("按钮颜色")) diff --git a/dbm-ui/backend/db_services/plugin/ticket/views.py b/dbm-ui/backend/db_services/plugin/ticket/views.py index c0f9d74e1d..85f1d55ba8 100644 --- a/dbm-ui/backend/db_services/plugin/ticket/views.py +++ b/dbm-ui/backend/db_services/plugin/ticket/views.py @@ -17,10 +17,17 @@ from rest_framework.response import Response from backend.db_services.plugin.constants import SWAGGER_TAG -from backend.db_services.plugin.ticket.serializers import OpenAPIBatchTicketOperateSerializer +from backend.db_services.plugin.ticket.serializers import ( + OpenAPIBatchTicketOperateSerializer, + OpenAPIBkChatProcessTodoResponseSerializer, + OpenAPIBkChatProcessTodoSerializer, +) from backend.db_services.plugin.view import BaseOpenAPIViewSet +from backend.ticket.constants import TodoStatus, TodoType +from backend.ticket.exceptions import TodoDuplicateProcessException from backend.ticket.handler import TicketHandler -from backend.ticket.serializers import TodoSerializer +from backend.ticket.models import Todo +from backend.ticket.todos import TodoActorFactory logger = logging.getLogger("root") @@ -29,10 +36,39 @@ class TicketViewSet(BaseOpenAPIViewSet): @swagger_auto_schema( operation_summary=_("批量单据待办处理"), request_body=OpenAPIBatchTicketOperateSerializer(), - responses={status.HTTP_200_OK: TodoSerializer(many=True)}, tags=[SWAGGER_TAG], ) @action(methods=["POST"], detail=False, serializer_class=OpenAPIBatchTicketOperateSerializer) def batch_process_ticket(self, request, *args, **kwargs): params = self.params_validate(self.get_serializer_class()) return Response(TicketHandler.batch_process_ticket(**params)) + + @swagger_auto_schema( + operation_summary=_("待办处理(bkchat专属)"), + request_body=OpenAPIBkChatProcessTodoSerializer(), + responses={status.HTTP_200_OK: OpenAPIBkChatProcessTodoResponseSerializer()}, + tags=[SWAGGER_TAG], + ) + @action(methods=["POST"], detail=False, serializer_class=OpenAPIBkChatProcessTodoSerializer) + def bkchat_process_todo(self, request, *args, **kwargs): + """ + bkchat专属的待办处理,区别主要是返回结构不同 + """ + params = self.params_validate(self.get_serializer_class()) + + todo = Todo.objects.get(id=params["todo_id"]) + if todo.type not in [TodoType.ITSM, TodoType.APPROVE]: + return Response({"response_msg": _("暂不支持该类型{}todo的处理").fromat(todo.type), "response_color": "red"}) + + # 确认todo,忽略重复操作 + try: + TodoActorFactory.actor(todo).process(params["username"], params["action"], params["params"]) + except TodoDuplicateProcessException: + pass + + # 根据操作类型获取文案和按钮颜色 + todo.refresh_from_db() + if todo.status == TodoStatus.DONE_FAILED: + return Response({"response_msg": _("{} 已终止").format(todo.done_by), "response_color": "red"}) + elif todo.status == TodoStatus.DONE_SUCCESS: + return Response({"response_msg": _("{} 已确认").format(todo.done_by), "response_color": "green"}) diff --git a/dbm-ui/backend/ticket/exceptions.py b/dbm-ui/backend/ticket/exceptions.py index db5482a9d5..387118d25a 100644 --- a/dbm-ui/backend/ticket/exceptions.py +++ b/dbm-ui/backend/ticket/exceptions.py @@ -60,6 +60,12 @@ class TodoWrongOperatorException(TicketBaseException): MESSAGE_TPL = _("错误的todo处理人{username}") +class TodoDuplicateProcessException(TicketBaseException): + ERROR_CODE = "010" + MESSAGE = _("重复操作") + MESSAGE_TPL = _("重复操作") + + class ApprovalWrongOperatorException(TicketBaseException): ERROR_CODE = "008" MESSAGE = _("审批处理异常") @@ -67,6 +73,6 @@ class ApprovalWrongOperatorException(TicketBaseException): class TicketFlowsConfigException(TicketBaseException): - ERROR_CODE = "008" + ERROR_CODE = "009" MESSAGE = _("单据流程设置失败") MESSAGE_TPL = _("单据流程{ticket_type}设置失败") diff --git a/dbm-ui/backend/ticket/flow_manager/manager.py b/dbm-ui/backend/ticket/flow_manager/manager.py index 896ef84f50..8b99309774 100644 --- a/dbm-ui/backend/ticket/flow_manager/manager.py +++ b/dbm-ui/backend/ticket/flow_manager/manager.py @@ -10,6 +10,8 @@ """ import logging +from django.db import transaction + from backend import env from backend.core import notify from backend.ticket import constants @@ -115,15 +117,20 @@ def update_ticket_status(self): # 其他场景下状态未变更,无需更新DB return - if self.ticket.status != target_status: - origin_status = self.ticket.status - self.ticket.status = target_status - self.ticket.save(update_fields=["status", "update_at"]) + # 原子更新单据状态 + with transaction.atomic(): + ticket = Ticket.objects.select_for_update().get(id=self.ticket.id) + if ticket.status == target_status: + return + origin_status, ticket.status = ticket.status, target_status + ticket.save(update_fields=["status", "update_at"]) self.ticket_status_trigger(origin_status, target_status) def ticket_status_trigger(self, origin_status, target_status): """单据状态更新后的钩子函数""" - # 单据状态变更后,发送通知。忽略running - if target_status != TicketStatus.RUNNING: + # 单据状态变更后,发送通知。 + # 忽略运行中:流转到内置任务无需通知,待继续在todo创建时才触发通知 + # 忽略待补货:到资源申请节点,单据状态总会流转为待补货,但是只有待补货todo创建才触发通知 + if target_status not in [TicketStatus.RUNNING, TicketStatus.RESOURCE_REPLENISH]: notify.send_msg.apply_async(args=(self.ticket.id,)) diff --git a/dbm-ui/backend/ticket/flow_manager/resource.py b/dbm-ui/backend/ticket/flow_manager/resource.py index 4fa5166810..e1f056e805 100644 --- a/dbm-ui/backend/ticket/flow_manager/resource.py +++ b/dbm-ui/backend/ticket/flow_manager/resource.py @@ -21,6 +21,7 @@ from backend.components.dbresource.client import DBResourceApi from backend.configuration.constants import AffinityEnum from backend.configuration.models import DBAdministrator +from backend.core import notify from backend.db_meta.models import Spec from backend.db_services.dbresource.exceptions import ResourceApplyException, ResourceApplyInsufficientException from backend.db_services.ipchooser.constants import CommonEnum @@ -214,6 +215,7 @@ def create_replenish_todo(self): flow_id=self.flow_obj.id, ticket_id=self.ticket.id, user=self.ticket.creator, administrators=dba ).to_dict(), ) + notify.send_msg.apply_async(args=(self.ticket.id,)) def fetch_apply_params(self, ticket_data): """ diff --git a/dbm-ui/backend/ticket/handler.py b/dbm-ui/backend/ticket/handler.py index d34e5345a8..c389086d1c 100644 --- a/dbm-ui/backend/ticket/handler.py +++ b/dbm-ui/backend/ticket/handler.py @@ -231,7 +231,13 @@ def approve_itsm_ticket(cls, ticket_id, action, operator, **kwargs): act_msg = kwargs.get("action_message") or act_msg_tpl # 审批单据 - params = {"action_message": act_msg} + params = { + "sn": sn, + "action_message": act_msg, + "action_type": action, + "operator": operator, + "bk_username": operator, + } if action == OperateNodeActionType.TRANSITION: is_approved = kwargs["is_approved"] itsm_fields = cls.get_itsm_fields(flow.ticket.ticket_type) @@ -239,11 +245,10 @@ def approve_itsm_ticket(cls, ticket_id, action, operator, **kwargs): {"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) + params.update(state_id=state_id, fields=fields) ItsmApi.operate_node(params) # 终止/撤销单据 elif action in [OperateNodeActionType.TERMINATE, OperateNodeActionType.WITHDRAW]: - params.update(sn=sn, action_type=action, operator=operator) ItsmApi.operate_ticket(params) return sn diff --git a/dbm-ui/backend/ticket/todos/__init__.py b/dbm-ui/backend/ticket/todos/__init__.py index ad71f9d014..3cf9742f88 100644 --- a/dbm-ui/backend/ticket/todos/__init__.py +++ b/dbm-ui/backend/ticket/todos/__init__.py @@ -19,7 +19,7 @@ from backend.constants import DEFAULT_SYSTEM_USER from backend.ticket.constants import TODO_RUNNING_STATUS -from backend.ticket.exceptions import TodoWrongOperatorException +from backend.ticket.exceptions import TodoDuplicateProcessException, TodoWrongOperatorException from backend.ticket.models import Todo from blue_krill.data_types.enum import EnumField, StructuredEnum @@ -55,7 +55,7 @@ def allow_superuser_process(self): def process(self, username, action, params): # 当状态已经被确认,则不允许重复操作 if self.todo.status not in TODO_RUNNING_STATUS: - raise TodoWrongOperatorException(_("当前代办操作已经处理,不能重复处理!")) + raise TodoDuplicateProcessException(_("当前代办操作已经处理,不能重复处理!")) # 允许系统内置用户确认 if username == DEFAULT_SYSTEM_USER: diff --git a/dbm-ui/backend/ticket/todos/pipeline_todo.py b/dbm-ui/backend/ticket/todos/pipeline_todo.py index b20b574442..ace2ad5d0b 100644 --- a/dbm-ui/backend/ticket/todos/pipeline_todo.py +++ b/dbm-ui/backend/ticket/todos/pipeline_todo.py @@ -74,7 +74,7 @@ def create(cls, ticket, flow, root_id, node_id): # 当前不存在待确认的todo,则发送通知 if not flow.todo_of_flow.filter(type=TodoType.INNER_APPROVE).count(): - notify.send_msg(ticket.id, flow.id) + notify.send_msg.apply_async(args=(ticket.id,)) Todo.objects.create( name=_("【{}】流程待确认,是否继续?").format(ticket.get_ticket_type_display()), diff --git a/dbm-ui/backend/utils/time.py b/dbm-ui/backend/utils/time.py index 90f809bb1d..b5510d6e8a 100644 --- a/dbm-ui/backend/utils/time.py +++ b/dbm-ui/backend/utils/time.py @@ -43,7 +43,7 @@ def timezone2timestamp(date: Union[str, datetime.datetime]) -> int: return int(time_parse(date).timestamp()) -def datetime2str(o_datetime: datetime.datetime, fmt: str = DATETIME_PATTERN, aware_check: bool = True) -> str: +def datetime2str(o_datetime: datetime.datetime, aware_check: bool = True) -> str: """ 将时间对象转换为时间字符串,可选时区强校验 """ diff --git a/dbm-ui/scripts/ci/install.sh b/dbm-ui/scripts/ci/install.sh index a25c698417..bb44b26cfc 100755 --- a/dbm-ui/scripts/ci/install.sh +++ b/dbm-ui/scripts/ci/install.sh @@ -18,6 +18,7 @@ pip install poetry >> /tmp/pip_install.log # 进入dbm-ui进行操作 cd $DBM_DIR +poetry self add poetry-plugin-export poetry export --without-hashes -f requirements.txt --output requirements.txt pip install -r requirements.txt >> /tmp/pip_install.log