diff --git a/apps/ticket/urls.py b/apps/ticket/urls.py index cc104c11..ea94a083 100644 --- a/apps/ticket/urls.py +++ b/apps/ticket/urls.py @@ -1,7 +1,7 @@ from django.urls import path from apps.ticket.views import TicketListView, TicketView, TicketTransition, TicketFlowlog, TicketFlowStep, TicketState, \ TicketsStates, TicketAccept, TicketDeliver, TicketAddNode, \ - TicketAddNodeEnd, TicketField, TicketScriptRetry, TicketComment + TicketAddNodeEnd, TicketField, TicketScriptRetry, TicketComment, TicketHookCallBack urlpatterns = [ path('', TicketListView.as_view()), @@ -17,5 +17,6 @@ path('//add_node_end', TicketAddNodeEnd.as_view()), path('//retry_script', TicketScriptRetry.as_view()), path('//comments', TicketComment.as_view()), + path('//hook_call_back', TicketHookCallBack.as_view()), path('/states', TicketsStates.as_view()), # 批量获取工单状态 ] diff --git a/apps/ticket/views.py b/apps/ticket/views.py index a1054fa9..42bf5b5f 100644 --- a/apps/ticket/views.py +++ b/apps/ticket/views.py @@ -446,7 +446,7 @@ def patch(self, request, *args, **kwargs): class TicketScriptRetry(View): def post(self, request, *args, **kwargs): """ - 重新执行工单脚本(用于脚本执行出错的情况) + 重新执行工单脚本(用于脚本执行出错的情况), 也可用于hook执行失败的情况 :return: """ json_str = request.body.decode('utf-8') @@ -454,7 +454,6 @@ def post(self, request, *args, **kwargs): return api_response(-1, 'post参数为空', {}) request_data_dict = json.loads(json_str) ticket_id = kwargs.get('ticket_id') - # username = request_data_dict.get('username', '') username = request.META.get('HTTP_USERNAME') from service.account.account_base_service import AccountBaseService @@ -487,7 +486,6 @@ def post(self, request, *args, **kwargs): return api_response(-1, 'post参数为空', {}) request_data_dict = json.loads(json_str) ticket_id = kwargs.get('ticket_id') - # username = request_data_dict.get('username', '') username = request.META.get('HTTP_USERNAME') suggestion = request_data_dict.get('suggestion', '') result, msg = TicketBaseService.add_comment(ticket_id, username, suggestion) @@ -496,3 +494,28 @@ def post(self, request, *args, **kwargs): else: code, msg, data = -1, msg, '' return api_response(code, msg, data) + + +class TicketHookCallBack(View): + def post(self, request, *args, **kwargs): + """ + 工单hook回调,用于hoot请求后,被请求方执行完任务后回调loonflow,以触发工单继续流转 + :param request: + :param args: + :param kwargs: + :return: + """ + ticket_id = kwargs.get('ticket_id') + json_str = request.body.decode('utf-8') + if not json_str: + return api_response(-1, 'post参数为空', {}) + request_data_dict = json.loads(json_str) + # {"result":true, "msg":"", field_value:{"xx":1,"bb":2}} + app_name = request.META.get('HTTP_APPNAME') + + result, msg = TicketBaseService().hook_call_back(ticket_id, app_name, request_data_dict) + if result: + code, msg, data = 0, 'add ticket comment successful', '' + else: + code, msg, data = -1, msg, '' + return api_response(code, msg, data) diff --git a/service/common/common_service.py b/service/common/common_service.py index fe9a874e..246059c5 100644 --- a/service/common/common_service.py +++ b/service/common/common_service.py @@ -50,6 +50,20 @@ def gen_signature(cls, app_name): tar_str = hashlib.md5(ori_str.encode(encoding='utf-8')).hexdigest() return True, dict(signature=tar_str, timestamp=timestamp) + @classmethod + @auto_log + def gen_hook_signature(cls, token): + """ + 生成hook签名 + :param token: + :return: + """ + timestamp = str(int(time.time())) + ori_str = timestamp + token + tar_str = hashlib.md5(ori_str.encode(encoding='utf-8')).hexdigest() + return True, dict(signature=tar_str, timestamp=timestamp) + + @classmethod @auto_log def get_model_field(cls, app_name, model_name): diff --git a/service/common/constant_service.py b/service/common/constant_service.py index bd972f24..283b9c7a 100644 --- a/service/common/constant_service.py +++ b/service/common/constant_service.py @@ -21,6 +21,7 @@ def __init__(self): self.PARTICIPANT_TYPE_FIELD = 7 # 工单字段(用户名类型的) self.PARTICIPANT_TYPE_PARENT_FIELD = 8 # 父工单字段(用户名类型的) self.PARTICIPANT_TYPE_MULTI_ALL = 9 # 多人全部处理(处理人为多个,且每个人都需要处理),当状态处理人配置为全部处理,且处理人数大于1时,实际的处理人类型则为此 + self.PARTICIPANT_TYPE_HOOK = 10 # hook方式,当工单状态叨叨处理人类型配置为kook的状态时,loonflow将触发一个hook请求,被请求方可以执行有些自动化操作然后回调loonflow, self.TRANSITION_TYPE_COMMON = 1 # 常规流转 self.TRANSITION_TYPE_TIMER = 2 # 定时器流转 diff --git a/service/ticket/ticket_base_service.py b/service/ticket/ticket_base_service.py index bfbefea7..20329220 100644 --- a/service/ticket/ticket_base_service.py +++ b/service/ticket/ticket_base_service.py @@ -299,6 +299,14 @@ def new_ticket(cls, request_data_dict, app_name=''): from tasks import run_flow_task # 放在文件开头会存在循环引用 run_flow_task.apply_async(args=[new_ticket_obj.id, destination_participant, destination_state_id], queue='loonflow') + # 如果下个状态是hook,开始触发hook + if destination_participant_type_id == CONSTANT_SERVICE.PARTICIPANT_TYPE_HOOK: + # 因为工单基础表中不保存hook配置,所以从状态表中获取 + state_obj, msg = WorkflowStateService.get_workflow_state_by_id(new_ticket_obj.state_id) + from tasks import flow_hook_task # 放在文件开头会存在循环引用 + flow_hook_task.apply_async(args=[new_ticket_obj.id], queue='loonflow') + + # 定时器处理逻辑 cls.handle_timer_transition(new_ticket_obj.id, destination_state_id) @@ -837,13 +845,14 @@ def get_ticket_format_participant_info(cls, ticket_id): @classmethod @auto_log - def ticket_handle_permission_check(cls, ticket_id, username, by_timer=False, by_task=False): + def ticket_handle_permission_check(cls, ticket_id, username, by_timer=False, by_task=False, by_hook=False): """ 处理权限校验: 获取当前状态是否需要处理, 该用户是否有权限处理 :param ticket_id: :param username: :param by_timer:是否为定时器流转 :param by_task:是否为通过脚本流转 + :param by_hook:是否hook回调触发的流转 :return: """ ticket_obj = TicketRecord.objects.filter(id=ticket_id, is_deleted=0).first() @@ -862,6 +871,9 @@ def ticket_handle_permission_check(cls, ticket_id, username, by_timer=False, by_ if by_task and username == 'loonrobot': # 脚本流转,有权限 return True, dict(need_accept=False, in_add_node=False, msg='脚本流转,放开处理权限') + if by_hook and username == 'loonrobot': + # hook触发流转,有权限 + return True, dict(need_accept=False, in_add_node=False, msg='hook触发流转,放开处理权限') participant_type_id = ticket_obj.participant_type_id participant = ticket_obj.participant @@ -962,7 +974,7 @@ def get_ticket_transition(cls, ticket_id, username): @classmethod @auto_log - def handle_ticket(cls, ticket_id, request_data_dict, by_timer=False, by_task=False): + def handle_ticket(cls, ticket_id, request_data_dict, by_timer=False, by_task=False, by_hook=False): """ 处理工单:校验必填参数,获取当前状态必填字段,更新工单基础字段,更新工单自定义字段, 更新工单流转记录,执行必要的脚本,通知消息 此处逻辑和新建工单有较多重复,下个版本会拆出来 @@ -970,6 +982,7 @@ def handle_ticket(cls, ticket_id, request_data_dict, by_timer=False, by_task=Fal :param request_data_dict: :param by_timer: 是否通过定时器触发的流转 :param by_task: 是否通过脚本执行完成后触发的流转 + :param by_hook: 是否hook回调用触发流转 :return: """ transition_id = request_data_dict.get('transition_id', '') @@ -984,7 +997,7 @@ def handle_ticket(cls, ticket_id, request_data_dict, by_timer=False, by_task=Fal return False, '工单不存在或已被删除' # 判断用户是否有权限处理该工单 - has_permission, msg = cls.ticket_handle_permission_check(ticket_id, username, by_timer, by_task) + has_permission, msg = cls.ticket_handle_permission_check(ticket_id, username, by_timer, by_task, by_hook) if not has_permission: return False, msg if msg['need_accept']: @@ -1126,6 +1139,11 @@ def handle_ticket(cls, ticket_id, request_data_dict, by_timer=False, by_task=Fal from tasks import run_flow_task # 放在文件开头会存在循环引用 run_flow_task.apply_async(args=[ticket_id, destination_participant, destination_state_id], queue='loonflow') + # 如果下个状态是hook,开始触发hook + if destination_participant_type_id == CONSTANT_SERVICE.PARTICIPANT_TYPE_HOOK: + from tasks import flow_hook_task # 放在文件开头会存在循环引用 + flow_hook_task.apply_async(args=[ticket_id], queue='loonflow') + return True, '' @classmethod @@ -1565,7 +1583,7 @@ def get_ticket_all_field_value(cls, ticket_id): @auto_log def retry_ticket_script(cls, ticket_id, username): """ - 重新执行工单脚本 + 重新执行工单脚本,或重新触发hook :param ticket_id: :return: """ @@ -1573,14 +1591,23 @@ def retry_ticket_script(cls, ticket_id, username): ticket_obj = TicketRecord.objects.filter(id=ticket_id, is_deleted=0).first() if not ticket_obj: return False, 'Ticket is not existed or has been deleted' - if ticket_obj.participant_type_id is not CONSTANT_SERVICE.PARTICIPANT_TYPE_ROBOT: - return False, "The ticket's participant_type is not robot, do not allow retry" - # 先重置上次执行结果 - ticket_obj.script_run_last_result = True - ticket_obj.save() + # if ticket_obj.participant_type_id is not CONSTANT_SERVICE.PARTICIPANT_TYPE_ROBOT: + # return False, "The ticket's participant_type is not robot, do not allow retry" + + if ticket_obj.participant_type_id == CONSTANT_SERVICE.PARTICIPANT_TYPE_ROBOT: + # 先重置上次执行结果 + ticket_obj.script_run_last_result = True + ticket_obj.save() + from tasks import run_flow_task # 放在文件开头会存在循环引用问题 + run_flow_task.apply_async(args=[ticket_id, ticket_obj.participant, ticket_obj.state_id, '{}_retry'.format(username)], queue='loonflow') + elif ticket_obj.participant_type_id == CONSTANT_SERVICE.PARTICIPANT_TYPE_HOOK: + ticket_obj.script_run_last_result = True + ticket_obj.save() + from tasks import flow_hook_task + flow_hook_task.apply_async(args=[ticket_id], queue='loonflow') + else: + return False, "The ticket's participant_type is not robot or hook, do not allow retry" - from tasks import run_flow_task # 放在文件开头会存在循环引用问题 - run_flow_task.apply_async(args=[ticket_id, ticket_obj.participant, ticket_obj.state_id, '{}_retry'.format(username)], queue='loonflow') @classmethod @auto_log @@ -1685,6 +1712,10 @@ def get_ticket_state_participant_info(cls, state_id, ticket_id=0, ticket_req_dic if len(approver.split(',')) > 1: destination_participant_type_id = CONSTANT_SERVICE.PARTICIPANT_TYPE_MULTI destination_participant = approver + + elif participant_type_id == CONSTANT_SERVICE.PARTICIPANT_TYPE_HOOK: + destination_participant = '***' # 敏感数据,不保存工单基础表中 + if destination_participant_type_id in (CONSTANT_SERVICE.PARTICIPANT_TYPE_MULTI, CONSTANT_SERVICE.PARTICIPANT_TYPE_DEPT, CONSTANT_SERVICE.PARTICIPANT_TYPE_ROLE) \ and state_obj.distribute_type_id in (CONSTANT_SERVICE.STATE_DISTRIBUTE_TYPE_RANDOM, CONSTANT_SERVICE.STATE_DISTRIBUTE_TYPE_ALL): # 处理人为角色,部门,或者角色都可能是为多个人,需要根据状态的分配方式计算实际的处理人 @@ -1821,3 +1852,45 @@ def add_comment(cls, ticket_id=0, username='', suggestion=''): return False, msg return True, '' + @classmethod + @auto_log + def hook_call_back(cls, ticket_id, app_name, request_data_dict): + """ + hook回调 + :param ticket_id: + :param app_name: + :param request_data_dict: + :return: + """ + # 校验请求app_name是否有hook回调该工单权限 + flag, msg = AccountBaseService().app_ticket_permission_check(app_name, ticket_id) + if not flag: + return False, msg + ticket_obj = TicketRecord.objects.filter(id=ticket_id, is_deleted=0).first() + + # 检查工单处理人类型为hook中 + if ticket_obj.participant_type_id != CONSTANT_SERVICE.PARTICIPANT_TYPE_HOOK: + return False, '工单当前处理人类型非hook,不执行回调操作' + + result = request_data_dict.get('result', True) + msg = request_data_dict.get('msg', '') + field_value = request_data_dict.get('field_value', {}) # 用于更新字段 + + if result is False: + # hook执行失败了,记录失败状态.以便允许下次再执行 + cls.update_ticket_field_value({'script_run_last_result': False}) + return True, '' + + state_id = ticket_obj.state_id + transition_queryset, msg = WorkflowTransitionService().get_state_transition_queryset(state_id) + transition_id = transition_queryset[0] # hook状态只支持一个流转 + + new_request_dict = field_value + + new_request_dict.update({'transition_id': transition_id, 'suggestion': msg, 'username': 'loonrobot'}) + + # 执行流转 + flag, msg = cls.handle_ticket(ticket_id, new_request_dict, by_timer=False, by_task=False) + if not flag: + return False, msg + return True, '' diff --git a/tasks.py b/tasks.py index 59e9a287..bbf63f06 100644 --- a/tasks.py +++ b/tasks.py @@ -22,12 +22,15 @@ # Load task modules from all registered Django app configs. app.autodiscover_tasks() - +import json +import requests from apps.ticket.models import TicketRecord from apps.workflow.models import Transition, State, WorkflowScript, Workflow, CustomNotice from service.account.account_base_service import AccountBaseService from service.common.constant_service import CONSTANT_SERVICE from service.ticket.ticket_base_service import TicketBaseService +from service.common.common_service import CommonService +from service.workflow.workflow_transition_service import WorkflowTransitionService from django.conf import settings try: @@ -221,3 +224,78 @@ def send_ticket_notice(ticket_id): script_result = False script_result_msg = e.__str__() return script_result, script_result_msg + + +@app.task +def flow_hook_task(ticket_id): + """ + hook 任务 + :param ticket_id: + :return: + """ + # 查询工单状态 + ticket_obj = TicketRecord.objects.filter(id=ticket_id, is_deleted=0).first() + state_id = ticket_obj.state_id + state_obj = State.objects.filter(id=state_id, is_deleted=0).first() + + participant_type_id = state_obj.participant_type_id + if participant_type_id != CONSTANT_SERVICE.PARTICIPANT_TYPE_HOOK: + return False, '' + hook_config = state_obj.participant + hook_config_dict= json.loads(hook_config) + hook_url = hook_config_dict.get('hook_url') + hook_token = hook_config_dict.get('hook_token') + wait = hook_config_dict.get('wait') + + flag, msg = CommonService().gen_hook_signature(hook_token) + if not flag: + return False, msg + r = requests.post(hook_url, headers=msg, timeout=10) + result = r.json() + if result.get('code') == 0: + # 调用成功 + if wait: + all_ticket_data, msg = TicketBaseService().get_ticket_all_field_value(ticket_id) + # date等格式需要转换为str + for key, value in all_ticket_data.items(): + if type(value) not in [int, str, bool, float]: + all_ticket_data[key] = str(all_ticket_data[key]) + + all_ticket_data_json = json.dumps(all_ticket_data) + TicketBaseService().add_ticket_flow_log(dict(ticket_id=ticket_id, transition_id=0, + suggestion=result.get('msg'), + participant_type_id=CONSTANT_SERVICE.PARTICIPANT_TYPE_HOOK, + participant='hook', state_id=state_id, + ticket_data=all_ticket_data_json, + creator='loonrobot' + )) + return True, '' + else: + # 不等待hook目标回调,直接流转 + transition_queryset, msg = WorkflowTransitionService().get_state_transition_queryset(state_id) + transition_id = transition_queryset[0] # hook状态只支持一个流转 + + new_request_dict = {} + new_request_dict.update({'transition_id': transition_id, 'suggestion': msg, 'username': 'loonrobot'}) + # 执行流转 + flag, msg = TicketBaseService().handle_ticket(ticket_id, new_request_dict, by_hook=True) + if not flag: + return False, msg + + else: + TicketBaseService().update_ticket_field_value({'script_run_last_result': False}) + + all_ticket_data, msg = TicketBaseService().get_ticket_all_field_value(ticket_id) + # date等格式需要转换为str + for key, value in all_ticket_data.items(): + if type(value) not in [int, str, bool, float]: + all_ticket_data[key] = str(all_ticket_data[key]) + + all_ticket_data_json = json.dumps(all_ticket_data) + TicketBaseService().add_ticket_flow_log(dict(ticket_id=ticket_id, transition_id=0, + suggestion=result.get('msg'), + participant_type_id=CONSTANT_SERVICE.PARTICIPANT_TYPE_HOOK, + participant='hook', state_id=state_id, ticket_data=all_ticket_data_json, + creator='loonrobot' + )) + diff --git a/templates/workflow/workflow_manage_edit.html b/templates/workflow/workflow_manage_edit.html index 382ab8af..817a0ec8 100644 --- a/templates/workflow/workflow_manage_edit.html +++ b/templates/workflow/workflow_manage_edit.html @@ -287,18 +287,21 @@ + -

如果你需要的类型不在此范围内,可以选择字符型或者文本域,然后指定label字段,实现自定义

+

初始状态的处理人类型和处理人和选择无和留空(状态的处理人仅供状态变化时确定新的处理人用,不会作为流转时目的状态,所以无需配置), + 结束状态处理人类型和处理人也请选择无和留空,因为结束状态无需人再处理

-

可以为空(无处理人的情况,如结束状态)、username\多个username(以,隔开)\部门id\角色id\变量(creator:工单的创建人,creator_tl:工单创建人的TL)\脚本记录的id等,包含子工作流的需要设置处理人为loonrobot

+

可以为空(无处理人的情况,如结束状态)、username\多个username(以,隔开)\部门id\角色id\变量(creator:工单的创建人,creator_tl:工单创建人的TL)\脚本记录的id等,包含子工作流的需要设置处理人为loonrobot。 当处理人类型为hook方式时,处理人需要按照如下规则配置 + {"hook_url":"http://xxx.com/xxx", "hook_token":"xxxx", "wait":true}。详见https://github.com/blackholll/loonflow/wiki 中新建状态