From 7e2f81a418c3141b387e27be827076d2a48a503e Mon Sep 17 00:00:00 2001 From: fit2bot <68588906+fit2bot@users.noreply.github.com> Date: Thu, 23 Jun 2022 13:52:28 +0800 Subject: [PATCH] =?UTF-8?q?perf:=20=E9=87=8D=E6=9E=84=20ticket=20(#8281)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf: 重构 ticket * perf: 优化 tickets * perf: 暂存 * perf: 建立 ticket model * perf: 暂存一下 * perf: 修改 tickets * perf: 修改 import * perf: 修改model * perf: 暂存一波 * perf: 修改... * del process_map field * 工单重构 * 资产 应用对接前端 * perf: 修改 ticket * fix: bug * 修改迁移文件 * 添加其他api * 去掉process_map * perf: 优化去掉 signal * perf: 修改这里 * 修改一点 * perf: 修改工单 * perf: 修改状态 * perf: 修改工单流转 * step 状态切换 * perf: 修改 ticket open * perf: 修改流程 * perf: stash it * 改又改 * stash it * perf: stash * stash * migrate * perf migrate * 调整一下 * 修复bug * 修改一点 * 修改一点 * 优化一波 * perf: ticket migrations Co-authored-by: ibuler Co-authored-by: feng626 <1304903146@qq.com> --- apps/acls/api/login_asset_check.py | 4 +- apps/acls/models/login_acl.py | 35 +- apps/acls/models/login_asset_acl.py | 19 +- apps/assets/api/cmd_filter.py | 2 +- apps/assets/models/cmd_filter.py | 25 +- apps/authentication/api/login_confirm.py | 2 +- apps/authentication/mixins.py | 15 +- .../serializers/connect_token.py | 8 +- apps/authentication/views/login.py | 3 +- apps/common/db/encoder.py | 24 +- apps/common/db/models.py | 27 ++ apps/common/utils/common.py | 4 + apps/terminal/utils.py | 2 +- apps/tickets/api/__init__.py | 1 + apps/tickets/api/flow.py | 31 ++ apps/tickets/api/ticket.py | 83 ++-- apps/tickets/const.py | 27 +- apps/tickets/filters.py | 38 +- apps/tickets/handler/apply_application.py | 108 ----- apps/tickets/handler/apply_asset.py | 106 ----- apps/tickets/handler/base.py | 135 ------ apps/tickets/handler/command_confirm.py | 33 -- apps/tickets/handler/login_asset_confirm.py | 21 - .../tickets/{handler => handlers}/__init__.py | 2 +- apps/tickets/handlers/apply_application.py | 63 +++ apps/tickets/handlers/apply_asset.py | 64 +++ apps/tickets/handlers/base.py | 81 ++++ apps/tickets/handlers/command_confirm.py | 6 + apps/tickets/{handler => handlers}/general.py | 0 apps/tickets/handlers/login_asset_confirm.py | 6 + .../{handler => handlers}/login_confirm.py | 9 +- .../migrations/0007_auto_20201224_1821.py | 6 +- .../migrations/0016_auto_20220609_1758.py | 189 +++++++++ .../migrations/0017_auto_20220623_1027.py | 339 +++++++++++++++ apps/tickets/models/comment.py | 8 + apps/tickets/models/flow.py | 9 +- apps/tickets/models/ticket.py | 328 --------------- apps/tickets/models/ticket/__init__.py | 6 + .../models/ticket/apply_application.py | 34 ++ apps/tickets/models/ticket/apply_asset.py | 28 ++ apps/tickets/models/ticket/command_confirm.py | 33 ++ apps/tickets/models/ticket/general.py | 389 ++++++++++++++++++ .../models/ticket/login_asset_confirm.py | 21 + apps/tickets/models/ticket/login_confirm.py | 12 + apps/tickets/notifications.py | 71 +++- apps/tickets/serializers/__init__.py | 3 +- apps/tickets/serializers/flow.py | 103 +++++ apps/tickets/serializers/super_ticket.py | 6 +- apps/tickets/serializers/ticket/__init__.py | 7 +- .../serializers/ticket/apply_application.py | 57 +++ .../tickets/serializers/ticket/apply_asset.py | 70 ++++ .../serializers/ticket/command_confirm.py | 16 + apps/tickets/serializers/ticket/common.py | 63 +++ .../serializers/ticket/login_asset_confirm.py | 14 + .../serializers/ticket/login_confirm.py | 14 + .../serializers/ticket/meta/__init__.py | 1 - apps/tickets/serializers/ticket/meta/meta.py | 43 -- .../ticket/meta/ticket_type/__init__.py | 0 .../meta/ticket_type/apply_application.py | 93 ----- .../ticket/meta/ticket_type/apply_asset.py | 92 ----- .../meta/ticket_type/command_confirm.py | 26 -- .../ticket/meta/ticket_type/common.py | 30 -- .../meta/ticket_type/login_asset_confirm.py | 21 - .../ticket/meta/ticket_type/login_confirm.py | 25 -- apps/tickets/serializers/ticket/ticket.py | 184 +-------- apps/tickets/signal_handlers/ticket.py | 25 +- apps/tickets/signals.py | 3 +- .../templates/tickets/_base_ticket_body.html | 20 - .../templates/tickets/_msg_ticket.html | 10 +- .../tickets/approve_check_password.html | 12 +- apps/tickets/urls/api_urls.py | 5 + apps/tickets/utils.py | 17 +- apps/tickets/views/approve.py | 4 +- 73 files changed, 2004 insertions(+), 1417 deletions(-) create mode 100644 apps/tickets/api/flow.py delete mode 100644 apps/tickets/handler/apply_application.py delete mode 100644 apps/tickets/handler/apply_asset.py delete mode 100644 apps/tickets/handler/base.py delete mode 100644 apps/tickets/handler/command_confirm.py delete mode 100644 apps/tickets/handler/login_asset_confirm.py rename apps/tickets/{handler => handlers}/__init__.py (70%) create mode 100644 apps/tickets/handlers/apply_application.py create mode 100644 apps/tickets/handlers/apply_asset.py create mode 100644 apps/tickets/handlers/base.py create mode 100644 apps/tickets/handlers/command_confirm.py rename apps/tickets/{handler => handlers}/general.py (100%) create mode 100644 apps/tickets/handlers/login_asset_confirm.py rename apps/tickets/{handler => handlers}/login_confirm.py (65%) create mode 100644 apps/tickets/migrations/0016_auto_20220609_1758.py create mode 100644 apps/tickets/migrations/0017_auto_20220623_1027.py delete mode 100644 apps/tickets/models/ticket.py create mode 100644 apps/tickets/models/ticket/__init__.py create mode 100644 apps/tickets/models/ticket/apply_application.py create mode 100644 apps/tickets/models/ticket/apply_asset.py create mode 100644 apps/tickets/models/ticket/command_confirm.py create mode 100644 apps/tickets/models/ticket/general.py create mode 100644 apps/tickets/models/ticket/login_asset_confirm.py create mode 100644 apps/tickets/models/ticket/login_confirm.py create mode 100644 apps/tickets/serializers/flow.py create mode 100644 apps/tickets/serializers/ticket/apply_application.py create mode 100644 apps/tickets/serializers/ticket/apply_asset.py create mode 100644 apps/tickets/serializers/ticket/command_confirm.py create mode 100644 apps/tickets/serializers/ticket/common.py create mode 100644 apps/tickets/serializers/ticket/login_asset_confirm.py create mode 100644 apps/tickets/serializers/ticket/login_confirm.py delete mode 100644 apps/tickets/serializers/ticket/meta/__init__.py delete mode 100644 apps/tickets/serializers/ticket/meta/meta.py delete mode 100644 apps/tickets/serializers/ticket/meta/ticket_type/__init__.py delete mode 100644 apps/tickets/serializers/ticket/meta/ticket_type/apply_application.py delete mode 100644 apps/tickets/serializers/ticket/meta/ticket_type/apply_asset.py delete mode 100644 apps/tickets/serializers/ticket/meta/ticket_type/command_confirm.py delete mode 100644 apps/tickets/serializers/ticket/meta/ticket_type/common.py delete mode 100644 apps/tickets/serializers/ticket/meta/ticket_type/login_asset_confirm.py delete mode 100644 apps/tickets/serializers/ticket/meta/ticket_type/login_confirm.py delete mode 100644 apps/tickets/templates/tickets/_base_ticket_body.html diff --git a/apps/acls/api/login_asset_check.py b/apps/acls/api/login_asset_check.py index fc5f4157f..a7e8990c7 100644 --- a/apps/acls/api/login_asset_check.py +++ b/apps/acls/api/login_asset_check.py @@ -47,7 +47,7 @@ class LoginAssetCheckAPI(CreateAPIView): asset=self.serializer.asset, system_user=self.serializer.system_user, assignees=acl.reviewers.all(), - org_id=self.serializer.org.id + org_id=self.serializer.org.id, ) confirm_status_url = reverse( view_name='api-tickets:super-ticket-status', @@ -59,7 +59,7 @@ class LoginAssetCheckAPI(CreateAPIView): external=True, api_to_ui=True ) ticket_detail_url = '{url}?type={type}'.format(url=ticket_detail_url, type=ticket.type) - ticket_assignees = ticket.current_node.first().ticket_assignees.all() + ticket_assignees = ticket.current_step.ticket_assignees.all() data = { 'check_confirm_status': {'method': 'GET', 'url': confirm_status_url}, 'close_confirm': {'method': 'DELETE', 'url': confirm_status_url}, diff --git a/apps/acls/models/login_acl.py b/apps/acls/models/login_acl.py index f9b24e426..1887ecd33 100644 --- a/apps/acls/models/login_acl.py +++ b/apps/acls/models/login_acl.py @@ -97,34 +97,25 @@ class LoginACL(BaseACL): return allow, reject_type - @staticmethod - def construct_confirm_ticket_meta(request=None): + def create_confirm_ticket(self, request): + from tickets import const + from tickets.models import ApplyLoginTicket + from orgs.models import Organization + title = _('Login confirm') + ' {}'.format(self.user) login_ip = get_request_ip(request) if request else '' login_ip = login_ip or '0.0.0.0' login_city = get_ip_city(login_ip) login_datetime = local_now_display() - ticket_meta = { - 'apply_login_ip': login_ip, - 'apply_login_city': login_city, - 'apply_login_datetime': login_datetime, - } - return ticket_meta - - def create_confirm_ticket(self, request=None): - from tickets import const - from tickets.models import Ticket - from orgs.models import Organization - ticket_title = _('Login confirm') + ' {}'.format(self.user) - ticket_meta = self.construct_confirm_ticket_meta(request) data = { - 'title': ticket_title, - 'type': const.TicketType.login_confirm.value, - 'meta': ticket_meta, + 'title': title, + 'type': const.TicketType.login_confirm, + 'applicant': self.user, + 'apply_login_city': login_city, + 'apply_login_ip': login_ip, + 'apply_login_datetime': login_datetime, 'org_id': Organization.ROOT_ID, } - ticket = Ticket.objects.create(**data) - applicant = self.user + ticket = ApplyLoginTicket.objects.create(**data) assignees = self.reviewers.all() - ticket.create_process_map_and_node(assignees, applicant) - ticket.open(applicant) + ticket.open_by_system(assignees) return ticket diff --git a/apps/acls/models/login_asset_acl.py b/apps/acls/models/login_asset_acl.py index dda9d97c1..9cf7989f9 100644 --- a/apps/acls/models/login_asset_acl.py +++ b/apps/acls/models/login_asset_acl.py @@ -85,19 +85,18 @@ class LoginAssetACL(BaseACL, OrgModelMixin): @classmethod def create_login_asset_confirm_ticket(cls, user, asset, system_user, assignees, org_id): from tickets.const import TicketType - from tickets.models import Ticket + from tickets.models import ApplyLoginAssetTicket + title = _('Login asset confirm') + ' ({})'.format(user) data = { - 'title': _('Login asset confirm') + ' ({})'.format(user), + 'title': title, 'type': TicketType.login_asset_confirm, - 'meta': { - 'apply_login_user': str(user), - 'apply_login_asset': str(asset), - 'apply_login_system_user': str(system_user), - }, + 'applicant': user, + 'apply_login_user': user, + 'apply_login_asset': asset, + 'apply_login_system_user': system_user, 'org_id': org_id, } - ticket = Ticket.objects.create(**data) - ticket.create_process_map_and_node(assignees, user) - ticket.open(applicant=user) + ticket = ApplyLoginAssetTicket.objects.create(**data) + ticket.open_by_system(assignees) return ticket diff --git a/apps/assets/api/cmd_filter.py b/apps/assets/api/cmd_filter.py index dcb2d77c9..0e09d5c73 100644 --- a/apps/assets/api/cmd_filter.py +++ b/apps/assets/api/cmd_filter.py @@ -69,7 +69,7 @@ class CommandConfirmAPI(CreateAPIView): external=True, api_to_ui=True ) ticket_detail_url = '{url}?type={type}'.format(url=ticket_detail_url, type=ticket.type) - ticket_assignees = ticket.current_node.first().ticket_assignees.all() + ticket_assignees = ticket.current_step.ticket_assignees.all() return { 'check_confirm_status': {'method': 'GET', 'url': confirm_status_url}, 'close_confirm': {'method': 'DELETE', 'url': confirm_status_url}, diff --git a/apps/assets/models/cmd_filter.py b/apps/assets/models/cmd_filter.py index c92a25109..235a4f331 100644 --- a/apps/assets/models/cmd_filter.py +++ b/apps/assets/models/cmd_filter.py @@ -165,26 +165,23 @@ class CommandFilterRule(OrgModelMixin): def create_command_confirm_ticket(self, run_command, session, cmd_filter_rule, org_id): from tickets.const import TicketType - from tickets.models import Ticket + from tickets.models import ApplyCommandTicket data = { 'title': _('Command confirm') + ' ({})'.format(session.user), 'type': TicketType.command_confirm, - 'meta': { - 'apply_run_user': session.user, - 'apply_run_asset': session.asset, - 'apply_run_system_user': session.system_user, - 'apply_run_command': run_command, - 'apply_from_session_id': str(session.id), - 'apply_from_cmd_filter_rule_id': str(cmd_filter_rule.id), - 'apply_from_cmd_filter_id': str(cmd_filter_rule.filter.id) - }, + 'applicant': session.user_obj, + 'apply_run_user_id': session.user_id, + 'apply_run_asset_id': session.asset_id, + 'apply_run_system_user_id': session.system_user_id, + 'apply_run_command': run_command, + 'apply_from_session_id': str(session.id), + 'apply_from_cmd_filter_rule_id': str(cmd_filter_rule.id), + 'apply_from_cmd_filter_id': str(cmd_filter_rule.filter.id), 'org_id': org_id, } - ticket = Ticket.objects.create(**data) - applicant = session.user_obj + ticket = ApplyCommandTicket.objects.create(**data) assignees = self.reviewers.all() - ticket.create_process_map_and_node(assignees, applicant) - ticket.open(applicant) + ticket.open_by_system(assignees) return ticket @classmethod diff --git a/apps/authentication/api/login_confirm.py b/apps/authentication/api/login_confirm.py index 9adedb1e0..22594b88e 100644 --- a/apps/authentication/api/login_confirm.py +++ b/apps/authentication/api/login_confirm.py @@ -25,5 +25,5 @@ class TicketStatusApi(mixins.AuthMixin, APIView): ticket = self.get_ticket() if ticket: request.session.pop('auth_ticket_id', '') - ticket.close(processor=self.get_user_from_session()) + ticket.close() return Response('', status=200) diff --git a/apps/authentication/mixins.py b/apps/authentication/mixins.py index 2f4f1d6e0..f1989b181 100644 --- a/apps/authentication/mixins.py +++ b/apps/authentication/mixins.py @@ -337,18 +337,18 @@ class AuthACLMixin: raise errors.TimePeriodNotAllowed(username=user.username, request=self.request) def get_ticket(self): - from tickets.models import Ticket + from tickets.models import ApplyLoginTicket ticket_id = self.request.session.get("auth_ticket_id") logger.debug('Login confirm ticket id: {}'.format(ticket_id)) if not ticket_id: ticket = None else: - ticket = Ticket.all().filter(id=ticket_id).first() + ticket = ApplyLoginTicket.all().filter(id=ticket_id).first() return ticket def get_ticket_or_create(self, confirm_setting): ticket = self.get_ticket() - if not ticket or ticket.status_closed: + if not ticket or ticket.is_status(ticket.Status.closed): ticket = confirm_setting.create_confirm_ticket(self.request) self.request.session['auth_ticket_id'] = str(ticket.id) return ticket @@ -357,16 +357,17 @@ class AuthACLMixin: ticket = self.get_ticket() if not ticket: raise errors.LoginConfirmOtherError('', "Not found") - if ticket.status_open: + + if ticket.is_status(ticket.Status.open): raise errors.LoginConfirmWaitError(ticket.id) - elif ticket.state_approve: + elif ticket.is_state(ticket.State.approved): self.request.session["auth_confirm"] = "1" return - elif ticket.state_reject: + elif ticket.is_state(ticket.State.rejected): raise errors.LoginConfirmOtherError( ticket.id, ticket.get_state_display() ) - elif ticket.state_close: + elif ticket.is_state(ticket.State.closed): raise errors.LoginConfirmOtherError( ticket.id, ticket.get_state_display() ) diff --git a/apps/authentication/serializers/connect_token.py b/apps/authentication/serializers/connect_token.py index d9694b5bd..36fc024b1 100644 --- a/apps/authentication/serializers/connect_token.py +++ b/apps/authentication/serializers/connect_token.py @@ -86,7 +86,10 @@ class ConnectionTokenAssetSerializer(serializers.ModelSerializer): class ConnectionTokenSystemUserSerializer(serializers.ModelSerializer): class Meta: model = SystemUser - fields = ['id', 'name', 'username', 'password', 'private_key', 'protocol', 'ad_domain', 'org_id'] + fields = [ + 'id', 'name', 'username', 'password', 'private_key', + 'protocol', 'ad_domain', 'org_id' + ] class ConnectionTokenGatewaySerializer(serializers.ModelSerializer): @@ -122,8 +125,7 @@ class ConnectionTokenFilterRuleSerializer(serializers.ModelSerializer): model = CommandFilterRule fields = [ 'id', 'type', 'content', 'ignore_case', 'pattern', - 'priority', 'action', - 'date_created', + 'priority', 'action', 'date_created', ] diff --git a/apps/authentication/views/login.py b/apps/authentication/views/login.py index 3b8f2c60c..e6fe6b6cf 100644 --- a/apps/authentication/views/login.py +++ b/apps/authentication/views/login.py @@ -282,8 +282,7 @@ class UserLoginWaitConfirmView(TemplateView): if ticket: timestamp_created = datetime.datetime.timestamp(ticket.date_created) ticket_detail_url = TICKET_DETAIL_URL.format(id=ticket_id, type=ticket.type) - assignees = ticket.current_node.first().ticket_assignees.all() - assignees_display = ', '.join([str(i.assignee) for i in assignees]) + assignees_display = ', '.join([str(assignee) for assignee in ticket.current_assignees]) msg = _("""Wait for {} confirm, You also can copy link to her/him
Don't close this page""").format(assignees_display) else: diff --git a/apps/common/db/encoder.py b/apps/common/db/encoder.py index 314ea071d..673b8aeca 100644 --- a/apps/common/db/encoder.py +++ b/apps/common/db/encoder.py @@ -1,20 +1,30 @@ import json -from datetime import datetime import uuid +import logging +from datetime import datetime from django.utils.translation import ugettext_lazy as _ +from django.db import models from django.conf import settings +lazy_type = type(_('ugettext_lazy')) + class ModelJSONFieldEncoder(json.JSONEncoder): """ 解决一些类型的字段不能序列化的问题 """ def default(self, obj): - if isinstance(obj, datetime): + str_cls = (models.Model, lazy_type, models.ImageField, uuid.UUID) + if isinstance(obj, str_cls): + return str(obj) + elif isinstance(obj, datetime): return obj.strftime(settings.DATETIME_DISPLAY_FORMAT) - if isinstance(obj, uuid.UUID): - return str(obj) - if isinstance(obj, type(_("ugettext_lazy"))): - return str(obj) + elif isinstance(obj, (list, tuple)) and len(obj) > 0 \ + and isinstance(obj[0], models.Model): + return [str(i) for i in obj] else: - return super().default(obj) + try: + return super().default(obj) + except TypeError: + logging.error('Type error: ', type(obj)) + return str(obj) diff --git a/apps/common/db/models.py b/apps/common/db/models.py index 2989f734e..92b6c4803 100644 --- a/apps/common/db/models.py +++ b/apps/common/db/models.py @@ -13,6 +13,7 @@ import uuid from functools import reduce, partial import inspect +from django.db import transaction from django.db.models import * from django.db.models import QuerySet from django.db.models.functions import Concat @@ -211,3 +212,29 @@ class UnionQuerySet(QuerySet): qs = cls(assets1, assets2) return qs + + +class MultiTableChildQueryset(QuerySet): + + def bulk_create(self, objs, batch_size=None): + assert batch_size is None or batch_size > 0 + if not objs: + return objs + + self._for_write = True + objs = list(objs) + parent_model = self.model._meta.pk.related_model + + parent_objs = [] + for obj in objs: + parent_values = {} + for field in [f for f in parent_model._meta.fields if hasattr(obj, f.name)]: + parent_values[field.name] = getattr(obj, field.name) + parent_objs.append(parent_model(**parent_values)) + setattr(obj, self.model._meta.pk.attname, obj.id) + parent_model.objects.bulk_create(parent_objs, batch_size=batch_size) + + with transaction.atomic(using=self.db, savepoint=False): + self._batched_insert(objs, self.model._meta.local_fields, batch_size) + + return objs diff --git a/apps/common/utils/common.py b/apps/common/utils/common.py index 3982b1349..5b2180ec0 100644 --- a/apps/common/utils/common.py +++ b/apps/common/utils/common.py @@ -361,3 +361,7 @@ def pretty_string(data: str, max_length=128, ellipsis_str='...'): end = data[-half:] data = f'{start}{ellipsis_str}{end}' return data + + +def group_by_count(it, count): + return [it[i:i+count] for i in range(0, len(it), count)] diff --git a/apps/terminal/utils.py b/apps/terminal/utils.py index e6fa17068..abdfbd738 100644 --- a/apps/terminal/utils.py +++ b/apps/terminal/utils.py @@ -12,7 +12,7 @@ from common.utils import get_logger from . import const from .models import ReplayStorage from tickets.models import TicketSession, TicketStep, TicketAssignee -from tickets.const import ProcessStatus +from tickets.const import StepState logger = get_logger(__name__) diff --git a/apps/tickets/api/__init__.py b/apps/tickets/api/__init__.py index 0342771e0..bec6f21d1 100644 --- a/apps/tickets/api/__init__.py +++ b/apps/tickets/api/__init__.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # from .ticket import * +from .flow import * from .comment import * from .super_ticket import * from .relation import * diff --git a/apps/tickets/api/flow.py b/apps/tickets/api/flow.py new file mode 100644 index 000000000..b45479187 --- /dev/null +++ b/apps/tickets/api/flow.py @@ -0,0 +1,31 @@ +from rest_framework.exceptions import MethodNotAllowed + +from tickets import serializers +from tickets.models import TicketFlow +from common.drf.api import JMSBulkModelViewSet + +__all__ = ['TicketFlowViewSet'] + + +class TicketFlowViewSet(JMSBulkModelViewSet): + serializer_class = serializers.TicketFlowSerializer + + filterset_fields = ['id', 'type'] + search_fields = ['id', 'type'] + + def destroy(self, request, *args, **kwargs): + raise MethodNotAllowed(self.action) + + def get_queryset(self): + queryset = TicketFlow.get_org_related_flows() + return queryset + + def perform_create_or_update(self, serializer): + instance = serializer.save() + instance.save() + + def perform_create(self, serializer): + self.perform_create_or_update(serializer) + + def perform_update(self, serializer): + self.perform_create_or_update(serializer) diff --git a/apps/tickets/api/ticket.py b/apps/tickets/api/ticket.py index 00e30f899..747066b0c 100644 --- a/apps/tickets/api/ticket.py +++ b/apps/tickets/api/ticket.py @@ -2,36 +2,43 @@ # from rest_framework import viewsets from rest_framework.decorators import action -from rest_framework.exceptions import MethodNotAllowed from rest_framework.response import Response +from rest_framework.exceptions import MethodNotAllowed from common.const.http import POST, PUT from common.mixins.api import CommonApiMixin -from common.drf.api import JMSBulkModelViewSet +from orgs.utils import tmp_to_root_org from rbac.permissions import RBACPermission from tickets import serializers -from tickets.models import Ticket, TicketFlow -from tickets.filters import TicketFilter +from tickets import filters from tickets.permissions.ticket import IsAssignee, IsApplicant +from tickets.models import ( + Ticket, ApplyAssetTicket, ApplyApplicationTicket, + ApplyLoginTicket, ApplyLoginAssetTicket, ApplyCommandTicket +) -__all__ = ['TicketViewSet', 'TicketFlowViewSet'] +__all__ = [ + 'TicketViewSet', 'ApplyAssetTicketViewSet', 'ApplyApplicationTicketViewSet', + 'ApplyLoginTicketViewSet', 'ApplyLoginAssetTicketViewSet', 'ApplyCommandTicketViewSet' +] class TicketViewSet(CommonApiMixin, viewsets.ModelViewSet): serializer_class = serializers.TicketDisplaySerializer serializer_classes = { - 'open': serializers.TicketApplySerializer, - 'approve': serializers.TicketApproveSerializer, + 'list': serializers.TicketListSerializer, + 'open': serializers.TicketApplySerializer } - filterset_class = TicketFilter + model = Ticket + filterset_class = filters.TicketFilter search_fields = [ 'title', 'type', 'status', 'applicant_display' ] ordering_fields = ( - 'title', 'applicant_display', 'status', 'state', 'action_display', - 'date_created', 'serial_num', + 'title', 'applicant_display', 'status', 'state', + 'action_display', 'date_created', 'serial_num', ) ordering = ('-date_created',) rbac_perms = { @@ -48,15 +55,15 @@ class TicketViewSet(CommonApiMixin, viewsets.ModelViewSet): raise MethodNotAllowed(self.action) def get_queryset(self): - queryset = Ticket.get_user_related_tickets(self.request.user) + with tmp_to_root_org(): + queryset = self.model.get_user_related_tickets(self.request.user) return queryset def perform_create(self, serializer): instance = serializer.save() - applicant = self.request.user - instance.create_related_node(applicant) - instance.process_map = instance.create_process_map(applicant) - instance.open(applicant) + instance.applicant = self.request.user + instance.save(update_fields=['applicant']) + instance.open() @action(detail=False, methods=[POST], permission_classes=[RBACPermission, ]) def open(self, request, *args, **kwargs): @@ -80,29 +87,41 @@ class TicketViewSet(CommonApiMixin, viewsets.ModelViewSet): def close(self, request, *args, **kwargs): instance = self.get_object() serializer = self.get_serializer(instance) - instance.close(processor=request.user) + instance.close() return Response(serializer.data) -class TicketFlowViewSet(JMSBulkModelViewSet): - serializer_class = serializers.TicketFlowSerializer +class ApplyAssetTicketViewSet(TicketViewSet): + serializer_class = serializers.ApplyAssetDisplaySerializer + serializer_classes = { + 'open': serializers.ApplyAssetSerializer + } + model = ApplyAssetTicket + filterset_class = filters.ApplyAssetTicketFilter - filterset_fields = ['id', 'type'] - search_fields = ['id', 'type'] - def destroy(self, request, *args, **kwargs): - raise MethodNotAllowed(self.action) +class ApplyApplicationTicketViewSet(TicketViewSet): + serializer_class = serializers.ApplyApplicationDisplaySerializer + serializer_classes = { + 'open': serializers.ApplyApplicationSerializer + } + model = ApplyApplicationTicket + filterset_class = filters.ApplyApplicationTicketFilter - def get_queryset(self): - queryset = TicketFlow.get_org_related_flows() - return queryset - def perform_create_or_update(self, serializer): - instance = serializer.save() - instance.save() +class ApplyLoginTicketViewSet(TicketViewSet): + serializer_class = serializers.LoginConfirmSerializer + model = ApplyLoginTicket + filterset_class = filters.ApplyLoginTicketFilter - def perform_create(self, serializer): - self.perform_create_or_update(serializer) - def perform_update(self, serializer): - self.perform_create_or_update(serializer) +class ApplyLoginAssetTicketViewSet(TicketViewSet): + serializer_class = serializers.LoginAssetConfirmSerializer + model = ApplyLoginAssetTicket + filterset_class = filters.ApplyLoginAssetTicketFilter + + +class ApplyCommandTicketViewSet(TicketViewSet): + serializer_class = serializers.ApplyCommandConfirmSerializer + model = ApplyCommandTicket + filterset_class = filters.ApplyCommandTicketFilter diff --git a/apps/tickets/const.py b/apps/tickets/const.py index 9bfaf795e..d97e6716e 100644 --- a/apps/tickets/const.py +++ b/apps/tickets/const.py @@ -14,20 +14,29 @@ class TicketType(TextChoices): class TicketState(TextChoices): - open = 'open', _('Open') - approved = 'approved', _('Approved') - rejected = 'rejected', _('Rejected') - closed = 'closed', _('Closed') - - -class ProcessStatus(TextChoices): - notified = 'notified', _('Notified') + pending = 'pending', _('Open') approved = 'approved', _('Approved') rejected = 'rejected', _('Rejected') + closed = 'closed', _("Cancel") + reopen = 'reopen', _("Reopen") class TicketStatus(TextChoices): open = 'open', _("Open") + closed = 'closed', _("Finished") + + +class StepState(TextChoices): + pending = 'pending', _('Pending') + approved = 'approved', _('Approved') + rejected = 'rejected', _('Rejected') + closed = 'closed', _("Closed") + reopen = 'reopen', _("Reopen") + + +class StepStatus(TextChoices): + pending = 'pending', _('Pending') + active = 'active', _('Active') closed = 'closed', _("Closed") @@ -38,7 +47,7 @@ class TicketAction(TextChoices): reject = 'reject', _('Reject') -class TicketApprovalLevel(IntegerChoices): +class TicketLevel(IntegerChoices): one = 1, _("One level") two = 2, _("Two level") diff --git a/apps/tickets/filters.py b/apps/tickets/filters.py index 3c795f0df..255908305 100644 --- a/apps/tickets/filters.py +++ b/apps/tickets/filters.py @@ -1,7 +1,10 @@ from django_filters import rest_framework as filters from common.drf.filters import BaseFilterSet -from tickets.models import Ticket +from tickets.models import ( + Ticket, ApplyAssetTicket, ApplyApplicationTicket, + ApplyLoginTicket, ApplyLoginAssetTicket, ApplyCommandTicket +) class TicketFilter(BaseFilterSet): @@ -10,9 +13,38 @@ class TicketFilter(BaseFilterSet): class Meta: model = Ticket fields = ( - 'id', 'title', 'type', 'status', 'state', 'applicant', 'assignees__id', - 'applicant_display', + 'id', 'title', 'type', 'status', 'state', 'applicant', 'assignees__id' ) def filter_assignees_id(self, queryset, name, value): return queryset.filter(ticket_steps__ticket_assignees__assignee__id=value) + + +class ApplyAssetTicketFilter(BaseFilterSet): + class Meta: + model = ApplyAssetTicket + fields = ('id',) + + +class ApplyApplicationTicketFilter(BaseFilterSet): + class Meta: + model = ApplyApplicationTicket + fields = ('id',) + + +class ApplyLoginTicketFilter(BaseFilterSet): + class Meta: + model = ApplyLoginTicket + fields = ('id',) + + +class ApplyLoginAssetTicketFilter(BaseFilterSet): + class Meta: + model = ApplyLoginAssetTicket + fields = ('id',) + + +class ApplyCommandTicketFilter(BaseFilterSet): + class Meta: + model = ApplyCommandTicket + fields = ('id',) diff --git a/apps/tickets/handler/apply_application.py b/apps/tickets/handler/apply_application.py deleted file mode 100644 index 5e20e89f9..000000000 --- a/apps/tickets/handler/apply_application.py +++ /dev/null @@ -1,108 +0,0 @@ -from django.utils.translation import ugettext as _ - -from orgs.utils import tmp_to_org, tmp_to_root_org -from applications.const import AppCategory, AppType -from applications.models import Application -from perms.models import ApplicationPermission -from assets.models import SystemUser - -from .base import BaseHandler - - -class Handler(BaseHandler): - - def _on_approve(self): - is_finished = super()._on_approve() - if is_finished: - self._create_application_permission() - - # display - def _construct_meta_display_of_open(self): - meta_display_fields = ['apply_category_display', 'apply_type_display'] - apply_category = self.ticket.meta.get('apply_category') - apply_category_display = AppCategory.get_label(apply_category) - apply_type = self.ticket.meta.get('apply_type') - apply_type_display = AppType.get_label(apply_type) - meta_display_values = [apply_category_display, apply_type_display] - meta_display = dict(zip(meta_display_fields, meta_display_values)) - apply_system_users = self.ticket.meta.get('apply_system_users') - apply_applications = self.ticket.meta.get('apply_applications') - with tmp_to_org(self.ticket.org_id): - meta_display.update({ - 'apply_system_users_display': [str(i) for i in SystemUser.objects.filter(id__in=apply_system_users)], - 'apply_applications_display': [str(i) for i in Application.objects.filter(id__in=apply_applications)] - }) - - return meta_display - - # body - def _construct_meta_body_of_open(self): - apply_category_display = self.ticket.meta.get('apply_category_display') - apply_type_display = self.ticket.meta.get('apply_type_display') - apply_applications = self.ticket.meta.get('apply_applications_display', []) - apply_system_users = self.ticket.meta.get('apply_system_users_display', []) - apply_date_start = self.ticket.meta.get('apply_date_start') - apply_date_expired = self.ticket.meta.get('apply_date_expired') - applied_body = '''{}: {}, - {}: {} - {}: {} - {}: {} - {}: {} - {}: {} - '''.format( - _('Applied category'), apply_category_display, - _('Applied type'), apply_type_display, - _('Applied application group'), ','.join(apply_applications), - _('Applied system user group'), ','.join(apply_system_users), - _('Applied date start'), apply_date_start, - _('Applied date expired'), apply_date_expired, - ) - return applied_body - - # permission - def _create_application_permission(self): - with tmp_to_root_org(): - application_permission = ApplicationPermission.objects.filter(id=self.ticket.id).first() - if application_permission: - return application_permission - - apply_category = self.ticket.meta.get('apply_category') - apply_type = self.ticket.meta.get('apply_type') - apply_permission_name = self.ticket.meta.get('apply_permission_name', '') - apply_applications = self.ticket.meta.get('apply_applications', []) - apply_system_users = self.ticket.meta.get('apply_system_users', []) - apply_date_start = self.ticket.meta.get('apply_date_start') - apply_date_expired = self.ticket.meta.get('apply_date_expired') - permission_created_by = '{}:{}'.format( - str(self.ticket.__class__.__name__), str(self.ticket.id) - ) - permission_comment = _( - 'Created by the ticket, ' - 'ticket title: {}, ' - 'ticket applicant: {}, ' - 'ticket processor: {}, ' - 'ticket ID: {}' - ).format( - self.ticket.title, - self.ticket.applicant_display, - ','.join([i['processor_display'] for i in self.ticket.process_map]), - str(self.ticket.id) - ) - permissions_data = { - 'id': self.ticket.id, - 'name': apply_permission_name, - 'from_ticket': True, - 'category': apply_category, - 'type': apply_type, - 'comment': str(permission_comment), - 'created_by': permission_created_by, - 'date_start': apply_date_start, - 'date_expired': apply_date_expired, - } - with tmp_to_org(self.ticket.org_id): - application_permission = ApplicationPermission.objects.create(**permissions_data) - application_permission.users.add(self.ticket.applicant) - application_permission.applications.set(apply_applications) - application_permission.system_users.set(apply_system_users) - - return application_permission diff --git a/apps/tickets/handler/apply_asset.py b/apps/tickets/handler/apply_asset.py deleted file mode 100644 index a13f6e008..000000000 --- a/apps/tickets/handler/apply_asset.py +++ /dev/null @@ -1,106 +0,0 @@ -from django.utils.translation import ugettext as _ - -from assets.models import Node, Asset, SystemUser -from perms.models import AssetPermission, Action -from orgs.utils import tmp_to_org, tmp_to_root_org -from .base import BaseHandler - - -class Handler(BaseHandler): - - def _on_approve(self): - is_finished = super()._on_approve() - if is_finished: - self._create_asset_permission() - - # display - def _construct_meta_display_of_open(self): - meta_display_fields = ['apply_actions_display'] - apply_actions = self.ticket.meta.get('apply_actions', Action.NONE) - apply_actions_display = Action.value_to_choices_display(apply_actions) - meta_display_values = [apply_actions_display] - meta_display = dict(zip(meta_display_fields, meta_display_values)) - apply_nodes = self.ticket.meta.get('apply_nodes', []) - apply_assets = self.ticket.meta.get('apply_assets', []) - apply_system_users = self.ticket.meta.get('apply_system_users') - with tmp_to_org(self.ticket.org_id): - meta_display.update({ - 'apply_nodes_display': [str(i) for i in Node.objects.filter(id__in=apply_nodes)], - 'apply_assets_display': [str(i) for i in Asset.objects.filter(id__in=apply_assets)], - 'apply_system_users_display': [ - str(i) for i in SystemUser.objects.filter(id__in=apply_system_users) - ] - }) - return meta_display - - # body - def _construct_meta_body_of_open(self): - apply_nodes = self.ticket.meta.get('apply_nodes_display', []) - apply_assets = self.ticket.meta.get('apply_assets_display', []) - apply_system_users = self.ticket.meta.get('apply_system_users_display', []) - apply_actions_display = self.ticket.meta.get('apply_actions_display', []) - apply_date_start = self.ticket.meta.get('apply_date_start') - apply_date_expired = self.ticket.meta.get('apply_date_expired') - applied_body = '''{}: {}, - {}: {}, - {}: {}, - {}: {}, - {}: {} - '''.format( - _("Applied node group"), ','.join(apply_nodes), - _("Applied hostname group"), ','.join(apply_assets), - _("Applied system user group"), ','.join(apply_system_users), - _("Applied actions"), ','.join(apply_actions_display), - _('Applied date start'), apply_date_start, - _('Applied date expired'), apply_date_expired, - ) - return applied_body - - # permission - def _create_asset_permission(self): - with tmp_to_root_org(): - asset_permission = AssetPermission.objects.filter(id=self.ticket.id).first() - if asset_permission: - return asset_permission - - apply_permission_name = self.ticket.meta.get('apply_permission_name', ) - apply_nodes = self.ticket.meta.get('apply_nodes', []) - apply_assets = self.ticket.meta.get('apply_assets', []) - apply_system_users = self.ticket.meta.get('apply_system_users', []) - apply_actions = self.ticket.meta.get('apply_actions', Action.NONE) - apply_date_start = self.ticket.meta.get('apply_date_start') - apply_date_expired = self.ticket.meta.get('apply_date_expired') - permission_created_by = '{}:{}'.format( - str(self.ticket.__class__.__name__), str(self.ticket.id) - ) - permission_comment = _( - 'Created by the ticket ' - 'ticket title: {} ' - 'ticket applicant: {} ' - 'ticket processor: {} ' - 'ticket ID: {}' - ).format( - self.ticket.title, - self.ticket.applicant_display, - ','.join([i['processor_display'] for i in self.ticket.process_map]), - str(self.ticket.id) - ) - - permission_data = { - 'id': self.ticket.id, - 'name': apply_permission_name, - 'from_ticket': True, - 'comment': str(permission_comment), - 'created_by': permission_created_by, - 'actions': apply_actions, - 'date_start': apply_date_start, - 'date_expired': apply_date_expired, - } - with tmp_to_org(self.ticket.org_id): - asset_permission = AssetPermission.objects.create(**permission_data) - asset_permission.users.add(self.ticket.applicant) - asset_permission.nodes.set(apply_nodes) - asset_permission.assets.set(apply_assets) - asset_permission.system_users.set(apply_system_users) - - return asset_permission diff --git a/apps/tickets/handler/base.py b/apps/tickets/handler/base.py deleted file mode 100644 index 1855a2f6c..000000000 --- a/apps/tickets/handler/base.py +++ /dev/null @@ -1,135 +0,0 @@ -from django.utils.translation import ugettext as _ -from common.utils import get_logger -from tickets.utils import ( - send_ticket_processed_mail_to_applicant, send_ticket_applied_mail_to_assignees -) -from tickets.const import TicketAction - -logger = get_logger(__name__) - - -class BaseHandler(object): - - def __init__(self, ticket): - self.ticket = ticket - - # on action - def _on_open(self): - self.ticket.applicant_display = str(self.ticket.applicant) - meta_display = getattr(self, '_construct_meta_display_of_open', lambda: {})() - self.ticket.meta.update(meta_display) - self.ticket.save() - self._send_applied_mail_to_assignees() - - def _on_approve(self): - if self.ticket.approval_step != len(self.ticket.process_map): - self._send_processed_mail_to_applicant(self.ticket.processor) - self.ticket.approval_step += 1 - self.ticket.create_related_node() - self._send_applied_mail_to_assignees() - is_finished = False - else: - self.ticket.set_state_approve() - self.ticket.set_status_closed() - self._send_processed_mail_to_applicant(self.ticket.processor) - is_finished = True - - self.ticket.save() - return is_finished - - def _on_reject(self): - self.ticket.set_state_reject() - self.ticket.set_status_closed() - self.__on_process(self.ticket.processor) - - def _on_close(self): - self.ticket.set_state_closed() - self.ticket.set_status_closed() - self.__on_process(self.ticket.processor) - - def __on_process(self, processor): - self._send_processed_mail_to_applicant(processor) - self.ticket.save() - - def dispatch(self, action): - processor = self.ticket.processor - current_node = self.ticket.current_node.first() - self.ticket.process_map[self.ticket.approval_step - 1].update({ - 'approval_date': str(current_node.date_updated), - 'state': current_node.state, - 'processor': processor.id if processor else '', - 'processor_display': str(processor) if processor else '', - }) - self.ticket.save() - self._create_comment_on_action(action) - method = getattr(self, f'_on_{action}', lambda: None) - return method() - - # email - def _send_applied_mail_to_assignees(self): - assignees = self.ticket.current_node.first().ticket_assignees.all() - assignees_display = ', '.join([str(i.assignee) for i in assignees]) - logger.debug('Send applied email to assignees: {}'.format(assignees_display)) - send_ticket_applied_mail_to_assignees(self.ticket) - - def _send_processed_mail_to_applicant(self, processor): - logger.debug('Send processed mail to applicant: {}'.format(self.ticket.applicant_display)) - send_ticket_processed_mail_to_applicant(self.ticket, processor) - - # comments - def _create_comment_on_action(self, action): - user = self.ticket.processor - # 打开或关闭工单,备注显示是自己,其他是受理人 - if action == TicketAction.open or action == TicketAction.close: - user = self.ticket.applicant - user_display = str(user) - action_display = getattr(TicketAction, action).label - data = { - 'body': _('{} {} the ticket').format(user_display, action_display), - 'user': user, - 'user_display': user_display - } - return self.ticket.comments.create(**data) - - # body - body_html_format = ''' - {}: -
{}
- ''' - - def get_body(self): - old_body = self.ticket.meta.get('body') - if old_body: - # 之前版本的body - return old_body - basic_body = self._construct_basic_body() - meta_body = self._construct_meta_body() - return basic_body + meta_body - - def _construct_basic_body(self): - basic_body = ''' - {}: {} - {}: {} - {}: {} - {}: {} - '''.format( - _('Ticket title'), self.ticket.title, - _('Ticket type'), self.ticket.get_type_display(), - _('Ticket status'), self.ticket.get_status_display(), - _('Ticket applicant'), self.ticket.applicant_display, - ).strip() - body = self.body_html_format.format(_("Ticket basic info"), basic_body) - return body - - def _construct_meta_body(self): - body = '' - open_body = self._base_construct_meta_body_of_open().strip() - body += open_body - return body - - def _base_construct_meta_body_of_open(self): - meta_body_of_open = getattr( - self, '_construct_meta_body_of_open', lambda: _('No content') - )().strip() - body = self.body_html_format.format(_('Ticket applied info'), meta_body_of_open) - return body diff --git a/apps/tickets/handler/command_confirm.py b/apps/tickets/handler/command_confirm.py deleted file mode 100644 index 6fb27dcfe..000000000 --- a/apps/tickets/handler/command_confirm.py +++ /dev/null @@ -1,33 +0,0 @@ -from django.utils.translation import ugettext as _ -from .base import BaseHandler - - -class Handler(BaseHandler): - - # body - def _construct_meta_body_of_open(self): - apply_run_user = self.ticket.meta.get('apply_run_user') - apply_run_asset = self.ticket.meta.get('apply_run_asset') - apply_run_system_user = self.ticket.meta.get('apply_run_system_user') - apply_run_command = self.ticket.meta.get('apply_run_command') - apply_from_session_id = self.ticket.meta.get('apply_from_session_id') - apply_from_cmd_filter_rule_id = self.ticket.meta.get('apply_from_cmd_filter_rule_id') - apply_from_cmd_filter_id = self.ticket.meta.get('apply_from_cmd_filter_id') - - applied_body = ''' - {}: {} - {}: {} - {}: {} - {}: {} - {}: {} - {}: {} - '''.format( - _("Applied run user"), apply_run_user, - _("Applied run asset"), apply_run_asset, - _("Applied run system user"), apply_run_system_user, - _("Applied run command"), apply_run_command, - _("Applied from session"), apply_from_session_id, - _("Applied from command filter rules"), apply_from_cmd_filter_rule_id, - _("Applied from command filter"), apply_from_cmd_filter_id, - ) - return applied_body diff --git a/apps/tickets/handler/login_asset_confirm.py b/apps/tickets/handler/login_asset_confirm.py deleted file mode 100644 index 039f7ce50..000000000 --- a/apps/tickets/handler/login_asset_confirm.py +++ /dev/null @@ -1,21 +0,0 @@ -from django.utils.translation import ugettext as _ -from .base import BaseHandler - - -class Handler(BaseHandler): - - # body - def _construct_meta_body_of_open(self): - apply_login_user = self.ticket.meta.get('apply_login_user') - apply_login_asset = self.ticket.meta.get('apply_login_asset') - apply_login_system_user = self.ticket.meta.get('apply_login_system_user') - applied_body = ''' - {}: {} - {}: {} - {}: {} - '''.format( - _("Applied login user"), apply_login_user, - _("Applied login asset"), apply_login_asset, - _("Applied login system user"), apply_login_system_user, - ) - return applied_body diff --git a/apps/tickets/handler/__init__.py b/apps/tickets/handlers/__init__.py similarity index 70% rename from apps/tickets/handler/__init__.py rename to apps/tickets/handlers/__init__.py index 42ef0d871..4400d043d 100644 --- a/apps/tickets/handler/__init__.py +++ b/apps/tickets/handlers/__init__.py @@ -2,6 +2,6 @@ from django.utils.module_loading import import_string def get_ticket_handler(ticket): - handler_class_path = 'tickets.handler.{}.Handler'.format(ticket.type) + handler_class_path = 'tickets.handlers.{}.Handler'.format(ticket.type) handler_class = import_string(handler_class_path) return handler_class(ticket=ticket) diff --git a/apps/tickets/handlers/apply_application.py b/apps/tickets/handlers/apply_application.py new file mode 100644 index 000000000..15c76c7e5 --- /dev/null +++ b/apps/tickets/handlers/apply_application.py @@ -0,0 +1,63 @@ +from django.utils.translation import ugettext as _ + +from orgs.utils import tmp_to_org, tmp_to_root_org +from perms.models import ApplicationPermission +from tickets.models import ApplyApplicationTicket +from .base import BaseHandler + + +class Handler(BaseHandler): + ticket: ApplyApplicationTicket + + def _on_step_approved(self, step): + is_finished = super()._on_step_approved(step) + if is_finished: + self._create_application_permission() + + # permission + def _create_application_permission(self): + with tmp_to_root_org(): + application_permission = ApplicationPermission.objects.filter(id=self.ticket.id).first() + if application_permission: + return application_permission + + apply_permission_name = self.ticket.apply_permission_name + apply_category = self.ticket.apply_category + apply_type = self.ticket.apply_type + apply_applications = self.ticket.apply_applications.all() + apply_system_users = self.ticket.apply_system_users.all() + apply_date_start = self.ticket.apply_date_start + apply_date_expired = self.ticket.apply_date_expired + permission_created_by = '{}:{}'.format( + str(self.ticket.__class__.__name__), str(self.ticket.id) + ) + permission_comment = _( + 'Created by the ticket, ' + 'ticket title: {}, ' + 'ticket applicant: {}, ' + 'ticket processor: {}, ' + 'ticket ID: {}' + ).format( + self.ticket.title, + self.ticket.applicant, + ','.join([i['processor_display'] for i in self.ticket.process_map]), + str(self.ticket.id) + ) + permissions_data = { + 'id': self.ticket.id, + 'name': apply_permission_name, + 'from_ticket': True, + 'category': apply_category, + 'type': apply_type, + 'comment': str(permission_comment), + 'created_by': permission_created_by, + 'date_start': apply_date_start, + 'date_expired': apply_date_expired, + } + with tmp_to_org(self.ticket.org_id): + application_permission = ApplicationPermission.objects.create(**permissions_data) + application_permission.users.add(self.ticket.applicant) + application_permission.applications.set(apply_applications) + application_permission.system_users.set(apply_system_users) + + return application_permission diff --git a/apps/tickets/handlers/apply_asset.py b/apps/tickets/handlers/apply_asset.py new file mode 100644 index 000000000..136347782 --- /dev/null +++ b/apps/tickets/handlers/apply_asset.py @@ -0,0 +1,64 @@ +from django.utils.translation import ugettext as _ + +from perms.models import AssetPermission +from orgs.utils import tmp_to_org, tmp_to_root_org +from tickets.models import ApplyAssetTicket +from .base import BaseHandler + + +class Handler(BaseHandler): + ticket: ApplyAssetTicket + + def _on_step_approved(self, step): + is_finished = super()._on_step_approved(step) + if is_finished: + self._create_asset_permission() + + # permission + def _create_asset_permission(self): + with tmp_to_root_org(): + asset_permission = AssetPermission.objects.filter(id=self.ticket.id).first() + if asset_permission: + return asset_permission + + apply_permission_name = self.ticket.apply_permission_name + apply_nodes = self.ticket.apply_nodes.all() + apply_assets = self.ticket.apply_assets.all() + apply_system_users = self.ticket.apply_system_users.all() + apply_actions = self.ticket.apply_actions + apply_date_start = self.ticket.apply_date_start + apply_date_expired = self.ticket.apply_date_expired + permission_created_by = '{}:{}'.format( + str(self.ticket.__class__.__name__), str(self.ticket.id) + ) + permission_comment = _( + 'Created by the ticket ' + 'ticket title: {} ' + 'ticket applicant: {} ' + 'ticket processor: {} ' + 'ticket ID: {}' + ).format( + self.ticket.title, + self.ticket.applicant, + ','.join([i['processor_display'] for i in self.ticket.process_map]), + str(self.ticket.id) + ) + + permission_data = { + 'id': self.ticket.id, + 'name': apply_permission_name, + 'from_ticket': True, + 'comment': str(permission_comment), + 'created_by': permission_created_by, + 'actions': apply_actions, + 'date_start': apply_date_start, + 'date_expired': apply_date_expired, + } + with tmp_to_org(self.ticket.org_id): + asset_permission = AssetPermission.objects.create(**permission_data) + asset_permission.users.add(self.ticket.applicant) + asset_permission.nodes.set(apply_nodes) + asset_permission.assets.set(apply_assets) + asset_permission.system_users.set(apply_system_users) + + return asset_permission diff --git a/apps/tickets/handlers/base.py b/apps/tickets/handlers/base.py new file mode 100644 index 000000000..308f9f914 --- /dev/null +++ b/apps/tickets/handlers/base.py @@ -0,0 +1,81 @@ +from django.utils.translation import ugettext as _ + +from common.utils import get_logger +from tickets.utils import ( + send_ticket_processed_mail_to_applicant, + send_ticket_applied_mail_to_assignees +) +from tickets.const import TicketState, TicketStatus + +logger = get_logger(__name__) + + +class BaseHandler: + + def __init__(self, ticket): + self.ticket = ticket + + def on_change_state(self, state): + self._create_state_change_comment(state) + handler = getattr(self, f'_on_{state}', lambda: None) + return handler() + + def _on_pending(self): + self._send_applied_mail_to_assignees() + + def on_step_state_change(self, step, state): + self._create_state_change_comment(state) + handler = getattr(self, f'_on_step_{state}', lambda: None) + return handler(step) + + def _on_step_approved(self, step): + next_step = step.next() + is_finished = not next_step + if is_finished: + self._send_processed_mail_to_applicant(step) + else: + self._send_processed_mail_to_applicant(step) + self._send_applied_mail_to_assignees(next_step) + return is_finished + + def _on_step_rejected(self, step): + self._send_processed_mail_to_applicant(step) + + def _on_step_closed(self, step): + self._send_processed_mail_to_applicant() + + def _send_applied_mail_to_assignees(self, step=None): + if step: + assignees = [step.assignee for step in step.ticket_assignees.all()] + else: + assignees = self.ticket.current_assignees + assignees_display = ', '.join([str(assignee) for assignee in assignees]) + logger.debug('Send applied email to assignees: {}'.format(assignees_display)) + send_ticket_applied_mail_to_assignees(self.ticket, assignees) + + def _send_processed_mail_to_applicant(self, step=None): + applicant = self.ticket.applicant + if self.ticket.status == TicketStatus.closed: + processor = applicant + else: + processor = step.processor if step else self.ticket.processor + logger.debug('Send processed mail to applicant: {}'.format(applicant)) + send_ticket_processed_mail_to_applicant(self.ticket, processor) + + def _create_state_change_comment(self, state): + # 打开或关闭工单,备注显示是自己,其他是受理人 + if state in [TicketState.reopen, TicketState.pending, TicketState.closed]: + user = self.ticket.applicant + else: + user = self.ticket.processor + + user_display = str(user) + state_display = getattr(TicketState, state).label + data = { + 'body': _('{} {} the ticket').format(user_display, state_display), + 'user': user, + 'user_display': str(user), + 'type': 'state', + 'state': state + } + return self.ticket.comments.create(**data) diff --git a/apps/tickets/handlers/command_confirm.py b/apps/tickets/handlers/command_confirm.py new file mode 100644 index 000000000..a34751b9d --- /dev/null +++ b/apps/tickets/handlers/command_confirm.py @@ -0,0 +1,6 @@ +from tickets.models import ApplyCommandTicket +from .base import BaseHandler + + +class Handler(BaseHandler): + ticket: ApplyCommandTicket diff --git a/apps/tickets/handler/general.py b/apps/tickets/handlers/general.py similarity index 100% rename from apps/tickets/handler/general.py rename to apps/tickets/handlers/general.py diff --git a/apps/tickets/handlers/login_asset_confirm.py b/apps/tickets/handlers/login_asset_confirm.py new file mode 100644 index 000000000..16f156d4d --- /dev/null +++ b/apps/tickets/handlers/login_asset_confirm.py @@ -0,0 +1,6 @@ +from tickets.models import ApplyLoginAssetTicket +from .base import BaseHandler + + +class Handler(BaseHandler): + ticket: ApplyLoginAssetTicket diff --git a/apps/tickets/handler/login_confirm.py b/apps/tickets/handlers/login_confirm.py similarity index 65% rename from apps/tickets/handler/login_confirm.py rename to apps/tickets/handlers/login_confirm.py index 65c46fb56..ad33ce476 100644 --- a/apps/tickets/handler/login_confirm.py +++ b/apps/tickets/handlers/login_confirm.py @@ -1,14 +1,15 @@ from django.utils.translation import ugettext as _ +from tickets.models import ApplyLoginTicket from .base import BaseHandler class Handler(BaseHandler): + ticket: ApplyLoginTicket - # body def _construct_meta_body_of_open(self): - apply_login_ip = self.ticket.meta.get('apply_login_ip') - apply_login_city = self.ticket.meta.get('apply_login_city') - apply_login_datetime = self.ticket.meta.get('apply_login_datetime') + apply_login_ip = self.ticket.apply_login_ip + apply_login_city = self.ticket.apply_login_city + apply_login_datetime = self.ticket.apply_login_datetime applied_body = ''' {}: {} {}: {} diff --git a/apps/tickets/migrations/0007_auto_20201224_1821.py b/apps/tickets/migrations/0007_auto_20201224_1821.py index a16771ef9..31645b00f 100644 --- a/apps/tickets/migrations/0007_auto_20201224_1821.py +++ b/apps/tickets/migrations/0007_auto_20201224_1821.py @@ -3,7 +3,7 @@ from django.conf import settings from django.db import migrations, models import django.db.models.deletion -import tickets.models.ticket +import common.db.encoder TICKET_TYPE_APPLY_ASSET = 'apply_asset' @@ -116,7 +116,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='ticket', name='assignees_display_new', - field=models.JSONField(default=list, encoder=tickets.models.ticket.ModelJSONFieldEncoder, verbose_name='Assignees display'), + field=models.JSONField(default=list, encoder=common.db.encoder.ModelJSONFieldEncoder, verbose_name='Assignees display'), ), migrations.AlterField( model_name='ticket', @@ -126,7 +126,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='ticket', name='meta', - field=models.JSONField(default=dict, encoder=tickets.models.ticket.ModelJSONFieldEncoder, verbose_name='Meta'), + field=models.JSONField(default=dict, encoder=common.db.encoder.ModelJSONFieldEncoder, verbose_name='Meta'), ), migrations.AlterField( model_name='ticket', diff --git a/apps/tickets/migrations/0016_auto_20220609_1758.py b/apps/tickets/migrations/0016_auto_20220609_1758.py new file mode 100644 index 000000000..55d88385e --- /dev/null +++ b/apps/tickets/migrations/0016_auto_20220609_1758.py @@ -0,0 +1,189 @@ +# Generated by Django 3.1.14 on 2022-06-09 09:58 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ('terminal', '0049_endpoint_redis_port'), + ('assets', '0090_auto_20220412_1145'), + ('applications', '0020_auto_20220316_2028'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('tickets', '0015_superticket'), + ] + + operations = [ + migrations.CreateModel( + name='ApplyLoginTicket', + fields=[ + ('ticket_ptr', + models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, + primary_key=True, serialize=False, to='tickets.ticket')), + ('apply_login_ip', models.GenericIPAddressField(null=True, verbose_name='Login ip')), + ('apply_login_city', models.CharField(max_length=64, null=True, verbose_name='Login city')), + ('apply_login_datetime', models.DateTimeField(null=True, verbose_name='Login datetime')), + ], + options={ + 'abstract': False, + }, + bases=('tickets.ticket',), + ), + migrations.RemoveField( + model_name='ticket', + name='process_map', + ), + migrations.AddField( + model_name='comment', + name='state', + field=models.CharField(max_length=16, null=True), + ), + migrations.AddField( + model_name='comment', + name='type', + field=models.CharField(choices=[('state', 'State'), ('common', 'common')], default='common', max_length=16, + verbose_name='Type'), + ), + migrations.AddField( + model_name='ticket', + name='rel_snapshot', + field=models.JSONField(default=dict, verbose_name='Relation snapshot'), + ), + migrations.AddField( + model_name='ticketstep', + name='status', + field=models.CharField(choices=[('pending', 'Pending'), ('active', 'Active'), ('closed', 'Closed')], + default='pending', max_length=16), + ), + migrations.AlterField( + model_name='ticket', + name='state', + field=models.CharField(choices=[('pending', 'Pending'), ('approved', 'Approved'), ('rejected', 'Rejected'), + ('closed', 'Cancel'), ('reopen', 'Reopen')], default='pending', + max_length=16, verbose_name='State'), + ), + migrations.AlterField( + model_name='ticket', + name='status', + field=models.CharField(choices=[('open', 'Open'), ('closed', 'Finished')], default='open', max_length=16, + verbose_name='Status'), + ), + migrations.AlterField( + model_name='ticketassignee', + name='state', + field=models.CharField(choices=[('pending', 'Pending'), ('approved', 'Approved'), ('rejected', 'Rejected'), + ('closed', 'Cancel'), ('reopen', 'Reopen')], default='pending', + max_length=64), + ), + migrations.AlterField( + model_name='ticketstep', + name='state', + field=models.CharField(choices=[('pending', 'Pending'), ('approved', 'Approved'), ('rejected', 'Rejected'), + ('closed', 'Closed')], default='pending', max_length=64, + verbose_name='State'), + ), + migrations.CreateModel( + name='ApplyLoginAssetTicket', + fields=[ + ('ticket_ptr', + models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, + primary_key=True, serialize=False, to='tickets.ticket')), + ('apply_login_asset', + models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.asset', + verbose_name='Login asset')), + ('apply_login_system_user', + models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.systemuser', + verbose_name='Login system user')), + ('apply_login_user', + models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, + verbose_name='Login user')), + ], + options={ + 'abstract': False, + }, + bases=('tickets.ticket',), + ), + migrations.CreateModel( + name='ApplyCommandTicket', + fields=[ + ('ticket_ptr', + models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, + primary_key=True, serialize=False, to='tickets.ticket')), + ('apply_run_command', models.CharField(max_length=4096, verbose_name='Run command')), + ('apply_from_cmd_filter', + models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.commandfilter', + verbose_name='From cmd filter')), + ('apply_from_cmd_filter_rule', + models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, + to='assets.commandfilterrule', verbose_name='From cmd filter rule')), + ('apply_from_session', + models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='terminal.session', + verbose_name='Session')), + ('apply_run_asset', + models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.asset', + verbose_name='Run asset')), + ('apply_run_system_user', + models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.systemuser', + verbose_name='Run system user')), + ('apply_run_user', + models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, + verbose_name='Run user')), + ], + options={ + 'abstract': False, + }, + bases=('tickets.ticket',), + ), + migrations.CreateModel( + name='ApplyAssetTicket', + fields=[ + ('ticket_ptr', + models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, + primary_key=True, serialize=False, to='tickets.ticket')), + ('apply_permission_name', models.CharField(max_length=128, verbose_name='Apply name')), + ('apply_actions', models.IntegerField( + choices=[(255, 'All'), (1, 'Connect'), (2, 'Upload file'), (4, 'Download file'), + (6, 'Upload download'), (8, 'Clipboard copy'), (16, 'Clipboard paste'), + (24, 'Clipboard copy paste')], default=255, verbose_name='Actions')), + ('apply_date_start', models.DateTimeField(null=True, verbose_name='Date start')), + ('apply_date_expired', models.DateTimeField(null=True, verbose_name='Date expired')), + ('apply_assets', models.ManyToManyField(to='assets.Asset', verbose_name='Apply assets')), + ('apply_nodes', models.ManyToManyField(to='assets.Node', verbose_name='Apply nodes')), + ('apply_system_users', + models.ManyToManyField(to='assets.SystemUser', verbose_name='Apply system users')), + ], + options={ + 'abstract': False, + }, + bases=('tickets.ticket',), + ), + migrations.CreateModel( + name='ApplyApplicationTicket', + fields=[ + ('ticket_ptr', + models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, + primary_key=True, serialize=False, to='tickets.ticket')), + ('apply_permission_name', models.CharField(max_length=128, verbose_name='Apply name')), + ('apply_category', + models.CharField(choices=[('db', 'Database'), ('remote_app', 'Remote app'), ('cloud', 'Cloud')], + max_length=16, verbose_name='Category')), + ('apply_type', models.CharField( + choices=[('mysql', 'MySQL'), ('mariadb', 'MariaDB'), ('oracle', 'Oracle'), + ('postgresql', 'PostgreSQL'), ('sqlserver', 'SQLServer'), ('redis', 'Redis'), + ('mongodb', 'MongoDB'), ('chrome', 'Chrome'), ('mysql_workbench', 'MySQL Workbench'), + ('vmware_client', 'vSphere Client'), ('custom', 'Custom'), ('k8s', 'Kubernetes')], + max_length=16, verbose_name='Type')), + ('apply_date_start', models.DateTimeField(null=True, verbose_name='Date start')), + ('apply_date_expired', models.DateTimeField(null=True, verbose_name='Date expired')), + ('apply_applications', + models.ManyToManyField(to='applications.Application', verbose_name='Apply applications')), + ('apply_system_users', + models.ManyToManyField(to='assets.SystemUser', verbose_name='Apply system users')), + ], + options={ + 'abstract': False, + }, + bases=('tickets.ticket',), + ), + ] diff --git a/apps/tickets/migrations/0017_auto_20220623_1027.py b/apps/tickets/migrations/0017_auto_20220623_1027.py new file mode 100644 index 000000000..7ac1c9eb2 --- /dev/null +++ b/apps/tickets/migrations/0017_auto_20220623_1027.py @@ -0,0 +1,339 @@ +# Generated by Django 3.1.14 on 2022-06-23 02:27 + +import re +from datetime import datetime +from collections import defaultdict + +from django.utils import timezone as dj_timezone +from django.db import migrations + +from perms.models import Action +from tickets.const import TicketType + +pt = re.compile(r'(\w+)\((\w+)\)') + + +def time_conversion(t): + if not t: + return + try: + return datetime.strptime(t, '%Y-%m-%d %H:%M:%S'). \ + astimezone(dj_timezone.get_current_timezone()) + except Exception: + return + + +nodes_dict = defaultdict(set) +assets_dict = defaultdict(set) +system_users_dict = defaultdict(set) +apps_dict = defaultdict(set) +global_inited = {} + + +def init_global_dict(apps): + if global_inited: + return + node_model = apps.get_model('assets', 'Node') + asset_model = apps.get_model('assets', 'Asset') + system_user_model = apps.get_model('assets', 'SystemUser') + application_model = apps.get_model('applications', 'Application') + + node_qs = node_model.objects.values('id', 'org_id') + asset_qs = asset_model.objects.values('id', 'org_id') + system_user_qs = system_user_model.objects.values('id', 'org_id') + app_qs = application_model.objects.values('id', 'org_id') + + for d, qs in [ + (nodes_dict, node_qs), + (assets_dict, asset_qs), + (system_users_dict, system_user_qs), + (apps_dict, app_qs) + ]: + for i in qs: + _id = str(i['id']) + org_id = str(i['org_id']) + d[org_id].add(_id) + global_inited['inited'] = True + + +def apply_asset_migrate(apps, *args): + init_global_dict(apps) + + ticket_model = apps.get_model('tickets', 'Ticket') + tickets = ticket_model.objects.filter(type=TicketType.apply_asset) + ticket_apply_asset_model = apps.get_model('tickets', 'ApplyAssetTicket') + + for instance in tickets: + meta = instance.meta + org_id = instance.org_id + apply_actions = meta.get('apply_actions') + + # 数据库中数据结构有问题,可能是 list,可能是 int + if isinstance(apply_actions, list): + apply_actions = Action.choices_to_value(value=apply_actions) + elif isinstance(apply_actions, int): + apply_actions = apply_actions + else: + apply_actions = 0 + + data = { + 'ticket_ptr_id': instance.pk, + 'apply_permission_name': meta.get('apply_permission_name', ''), + 'apply_date_start': time_conversion(meta.get('apply_date_start')), + 'apply_date_expired': time_conversion(meta.get('apply_date_expired')), + 'apply_actions': apply_actions, + } + + child = ticket_apply_asset_model(**data) + child.__dict__.update(instance.__dict__) + child.save() + + apply_nodes = list(set(meta.get('apply_nodes', [])) & nodes_dict[org_id]) + apply_assets = list(set(meta.get('apply_assets', [])) & assets_dict[org_id]) + apply_system_users = list(set(meta.get('apply_system_users', [])) & system_users_dict[org_id]) + child.apply_nodes.set(apply_nodes) + child.apply_assets.set(apply_assets) + child.apply_system_users.set(apply_system_users) + + if (not apply_nodes and not apply_assets) or not apply_system_users: + continue + + rel_snapshot = { + 'applicant': instance.applicant_display, + 'apply_nodes': meta.get('apply_nodes_display', []), + 'apply_assets': meta.get('apply_assets_display', []), + 'apply_system_users': meta.get('apply_system_users', []), + } + instance.rel_snapshot = rel_snapshot + instance.save(update_fields=['rel_snapshot']) + + +def apply_application_migrate(apps, *args): + init_global_dict(apps) + + ticket_model = apps.get_model('tickets', 'Ticket') + tickets = ticket_model.objects.filter(type=TicketType.apply_application) + ticket_apply_app_model = apps.get_model('tickets', 'ApplyApplicationTicket') + + for instance in tickets: + meta = instance.meta + org_id = instance.org_id + data = { + 'ticket_ptr_id': instance.pk, + 'apply_permission_name': meta.get('apply_permission_name', ''), + 'apply_category': meta.get('apply_category'), + 'apply_type': meta.get('apply_type'), + 'apply_date_start': time_conversion(meta.get('apply_date_start')), + 'apply_date_expired': time_conversion(meta.get('apply_date_expired')), + } + child = ticket_apply_app_model(**data) + child.__dict__.update(instance.__dict__) + child.save() + + apply_applications = list(set(meta.get('apply_applications', [])) & apps_dict[org_id]) + apply_system_users = list(set(meta.get('apply_system_users', [])) & system_users_dict[org_id]) + if not apply_applications or not apply_system_users: + continue + child.apply_applications.set(apply_applications) + child.apply_system_users.set(apply_system_users) + + rel_snapshot = { + 'applicant': instance.applicant_display, + 'apply_applications': meta.get('apply_applications_display', []), + 'apply_system_users': meta.get('apply_system_users', []), + } + instance.rel_snapshot = rel_snapshot + instance.save(update_fields=['rel_snapshot']) + + +def login_confirm_migrate(apps, *args): + ticket_model = apps.get_model('tickets', 'Ticket') + tickets = ticket_model.objects.filter(type=TicketType.login_confirm) + ticket_apply_login_model = apps.get_model('tickets', 'ApplyLoginTicket') + + for instance in tickets: + meta = instance.meta + data = { + 'ticket_ptr_id': instance.pk, + 'apply_login_ip': meta.get('apply_login_ip'), + 'apply_login_city': meta.get('apply_login_city'), + 'apply_login_datetime': time_conversion(meta.get('apply_login_datetime')), + } + rel_snapshot = { + 'applicant': instance.applicant_display + } + instance.rel_snapshot = rel_snapshot + instance.save(update_fields=['rel_snapshot']) + child = ticket_apply_login_model(**data) + child.__dict__.update(instance.__dict__) + child.save() + + +def analysis_instance_name(name: str): + if not name: + return None + matched = pt.match(name) + if not matched: + return None + return matched.groups() + + +def login_asset_confirm_migrate(apps, *args): + user_model = apps.get_model('users', 'User') + asset_model = apps.get_model('assets', 'Asset') + system_user_model = apps.get_model('assets', 'SystemUser') + ticket_model = apps.get_model('tickets', 'Ticket') + tickets = ticket_model.objects.filter(type=TicketType.login_asset_confirm) + ticket_apply_login_asset_model = apps.get_model('tickets', 'ApplyLoginAssetTicket') + + for instance in tickets: + meta = instance.meta + + name_username = analysis_instance_name(meta.get('apply_login_user')) + apply_login_user = user_model.objects.filter( + name=name_username[0], + username=name_username[1] + ).first() if name_username else None + + hostname_ip = analysis_instance_name(meta.get('apply_login_asset')) + apply_login_asset = asset_model.objects.filter( + org_id=instance.org_id, + hostname=hostname_ip[0], + ip=hostname_ip[1] + ).first() if hostname_ip else None + + name_username = analysis_instance_name(meta.get('apply_login_system_user')) + apply_login_system_user = system_user_model.objects.filter( + org_id=instance.org_id, + name=name_username[0], + username=name_username[1] + ).first() if name_username else None + + data = { + 'ticket_ptr_id': instance.pk, + 'apply_login_user': apply_login_user, + 'apply_login_asset': apply_login_asset, + 'apply_login_system_user': apply_login_system_user, + } + child = ticket_apply_login_asset_model(**data) + child.__dict__.update(instance.__dict__) + child.save() + + rel_snapshot = { + 'applicant': instance.applicant_display, + 'apply_login_user': meta.get('apply_login_user', ''), + 'apply_login_asset': meta.get('apply_login_asset', ''), + 'apply_login_system_user': meta.get('apply_login_system_user', ''), + } + instance.rel_snapshot = rel_snapshot + instance.save(update_fields=['rel_snapshot']) + + +def command_confirm_migrate(apps, *args): + user_model = apps.get_model('users', 'User') + asset_model = apps.get_model('assets', 'Asset') + system_user_model = apps.get_model('assets', 'SystemUser') + ticket_model = apps.get_model('tickets', 'Ticket') + session_model = apps.get_model('terminal', 'Session') + command_filter_model = apps.get_model('assets', 'CommandFilter') + command_filter_rule_model = apps.get_model('assets', 'CommandFilterRule') + + tickets = ticket_model.objects.filter(type=TicketType.command_confirm) + session_ids = tickets.values_list('meta__apply_from_session_id', flat=True) + session_ids = session_model.objects.filter(id__in=session_ids).values_list('id', flat=True) + + command_filter_ids = tickets.values_list('meta__apply_from_cmd_filter_id', flat=True) + command_filter_ids = command_filter_model.objects\ + .filter(id__in=command_filter_ids)\ + .values_list('id', flat=True) + + command_filter_rule_ids = tickets.values_list('meta__apply_from_cmd_filter_rule_id', flat=True) + command_filter_rule_ids = command_filter_rule_model.objects\ + .filter(id__in=command_filter_rule_ids)\ + .values_list('id', flat=True) + ticket_apply_command_model = apps.get_model('tickets', 'ApplyCommandTicket') + + for instance in tickets: + meta = instance.meta + + name_username = analysis_instance_name(meta.get('apply_run_user')) + apply_run_user = user_model.objects.filter( + name=name_username[0], username=name_username[1] + ).first() if name_username else None + + hostname_ip = analysis_instance_name(meta.get('apply_run_asset')) + apply_run_asset = asset_model.objects.filter( + org_id=instance.org_id, hostname=hostname_ip[0], ip=hostname_ip[1] + ).first() if hostname_ip else None + + name_username = analysis_instance_name(meta.get('apply_run_system_user')) + apply_run_system_user = system_user_model.objects.filter( + org_id=instance.org_id, name=name_username[0], username=name_username[1] + ).first() if name_username else None + + apply_from_session_id = meta.get('apply_from_session_id') + apply_from_cmd_filter_id = meta.get('apply_from_cmd_filter_id') + apply_from_cmd_filter_rule_id = meta.get('apply_from_cmd_filter_rule_id') + + if apply_from_session_id not in session_ids: + apply_from_session_id = None + if apply_from_cmd_filter_id not in command_filter_ids: + apply_from_cmd_filter_id = None + if apply_from_cmd_filter_rule_id not in command_filter_rule_ids: + apply_from_cmd_filter_rule_id = None + + data = { + 'ticket_ptr_id': instance.pk, + 'apply_run_user': apply_run_user, + 'apply_run_asset': apply_run_asset, + 'apply_run_system_user': apply_run_system_user, + 'apply_run_command': meta.get('apply_run_command', ''), + 'apply_from_session_id': apply_from_session_id, + 'apply_from_cmd_filter_id': apply_from_cmd_filter_id, + 'apply_from_cmd_filter_rule_id': apply_from_cmd_filter_rule_id, + } + + rel_snapshot = { + 'applicant': instance.applicant_display, + 'apply_run_user': meta.get('apply_run_user', ''), + 'apply_run_asset': meta.get('apply_run_asset', ''), + 'apply_run_system_user': meta.get('apply_run_system_user', ''), + 'apply_from_session': meta.get('apply_from_session_id', ''), + 'apply_from_cmd_filter': meta.get('apply_from_cmd_filter_id', ''), + 'apply_from_cmd_filter_rule': meta.get('apply_from_cmd_filter_rule_id', ''), + } + child = ticket_apply_command_model(**data) + child.__dict__.update(instance.__dict__) + child.save() + + instance.rel_snapshot = rel_snapshot + instance.save(update_fields=['rel_snapshot']) + + +def migrate_ticket_state(apps, *args): + ticket_model = apps.get_model('tickets', 'Ticket') + ticket_step_model = apps.get_model('tickets', 'TicketStep') + ticket_assignee_model = apps.get_model('tickets', 'TicketAssignee') + ticket_model.objects.filter(state='open').update(state='pending') + ticket_step_model.objects.filter(state='notified').update(state='pending') + ticket_assignee_model.objects.filter(state='notified').update(state='pending') + + +class Migration(migrations.Migration): + + dependencies = [ + ('tickets', '0016_auto_20220609_1758'), + ] + + operations = [ + migrations.RunPython(migrate_ticket_state), + migrations.RunPython(apply_asset_migrate), + migrations.RunPython(apply_application_migrate), + migrations.RunPython(login_confirm_migrate), + migrations.RunPython(login_asset_confirm_migrate), + migrations.RunPython(command_confirm_migrate), + migrations.RemoveField( + model_name='ticket', + name='applicant_display', + ), + ] diff --git a/apps/tickets/models/comment.py b/apps/tickets/models/comment.py index c08b29dbe..f15057d7e 100644 --- a/apps/tickets/models/comment.py +++ b/apps/tickets/models/comment.py @@ -9,6 +9,10 @@ __all__ = ['Comment'] class Comment(CommonModelMixin): + class Type(models.TextChoices): + state = 'state', _('State') + common = 'common', _('common') + ticket = models.ForeignKey( 'tickets.Ticket', on_delete=models.CASCADE, related_name='comments' ) @@ -18,6 +22,10 @@ class Comment(CommonModelMixin): ) user_display = models.CharField(max_length=256, verbose_name=_("User display name")) body = models.TextField(verbose_name=_("Body")) + type = models.CharField( + max_length=16, choices=Type.choices, default=Type.common, verbose_name=_("Type") + ) + state = models.CharField(max_length=16, null=True) class Meta: ordering = ('date_created', ) diff --git a/apps/tickets/models/flow.py b/apps/tickets/models/flow.py index 69aa6c432..4dd468f57 100644 --- a/apps/tickets/models/flow.py +++ b/apps/tickets/models/flow.py @@ -5,17 +5,18 @@ from django.utils.translation import ugettext_lazy as _ from users.models import User from common.mixins.models import CommonModelMixin + from orgs.mixins.models import OrgModelMixin from orgs.models import Organization from orgs.utils import tmp_to_org, get_current_org_id -from ..const import TicketType, TicketApprovalLevel, TicketApprovalStrategy +from ..const import TicketType, TicketLevel, TicketApprovalStrategy __all__ = ['TicketFlow', 'ApprovalRule'] class ApprovalRule(CommonModelMixin): level = models.SmallIntegerField( - default=TicketApprovalLevel.one, choices=TicketApprovalLevel.choices, + default=TicketLevel.one, choices=TicketLevel.choices, verbose_name=_('Approve level') ) strategy = models.CharField( @@ -56,8 +57,8 @@ class TicketFlow(CommonModelMixin, OrgModelMixin): default=TicketType.general, verbose_name=_("Type") ) approval_level = models.SmallIntegerField( - default=TicketApprovalLevel.one, - choices=TicketApprovalLevel.choices, + default=TicketLevel.one, + choices=TicketLevel.choices, verbose_name=_('Approve level') ) rules = models.ManyToManyField(ApprovalRule, related_name='ticket_flows') diff --git a/apps/tickets/models/ticket.py b/apps/tickets/models/ticket.py deleted file mode 100644 index c05fa8b3b..000000000 --- a/apps/tickets/models/ticket.py +++ /dev/null @@ -1,328 +0,0 @@ -# -*- coding: utf-8 -*- -# -from typing import Callable - -from django.db import models -from django.db.models import Q -from django.utils.translation import ugettext_lazy as _ -from django.db.utils import IntegrityError - -from common.exceptions import JMSException -from common.utils.timezone import as_current_tz -from common.mixins.models import CommonModelMixin -from common.db.encoder import ModelJSONFieldEncoder -from orgs.mixins.models import OrgModelMixin -from orgs.utils import tmp_to_root_org, tmp_to_org -from tickets.const import TicketType, TicketStatus, TicketState, TicketApprovalLevel, ProcessStatus, TicketAction -from tickets.signals import post_change_ticket_action -from tickets.handler import get_ticket_handler -from tickets.errors import AlreadyClosed - -__all__ = ['Ticket', 'TicketStep', 'TicketAssignee', 'SuperTicket'] - - -class TicketStep(CommonModelMixin): - ticket = models.ForeignKey( - 'Ticket', related_name='ticket_steps', on_delete=models.CASCADE, verbose_name='Ticket' - ) - level = models.SmallIntegerField( - default=TicketApprovalLevel.one, choices=TicketApprovalLevel.choices, - verbose_name=_('Approve level') - ) - state = models.CharField(choices=ProcessStatus.choices, max_length=64, default=ProcessStatus.notified) - - class Meta: - verbose_name = _("Ticket step") - - -class TicketAssignee(CommonModelMixin): - assignee = models.ForeignKey( - 'users.User', related_name='ticket_assignees', on_delete=models.CASCADE, verbose_name='Assignee' - ) - state = models.CharField(choices=ProcessStatus.choices, max_length=64, default=ProcessStatus.notified) - step = models.ForeignKey('tickets.TicketStep', related_name='ticket_assignees', on_delete=models.CASCADE) - - class Meta: - verbose_name = _('Ticket assignee') - - def __str__(self): - return '{0.assignee.name}({0.assignee.username})_{0.step}'.format(self) - - -class StatusMixin: - state: str - status: str - applicant: models.ForeignKey - current_node: models.Manager - save: Callable - - def set_state_approve(self): - self.state = TicketState.approved - - def set_state_reject(self): - self.state = TicketState.rejected - - def set_state_closed(self): - self.state = TicketState.closed - - def set_status_closed(self): - self.status = TicketStatus.closed - - # status - @property - def status_open(self): - return self.status == TicketStatus.open.value - - @property - def status_closed(self): - return self.status == TicketStatus.closed.value - - @property - def state_open(self): - return self.state == TicketState.open.value - - @property - def state_approve(self): - return self.state == TicketState.approved.value - - @property - def state_reject(self): - return self.state == TicketState.rejected.value - - @property - def state_close(self): - return self.state == TicketState.closed.value - - # action changed - def open(self, applicant): - self.applicant = applicant - self._change_action(TicketAction.open) - - def approve(self, processor): - self.update_current_step_state_and_assignee(processor, TicketState.approved) - self._change_action(TicketAction.approve) - - def reject(self, processor): - self.update_current_step_state_and_assignee(processor, TicketState.rejected) - self._change_action(TicketAction.reject) - - def close(self, processor): - self.update_current_step_state_and_assignee(processor, TicketState.closed) - self._change_action(TicketAction.close) - - def update_current_step_state_and_assignee(self, processor, state): - if self.status_closed: - raise AlreadyClosed - if state != TicketState.approved: - self.state = state - current_node = self.current_node - current_node.update(state=state) - current_node.first().ticket_assignees.filter(assignee=processor).update(state=state) - - def _change_action(self, action): - self.save() - post_change_ticket_action.send(sender=self.__class__, ticket=self, action=action) - - -class Ticket(CommonModelMixin, StatusMixin, OrgModelMixin): - title = models.CharField(max_length=256, verbose_name=_("Title")) - type = models.CharField( - max_length=64, choices=TicketType.choices, - default=TicketType.general, verbose_name=_("Type") - ) - meta = models.JSONField(encoder=ModelJSONFieldEncoder, default=dict, verbose_name=_("Meta")) - state = models.CharField( - max_length=16, choices=TicketState.choices, - default=TicketState.open, verbose_name=_("State") - ) - status = models.CharField( - max_length=16, choices=TicketStatus.choices, - default=TicketStatus.open, verbose_name=_("Status") - ) - approval_step = models.SmallIntegerField( - default=TicketApprovalLevel.one, choices=TicketApprovalLevel.choices, - verbose_name=_('Approval step') - ) - # 申请人 - applicant = models.ForeignKey( - 'users.User', related_name='applied_tickets', on_delete=models.SET_NULL, null=True, - verbose_name=_("Applicant") - ) - applicant_display = models.CharField(max_length=256, default='', verbose_name=_("Applicant display")) - process_map = models.JSONField(encoder=ModelJSONFieldEncoder, default=list, verbose_name=_("Process")) - # 评论 - comment = models.TextField(default='', blank=True, verbose_name=_('Comment')) - flow = models.ForeignKey( - 'TicketFlow', related_name='tickets', on_delete=models.SET_NULL, null=True, - verbose_name=_("TicketFlow") - ) - serial_num = models.CharField(max_length=128, unique=True, null=True, verbose_name=_('Serial number')) - - class Meta: - ordering = ('-date_created',) - verbose_name = _('Ticket') - - def __str__(self): - return '{}({})'.format(self.title, self.applicant_display) - - # type - @property - def type_apply_asset(self): - return self.type == TicketType.apply_asset.value - - @property - def type_apply_application(self): - return self.type == TicketType.apply_application.value - - @property - def type_login_confirm(self): - return self.type == TicketType.login_confirm.value - - @property - def current_node(self): - return self.ticket_steps.filter(level=self.approval_step) - - @property - def processor(self): - processor = self.current_node.first().ticket_assignees\ - .exclude(state=ProcessStatus.notified).first() - return processor.assignee if processor else None - - def ignore_applicant(self, assignees, applicant=None): - applicant = applicant if applicant else self.applicant - if len(assignees) != 1: - assignees = set(assignees) - {applicant, } - return list(assignees) - - def create_related_node(self, applicant=None): - org_id = self.flow.org_id - approval_rule = self.get_current_ticket_flow_approve() - ticket_step = TicketStep.objects.create(ticket=self, level=self.approval_step) - ticket_assignees = [] - assignees = approval_rule.get_assignees(org_id=org_id) - assignees = self.ignore_applicant(assignees, applicant) - for assignee in assignees: - ticket_assignees.append(TicketAssignee(step=ticket_step, assignee=assignee)) - TicketAssignee.objects.bulk_create(ticket_assignees) - - def create_process_map(self, applicant=None): - org_id = self.flow.org_id - approval_rules = self.flow.rules.order_by('level') - nodes = list() - for node in approval_rules: - assignees = node.get_assignees(org_id=org_id) - assignees = self.ignore_applicant(assignees, applicant) - assignee_ids = [assignee.id for assignee in assignees] - assignees_display = [str(assignee) for assignee in assignees] - nodes.append( - { - 'approval_level': node.level, - 'state': ProcessStatus.notified, - 'assignees': assignee_ids, - 'assignees_display': assignees_display - } - ) - return nodes - - # TODO 兼容不存在流的工单 - def create_process_map_and_node(self, assignees, applicant): - assignees = self.ignore_applicant(assignees, applicant) - self.process_map = [{ - 'approval_level': 1, - 'state': 'notified', - 'assignees': [assignee.id for assignee in assignees], - 'assignees_display': [str(assignee) for assignee in assignees] - }, ] - self.save() - ticket_step = TicketStep.objects.create(ticket=self, level=1) - ticket_assignees = [] - for assignee in assignees: - ticket_assignees.append(TicketAssignee(step=ticket_step, assignee=assignee)) - TicketAssignee.objects.bulk_create(ticket_assignees) - - def has_current_assignee(self, assignee): - return self.ticket_steps.filter(ticket_assignees__assignee=assignee, level=self.approval_step).exists() - - def has_all_assignee(self, assignee): - return self.ticket_steps.filter(ticket_assignees__assignee=assignee).exists() - - @classmethod - def get_user_related_tickets(cls, user): - queries = Q(applicant=user) | Q(ticket_steps__ticket_assignees__assignee=user) - tickets = cls.all().filter(queries).distinct() - return tickets - - def get_current_ticket_flow_approve(self): - return self.flow.rules.filter(level=self.approval_step).first() - - @classmethod - def all(cls): - with tmp_to_root_org(): - return Ticket.objects.all() - - def save(self, *args, **kwargs): - """ 确保保存的org_id的是自身的值 """ - with tmp_to_org(self.org_id): - return super().save(*args, **kwargs) - - @property - def handler(self): - return get_ticket_handler(ticket=self) - - # body - @property - def body(self): - _body = self.handler.get_body() - return _body - - def get_serial_num_date(self): - date_created = as_current_tz(self.date_created) - date = date_created.strftime('%Y%m%d') - return date - - def get_last_serail_num(self): - date_created = as_current_tz(self.date_created) - date_prefix = date_created.strftime('%Y%m%d') - - ticket = Ticket.all().select_for_update().filter( - serial_num__startswith=date_prefix - ).order_by('-date_created').first() - - if ticket: - # 202212010001 - num_str = ticket.serial_num[8:] - num = int(num_str) - return num - return None - - def get_next_serail_num(self): - num = self.get_last_serail_num() - if num is None: - num = 0 - return '%04d' % (num + 1) - - def construct_serial_num(self): - date_prefix = self.get_serial_num_date() - num_suffix = self.get_next_serail_num() - return date_prefix + num_suffix - - def update_serial_num_if_need(self): - if self.serial_num: - return - - try: - self.serial_num = self.construct_serial_num() - self.save(update_fields=('serial_num',)) - except IntegrityError as e: - if e.args[0] == 1062: - # 虽然做了 `select_for_update` 但是每天的第一条工单仍可能造成冲突 - # 但概率小,这里只报错,用户重新提交即可 - raise JMSException(detail=_('Please try again'), code='please_try_again') - - raise e - - -class SuperTicket(Ticket): - class Meta: - proxy = True - verbose_name = _("Super ticket") diff --git a/apps/tickets/models/ticket/__init__.py b/apps/tickets/models/ticket/__init__.py new file mode 100644 index 000000000..c13cea9b1 --- /dev/null +++ b/apps/tickets/models/ticket/__init__.py @@ -0,0 +1,6 @@ +from .general import * +from .apply_asset import * +from .apply_application import * +from .command_confirm import * +from .login_asset_confirm import * +from .login_confirm import * diff --git a/apps/tickets/models/ticket/apply_application.py b/apps/tickets/models/ticket/apply_application.py new file mode 100644 index 000000000..de041c8da --- /dev/null +++ b/apps/tickets/models/ticket/apply_application.py @@ -0,0 +1,34 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from .general import Ticket +from applications.const import AppCategory, AppType + +__all__ = ['ApplyApplicationTicket'] + + +class ApplyApplicationTicket(Ticket): + apply_permission_name = models.CharField(max_length=128, verbose_name=_('Apply name')) + # 申请信息 + apply_category = models.CharField( + max_length=16, choices=AppCategory.choices, verbose_name=_('Category') + ) + apply_type = models.CharField( + max_length=16, choices=AppType.choices, verbose_name=_('Type') + ) + apply_applications = models.ManyToManyField( + 'applications.Application', verbose_name=_('Apply applications'), + ) + apply_system_users = models.ManyToManyField( + 'assets.SystemUser', verbose_name=_('Apply system users'), + ) + apply_date_start = models.DateTimeField(verbose_name=_('Date start'), null=True) + apply_date_expired = models.DateTimeField(verbose_name=_('Date expired'), null=True) + + @property + def apply_category_display(self): + return AppCategory.get_label(self.apply_category) + + @property + def apply_type_display(self): + return AppType.get_label(self.apply_type) diff --git a/apps/tickets/models/ticket/apply_asset.py b/apps/tickets/models/ticket/apply_asset.py new file mode 100644 index 000000000..45cad4dca --- /dev/null +++ b/apps/tickets/models/ticket/apply_asset.py @@ -0,0 +1,28 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from perms.models import Action +from .general import Ticket + +__all__ = ['ApplyAssetTicket'] + +asset_or_node_help_text = _("Select at least one asset or node") + + +class ApplyAssetTicket(Ticket): + apply_permission_name = models.CharField(max_length=128, verbose_name=_('Apply name')) + apply_nodes = models.ManyToManyField('assets.Node', verbose_name=_('Apply nodes')) + # 申请信息 + apply_assets = models.ManyToManyField('assets.Asset', verbose_name=_('Apply assets')) + apply_system_users = models.ManyToManyField( + 'assets.SystemUser', verbose_name=_('Apply system users') + ) + apply_actions = models.IntegerField( + choices=Action.DB_CHOICES, default=Action.ALL, verbose_name=_('Actions') + ) + apply_date_start = models.DateTimeField(verbose_name=_('Date start'), null=True) + apply_date_expired = models.DateTimeField(verbose_name=_('Date expired'), null=True) + + @property + def apply_actions_display(self): + return Action.value_to_choices_display(self.apply_actions) diff --git a/apps/tickets/models/ticket/command_confirm.py b/apps/tickets/models/ticket/command_confirm.py new file mode 100644 index 000000000..94d27fde7 --- /dev/null +++ b/apps/tickets/models/ticket/command_confirm.py @@ -0,0 +1,33 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from .general import Ticket + + +class ApplyCommandTicket(Ticket): + apply_run_user = models.ForeignKey( + 'users.User', on_delete=models.SET_NULL, + null=True, verbose_name=_('Run user') + ) + apply_run_asset = models.ForeignKey( + 'assets.Asset', on_delete=models.SET_NULL, + null=True, verbose_name=_('Run asset') + ) + apply_run_system_user = models.ForeignKey( + 'assets.SystemUser', on_delete=models.SET_NULL, + null=True, verbose_name=_('Run system user') + ) + apply_run_command = models.CharField(max_length=4096, verbose_name=_('Run command')) + apply_from_session = models.ForeignKey( + 'terminal.Session', on_delete=models.SET_NULL, + null=True, verbose_name=_("Session") + ) + apply_from_cmd_filter = models.ForeignKey( + 'assets.CommandFilter', on_delete=models.SET_NULL, + null=True, verbose_name=_('From cmd filter') + ) + apply_from_cmd_filter_rule = models.ForeignKey( + 'assets.CommandFilterRule', on_delete=models.SET_NULL, + null=True, verbose_name=_('From cmd filter rule') + ) + diff --git a/apps/tickets/models/ticket/general.py b/apps/tickets/models/ticket/general.py new file mode 100644 index 000000000..b8f192a9d --- /dev/null +++ b/apps/tickets/models/ticket/general.py @@ -0,0 +1,389 @@ +# -*- coding: utf-8 -*- +# +from typing import Callable + +from django.db import models +from django.db.models import Q +from django.utils.translation import ugettext_lazy as _ +from django.db.utils import IntegrityError +from django.db.models.fields import related + +from common.exceptions import JMSException +from common.utils.timezone import as_current_tz +from common.mixins.models import CommonModelMixin +from common.db.encoder import ModelJSONFieldEncoder +from orgs.models import Organization +from tickets.const import ( + TicketType, TicketStatus, TicketState, + TicketLevel, StepState, StepStatus +) +from tickets.handlers import get_ticket_handler +from tickets.errors import AlreadyClosed +from ..flow import TicketFlow + +__all__ = ['Ticket', 'TicketStep', 'TicketAssignee', 'SuperTicket', 'SubTicketManager'] + + +class TicketStep(CommonModelMixin): + ticket = models.ForeignKey( + 'Ticket', related_name='ticket_steps', + on_delete=models.CASCADE, verbose_name='Ticket' + ) + level = models.SmallIntegerField( + default=TicketLevel.one, choices=TicketLevel.choices, + verbose_name=_('Approve level') + ) + state = models.CharField( + max_length=64, choices=StepState.choices, + default=StepState.pending, verbose_name=_("State") + ) + status = models.CharField( + max_length=16, choices=StepStatus.choices, + default=StepStatus.pending + ) + + def change_state(self, state, processor): + if state != StepState.closed: + assignees = self.ticket_assignees.filter(assignee=processor) + if not assignees: + raise PermissionError('Only assignees can do this') + assignees.update(state=state) + self.status = StepStatus.closed + self.state = state + self.save(update_fields=['state', 'status']) + + def set_active(self): + self.status = StepStatus.active + self.save(update_fields=['status']) + + def next(self): + kwargs = dict(ticket=self.ticket, level=self.level + 1, status=StepStatus.pending) + return self.__class__.objects.filter(**kwargs).first() + + @property + def processor(self): + processor = self.ticket_assignees.exclude(state=StepState.pending).first() + return processor.assignee if processor else None + + class Meta: + verbose_name = _("Ticket step") + + +class TicketAssignee(CommonModelMixin): + assignee = models.ForeignKey( + 'users.User', related_name='ticket_assignees', + on_delete=models.CASCADE, verbose_name='Assignee' + ) + state = models.CharField( + choices=TicketState.choices, max_length=64, + default=TicketState.pending + ) + step = models.ForeignKey( + 'tickets.TicketStep', related_name='ticket_assignees', + on_delete=models.CASCADE + ) + + class Meta: + verbose_name = _('Ticket assignee') + + def __str__(self): + return '{0.assignee.name}({0.assignee.username})_{0.step}'.format(self) + + +class StatusMixin: + State = TicketState + Status = TicketStatus + + state: str + status: str + applicant: models.ForeignKey + current_step: TicketStep + save: Callable + create_process_steps_by_flow: Callable + create_process_steps_by_assignees: Callable + assignees: Callable + set_serial_num: Callable + set_rel_snapshot: Callable + approval_step: int + handler: None + flow: TicketFlow + ticket_steps: models.Manager + + def is_state(self, state: TicketState): + return self.state == state + + def is_status(self, status: TicketStatus): + return self.status == status + + def _open(self): + self.set_serial_num() + self.set_rel_snapshot() + self._change_state_by_applicant(TicketState.pending) + + def open(self): + self.create_process_steps_by_flow() + self._open() + + def open_by_system(self, assignees): + self.create_process_steps_by_assignees(assignees) + self._open() + + def approve(self, processor): + self._change_state(StepState.approved, processor) + + def reject(self, processor): + self._change_state(StepState.rejected, processor) + + def reopen(self): + self._change_state_by_applicant(TicketState.reopen) + + def close(self): + self._change_state(TicketState.closed, self.applicant) + + def _change_state_by_applicant(self, state): + if state == TicketState.closed: + self.status = TicketStatus.closed + elif state in [TicketState.reopen, TicketState.pending]: + self.status = TicketStatus.open + else: + raise ValueError("Not supported state: {}".format(state)) + + self.state = state + self.save(update_fields=['state', 'status']) + self.handler.on_change_state(state) + + def _change_state(self, state, processor): + if self.is_status(self.Status.closed): + raise AlreadyClosed + current_step = self.current_step + current_step.change_state(state, processor) + self._finish_or_next(current_step, state) + + def _finish_or_next(self, current_step, state): + next_step = current_step.next() + + # 提前结束,或者最后一步 + if state in [TicketState.rejected, TicketState.closed] or not next_step: + self.state = state + self.status = Ticket.Status.closed + self.save(update_fields=['state', 'status']) + self.handler.on_step_state_change(current_step, state) + else: + self.handler.on_step_state_change(current_step, state) + next_step.set_active() + self.approval_step += 1 + self.save(update_fields=['approval_step']) + + @property + def process_map(self): + process_map = [] + steps = self.ticket_steps.all() + for step in steps: + assignee_ids = [] + assignees_display = [] + ticket_assignees = step.ticket_assignees.all() + processor = None + state = step.state + for i in ticket_assignees: + assignee_ids.append(i.assignee_id) + assignees_display.append(str(i.assignee)) + if state != StepState.pending and state == i.state: + processor = i.assignee + if state != StepState.closed: + processor = self.applicant + step_info = { + 'state': state, + 'approval_level': step.level, + 'assignees': assignee_ids, + 'assignees_display': assignees_display, + 'approval_date': str(step.date_updated), + 'processor': processor.id if processor else '', + 'processor_display': str(processor) if processor else '' + } + process_map.append(step_info) + return process_map + + def exclude_applicant(self, assignees, applicant=None): + applicant = applicant if applicant else self.applicant + if len(assignees) != 1: + assignees = set(assignees) - {applicant, } + return list(assignees) + + def create_process_steps_by_flow(self): + org_id = self.flow.org_id + flow_rules = self.flow.rules.order_by('level') + for rule in flow_rules: + step = TicketStep.objects.create(ticket=self, level=rule.level) + assignees = rule.get_assignees(org_id=org_id) + assignees = self.exclude_applicant(assignees, self.applicant) + step_assignees = [TicketAssignee(step=step, assignee=user) for user in assignees] + TicketAssignee.objects.bulk_create(step_assignees) + + def create_process_steps_by_assignees(self, assignees): + assignees = self.exclude_applicant(assignees, self.applicant) + step = TicketStep.objects.create(ticket=self, level=1) + ticket_assignees = [TicketAssignee(step=step, assignee=user) for user in assignees] + TicketAssignee.objects.bulk_create(ticket_assignees) + + @property + def current_step(self): + return self.ticket_steps.filter(level=self.approval_step).first() + + @property + def current_assignees(self): + ticket_assignees = self.current_step.ticket_assignees.all() + return [i.assignee for i in ticket_assignees] + + @property + def processor(self): + processor = self.current_step.ticket_assignees \ + .exclude(state=StepState.pending) \ + .first() + return processor.assignee if processor else None + + def has_current_assignee(self, assignee): + return self.ticket_steps.filter( + ticket_assignees__assignee=assignee, + level=self.approval_step + ).exists() + + def has_all_assignee(self, assignee): + return self.ticket_steps.filter(ticket_assignees__assignee=assignee).exists() + + @property + def handler(self): + return get_ticket_handler(ticket=self) + + +class Ticket(StatusMixin, CommonModelMixin): + title = models.CharField(max_length=256, verbose_name=_('Title')) + type = models.CharField( + max_length=64, choices=TicketType.choices, + default=TicketType.general, verbose_name=_('Type') + ) + state = models.CharField( + max_length=16, choices=TicketState.choices, + default=TicketState.pending, verbose_name=_('State') + ) + status = models.CharField( + max_length=16, choices=TicketStatus.choices, + default=TicketStatus.open, verbose_name=_('Status') + ) + # 申请人 + applicant = models.ForeignKey( + 'users.User', related_name='applied_tickets', on_delete=models.SET_NULL, + null=True, verbose_name=_("Applicant") + ) + comment = models.TextField(default='', blank=True, verbose_name=_('Comment')) + flow = models.ForeignKey( + 'TicketFlow', related_name='tickets', on_delete=models.SET_NULL, + null=True, verbose_name=_('TicketFlow') + ) + approval_step = models.SmallIntegerField( + default=TicketLevel.one, choices=TicketLevel.choices, verbose_name=_('Approval step') + ) + serial_num = models.CharField(_('Serial number'), max_length=128, unique=True, null=True) + rel_snapshot = models.JSONField(verbose_name=_('Relation snapshot'), default=dict) + meta = models.JSONField(encoder=ModelJSONFieldEncoder, default=dict, verbose_name=_("Meta")) + org_id = models.CharField( + max_length=36, blank=True, default='', verbose_name=_('Organization'), db_index=True + ) + + class Meta: + ordering = ('-date_created',) + verbose_name = _('Ticket') + + def __str__(self): + return '{}({})'.format(self.title, self.applicant) + + @property + def spec_ticket(self): + attr = self.type.replace('_', '') + 'ticket' + return getattr(self, attr) + + # TODO 先单独处理一下 + @property + def org_name(self): + org = Organization.get_instance(self.org_id) + return org.name + + def is_type(self, tp: TicketType): + return self.type == tp + + @classmethod + def get_user_related_tickets(cls, user): + queries = Q(applicant=user) | Q(ticket_steps__ticket_assignees__assignee=user) + tickets = cls.objects.all().filter(queries).distinct() + return tickets + + def get_current_ticket_flow_approve(self): + return self.flow.rules.filter(level=self.approval_step).first() + + @classmethod + def all(cls): + return cls.objects.all() + + def set_rel_snapshot(self, save=True): + rel_fields = set() + m2m_fields = set() + excludes = ['ticket_ptr_id', 'ticket_ptr', 'flow_id', 'flow', 'applicant_id'] + for name, field in self._meta._forward_fields_map.items(): + if name in excludes: + continue + if isinstance(field, related.RelatedField): + rel_fields.add(name) + if isinstance(field, related.ManyToManyField): + m2m_fields.add(name) + + snapshot = {} + for field in rel_fields: + value = getattr(self, field) + + if field in m2m_fields: + value = [str(v) for v in value.all()] + else: + value = str(value) if value else '' + snapshot[field] = value + + self.rel_snapshot.update(snapshot) + if save: + self.save(update_fields=('rel_snapshot',)) + + def get_next_serial_num(self): + date_created = as_current_tz(self.date_created) + date_prefix = date_created.strftime('%Y%m%d') + + ticket = Ticket.objects.all().select_for_update().filter( + serial_num__startswith=date_prefix + ).order_by('-date_created').first() + + last_num = 0 + if ticket: + last_num = ticket.serial_num[8:] + last_num = int(last_num) + num = '%04d' % (last_num + 1) + return '{}{}'.format(date_prefix, num) + + def set_serial_num(self): + if self.serial_num: + return + + try: + self.serial_num = self.get_next_serial_num() + self.save(update_fields=('serial_num',)) + except IntegrityError as e: + if e.args[0] == 1062: + # 虽然做了 `select_for_update` 但是每天的第一条工单仍可能造成冲突 + # 但概率小,这里只报错,用户重新提交即可 + raise JMSException(detail=_('Please try again'), code='please_try_again') + raise e + + +class SuperTicket(Ticket): + class Meta: + proxy = True + verbose_name = _("Super ticket") + + +class SubTicketManager(models.Manager): + pass diff --git a/apps/tickets/models/ticket/login_asset_confirm.py b/apps/tickets/models/ticket/login_asset_confirm.py new file mode 100644 index 000000000..5e5c53a47 --- /dev/null +++ b/apps/tickets/models/ticket/login_asset_confirm.py @@ -0,0 +1,21 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from .general import Ticket + +__all__ = ['ApplyLoginAssetTicket'] + + +class ApplyLoginAssetTicket(Ticket): + apply_login_user = models.ForeignKey( + 'users.User', on_delete=models.SET_NULL, null=True, + verbose_name=_('Login user'), + ) + apply_login_asset = models.ForeignKey( + 'assets.Asset', on_delete=models.SET_NULL, null=True, + verbose_name=_('Login asset'), + ) + apply_login_system_user = models.ForeignKey( + 'assets.SystemUser', on_delete=models.SET_NULL, null=True, + verbose_name=_('Login system user'), + ) diff --git a/apps/tickets/models/ticket/login_confirm.py b/apps/tickets/models/ticket/login_confirm.py new file mode 100644 index 000000000..89fb32e94 --- /dev/null +++ b/apps/tickets/models/ticket/login_confirm.py @@ -0,0 +1,12 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from .general import Ticket, SubTicketManager + +__all__ = ['ApplyLoginTicket'] + + +class ApplyLoginTicket(Ticket): + apply_login_ip = models.GenericIPAddressField(verbose_name=_('Login ip'), null=True) + apply_login_city = models.CharField(max_length=64, verbose_name=_('Login city'), null=True) + apply_login_datetime = models.DateTimeField(verbose_name=_('Login datetime'), null=True) diff --git a/apps/tickets/notifications.py b/apps/tickets/notifications.py index c29c8d00e..728510280 100644 --- a/apps/tickets/notifications.py +++ b/apps/tickets/notifications.py @@ -1,13 +1,17 @@ from urllib.parse import urljoin +import json from django.conf import settings from django.core.cache import cache from django.shortcuts import reverse +from django.db.models.fields import related from django.template.loader import render_to_string +from django.forms import model_to_dict from django.utils.translation import ugettext_lazy as _ from notifications.notifications import UserMessage from common.utils import get_logger, random_string +from common.db.encoder import ModelJSONFieldEncoder from .models import Ticket from . import const @@ -41,8 +45,8 @@ class BaseTicketMessage(UserMessage): def get_html_msg(self) -> dict: context = dict( title=self.content_title, - ticket_detail_url=self.ticket_detail_url, - body=self.ticket.body.replace('\n', '
'), + content=self.content, + ticket_detail_url=self.ticket_detail_url ) message = render_to_string('tickets/_msg_ticket.html', context) return { @@ -54,8 +58,48 @@ class BaseTicketMessage(UserMessage): def gen_test_msg(cls): return None + @property + def content(self): + content = [ + {'title': _('Ticket basic info'), 'content': self.basic_items}, + {'title': _('Ticket applied info'), 'content': self.spec_items}, + ] + return content -class TicketAppliedToAssignee(BaseTicketMessage): + def _get_fields_items(self, item_names): + fields = self.ticket._meta._forward_fields_map + json_data = json.dumps(model_to_dict(self.ticket), cls=ModelJSONFieldEncoder) + data = json.loads(json_data) + items = [] + + for name in item_names: + field = fields[name] + item = {'name': name, 'title': field.verbose_name} + value = data.get(name) + if hasattr(self.ticket, f'get_{name}_display'): + value = getattr(self.ticket, f'get_{name}_display')() + elif isinstance(field, related.ForeignKey): + value = self.ticket.rel_snapshot[name] + elif isinstance(field, related.ManyToManyField): + value = ', '.join(self.ticket.rel_snapshot[name]) + item['value'] = value + items.append(item) + return items + + @property + def basic_items(self): + item_names = ['serial_num', 'title', 'type', 'state', 'applicant', 'comment'] + return self._get_fields_items(item_names) + + @property + def spec_items(self): + fields = self.ticket._meta.local_fields + self.ticket._meta.local_many_to_many + excludes = ['ticket_ptr'] + item_names = [field.name for field in fields if field.name not in excludes] + return self._get_fields_items(item_names) + + +class TicketAppliedToAssigneeMessage(BaseTicketMessage): def __init__(self, user, ticket): self.ticket = ticket super().__init__(user) @@ -69,14 +113,14 @@ class TicketAppliedToAssignee(BaseTicketMessage): @property def content_title(self): - return _('Your has a new ticket, applicant - {}').format( - str(self.ticket.applicant_display) - ) + return _('Your has a new ticket') @property def subject(self): - title = _('New Ticket - {} ({})').format( - self.ticket.title, self.ticket.get_type_display() + title = _('{}: New Ticket - {} ({})').format( + self.ticket.applicant, + self.ticket.title, + self.ticket.get_type_display() ) return title @@ -85,19 +129,16 @@ class TicketAppliedToAssignee(BaseTicketMessage): return urljoin(settings.SITE_URL, url) def get_html_msg(self) -> dict: - body = self.ticket.body.replace('\n', '
') context = dict( title=self.content_title, - ticket_detail_url=self.ticket_detail_url, - body=body, + content=self.content, + ticket_detail_url=self.ticket_detail_url ) ticket_approval_url = self.get_ticket_approval_url() context.update({'ticket_approval_url': ticket_approval_url}) message = render_to_string('tickets/_msg_ticket.html', context) - cache.set(self.token, { - 'body': body, 'ticket_id': self.ticket.id - }, 3600) + cache.set(self.token, {'ticket_id': self.ticket.id, 'content': self.content}, 3600) return { 'subject': self.subject, 'message': message @@ -112,7 +153,7 @@ class TicketAppliedToAssignee(BaseTicketMessage): return cls(user, ticket) -class TicketProcessedToApplicant(BaseTicketMessage): +class TicketProcessedToApplicantMessage(BaseTicketMessage): def __init__(self, user, ticket, processor): self.ticket = ticket self.processor = processor diff --git a/apps/tickets/serializers/__init__.py b/apps/tickets/serializers/__init__.py index 853feafd1..26a4b9aa6 100644 --- a/apps/tickets/serializers/__init__.py +++ b/apps/tickets/serializers/__init__.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # from .ticket import * +from .flow import * from .comment import * from .relation import * -from .super_ticket import * \ No newline at end of file +from .super_ticket import * diff --git a/apps/tickets/serializers/flow.py b/apps/tickets/serializers/flow.py new file mode 100644 index 000000000..42c7bed15 --- /dev/null +++ b/apps/tickets/serializers/flow.py @@ -0,0 +1,103 @@ +from django.db.transaction import atomic +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers + +from orgs.models import Organization +from orgs.mixins.serializers import OrgResourceModelSerializerMixin +from tickets.models import TicketFlow, ApprovalRule +from tickets.const import TicketApprovalStrategy + +__all__ = ['TicketFlowSerializer'] + + +class TicketFlowApproveSerializer(serializers.ModelSerializer): + strategy_display = serializers.ReadOnlyField(source='get_strategy_display', label=_('Approve strategy')) + assignees_read_only = serializers.SerializerMethodField(label=_('Assignees')) + assignees_display = serializers.SerializerMethodField(label=_('Assignees display')) + + class Meta: + model = ApprovalRule + fields_small = [ + 'level', 'strategy', 'assignees_read_only', 'assignees_display', 'strategy_display' + ] + fields_m2m = ['assignees', ] + fields = fields_small + fields_m2m + read_only_fields = ['level', 'assignees_display'] + extra_kwargs = { + 'assignees': {'write_only': True, 'allow_empty': True, 'required': False} + } + + @staticmethod + def get_assignees_display(instance): + return [str(assignee) for assignee in instance.get_assignees()] + + @staticmethod + def get_assignees_read_only(instance): + if instance.strategy == TicketApprovalStrategy.custom_user: + return instance.assignees.values_list('id', flat=True) + return [] + + def validate(self, attrs): + if attrs['strategy'] == TicketApprovalStrategy.custom_user and not attrs.get('assignees'): + error = _('Please select the Assignees') + raise serializers.ValidationError({'assignees': error}) + return super().validate(attrs) + + +class TicketFlowSerializer(OrgResourceModelSerializerMixin): + type_display = serializers.ReadOnlyField(source='get_type_display', label=_('Type display')) + rules = TicketFlowApproveSerializer(many=True, required=True) + + class Meta: + model = TicketFlow + fields_mini = ['id', ] + fields_small = fields_mini + [ + 'type', 'type_display', 'approval_level', 'created_by', 'date_created', 'date_updated', + 'org_id', 'org_name' + ] + fields = fields_small + ['rules', ] + read_only_fields = ['created_by', 'org_id', 'date_created', 'date_updated'] + extra_kwargs = { + 'type': {'required': True}, + 'approval_level': {'required': True} + } + + def validate_type(self, value): + if not self.instance or (self.instance and self.instance.type != value): + if self.Meta.model.objects.filter(type=value).exists(): + error = _('The current organization type already exists') + raise serializers.ValidationError(error) + return value + + def create_or_update(self, action, validated_data, instance=None): + related = 'rules' + assignees = 'assignees' + childs = validated_data.pop(related, []) + if not instance: + instance = getattr(super(), action)(validated_data) + else: + instance = getattr(super(), action)(instance, validated_data) + getattr(instance, related).all().delete() + instance_related = getattr(instance, related) + child_instances = [] + related_model = instance_related.model + # Todo: 这个权限的判断 + for level, data in enumerate(childs, 1): + data_m2m = data.pop(assignees, None) + child_instance = related_model.objects.create(**data, level=level) + getattr(child_instance, assignees).set(data_m2m) + child_instances.append(child_instance) + instance_related.set(child_instances) + return instance + + @atomic + def create(self, validated_data): + return self.create_or_update('create', validated_data) + + @atomic + def update(self, instance, validated_data): + if instance.org_id == Organization.ROOT_ID: + instance = self.create(validated_data) + else: + instance = self.create_or_update('update', validated_data, instance) + return instance diff --git a/apps/tickets/serializers/super_ticket.py b/apps/tickets/serializers/super_ticket.py index 9d84728e4..9200c3f28 100644 --- a/apps/tickets/serializers/super_ticket.py +++ b/apps/tickets/serializers/super_ticket.py @@ -15,7 +15,7 @@ class SuperTicketSerializer(serializers.ModelSerializer): fields = ['id', 'status', 'state', 'processor'] @staticmethod - def get_processor(ticket): - if not ticket.processor: + def get_processor(instance): + if not instance.processor: return '' - return str(ticket.processor) + return str(instance.processor) diff --git a/apps/tickets/serializers/ticket/__init__.py b/apps/tickets/serializers/ticket/__init__.py index bb2ee74d6..698906d3a 100644 --- a/apps/tickets/serializers/ticket/__init__.py +++ b/apps/tickets/serializers/ticket/__init__.py @@ -1,2 +1,7 @@ from .ticket import * -from .meta import * +from .apply_asset import * +from .apply_application import * +from .login_confirm import * +from .login_asset_confirm import * +from .command_confirm import * +from .common import * diff --git a/apps/tickets/serializers/ticket/apply_application.py b/apps/tickets/serializers/ticket/apply_application.py new file mode 100644 index 000000000..b2774e420 --- /dev/null +++ b/apps/tickets/serializers/ticket/apply_application.py @@ -0,0 +1,57 @@ +from rest_framework import serializers + +from perms.models import ApplicationPermission +from orgs.utils import tmp_to_org +from applications.models import Application +from tickets.models import ApplyApplicationTicket +from .ticket import TicketApplySerializer +from .common import BaseApplyAssetApplicationSerializer + +__all__ = ['ApplyApplicationSerializer', 'ApplyApplicationDisplaySerializer'] + + +class ApplyApplicationSerializer(BaseApplyAssetApplicationSerializer, TicketApplySerializer): + permission_model = ApplicationPermission + + class Meta: + model = ApplyApplicationTicket + writeable_fields = [ + 'id', 'title', 'type', 'apply_category', + 'apply_type', 'apply_applications', 'apply_system_users', + 'apply_date_start', 'apply_date_expired', 'org_id' + ] + fields = TicketApplySerializer.Meta.fields + writeable_fields + ['apply_permission_name'] + read_only_fields = list(set(fields) - set(writeable_fields)) + ticket_extra_kwargs = TicketApplySerializer.Meta.extra_kwargs + extra_kwargs = {} + extra_kwargs.update(ticket_extra_kwargs) + + def validate_apply_applications(self, apply_applications): + type = self.initial_data.get('apply_type') + org_id = self.initial_data.get('org_id') + application_ids = [app.id for app in apply_applications] + with tmp_to_org(org_id): + applications = Application.objects.filter( + id__in=application_ids, type=type + ).values_list('id', flat=True) + return list(applications) + + +class ApplyApplicationDisplaySerializer(ApplyApplicationSerializer): + apply_applications = serializers.SerializerMethodField() + apply_system_users = serializers.SerializerMethodField() + + class Meta: + model = ApplyApplicationSerializer.Meta.model + fields = ApplyApplicationSerializer.Meta.fields + read_only_fields = fields + + @staticmethod + def get_apply_applications(instance): + with tmp_to_org(instance.org_id): + return instance.apply_applications.values_list('id', flat=True) + + @staticmethod + def get_apply_system_users(instance): + with tmp_to_org(instance.org_id): + return instance.apply_system_users.values_list('id', flat=True) diff --git a/apps/tickets/serializers/ticket/apply_asset.py b/apps/tickets/serializers/ticket/apply_asset.py new file mode 100644 index 000000000..3c8e0aade --- /dev/null +++ b/apps/tickets/serializers/ticket/apply_asset.py @@ -0,0 +1,70 @@ +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers + +from perms.serializers.base import ActionsField +from perms.models import AssetPermission +from orgs.utils import tmp_to_org + +from tickets.models import ApplyAssetTicket +from .ticket import TicketApplySerializer +from .common import BaseApplyAssetApplicationSerializer + +__all__ = ['ApplyAssetSerializer', 'ApplyAssetDisplaySerializer'] + +asset_or_node_help_text = _("Select at least one asset or node") + + +class ApplyAssetSerializer(BaseApplyAssetApplicationSerializer, TicketApplySerializer): + apply_actions = ActionsField(required=True, allow_null=True) + permission_model = AssetPermission + + class Meta: + model = ApplyAssetTicket + writeable_fields = [ + 'id', 'title', 'type', 'apply_nodes', 'apply_assets', + 'apply_system_users', 'apply_actions', 'apply_actions_display', + 'apply_date_start', 'apply_date_expired', 'org_id' + ] + fields = TicketApplySerializer.Meta.fields + writeable_fields + ['apply_permission_name'] + read_only_fields = list(set(fields) - set(writeable_fields)) + ticket_extra_kwargs = TicketApplySerializer.Meta.extra_kwargs + extra_kwargs = { + 'apply_nodes': {'required': False, 'help_text': asset_or_node_help_text}, + 'apply_assets': {'required': False, 'help_text': asset_or_node_help_text} + } + extra_kwargs.update(ticket_extra_kwargs) + + def validate(self, attrs): + attrs = super().validate(attrs) + if not attrs.get('apply_nodes') and not attrs.get('apply_assets'): + raise serializers.ValidationError({ + 'apply_nodes': asset_or_node_help_text, + 'apply_assets': asset_or_node_help_text, + }) + return attrs + + +class ApplyAssetDisplaySerializer(ApplyAssetSerializer): + apply_nodes = serializers.SerializerMethodField() + apply_assets = serializers.SerializerMethodField() + apply_system_users = serializers.SerializerMethodField() + + class Meta: + model = ApplyAssetSerializer.Meta.model + fields = ApplyAssetSerializer.Meta.fields + read_only_fields = fields + + @staticmethod + def get_apply_nodes(instance): + with tmp_to_org(instance.org_id): + return instance.apply_nodes.values_list('id', flat=True) + + @staticmethod + def get_apply_assets(instance): + with tmp_to_org(instance.org_id): + return instance.apply_assets.values_list('id', flat=True) + + @staticmethod + def get_apply_system_users(instance): + with tmp_to_org(instance.org_id): + return instance.apply_system_users.values_list('id', flat=True) diff --git a/apps/tickets/serializers/ticket/command_confirm.py b/apps/tickets/serializers/ticket/command_confirm.py new file mode 100644 index 000000000..c52bfd153 --- /dev/null +++ b/apps/tickets/serializers/ticket/command_confirm.py @@ -0,0 +1,16 @@ +from tickets.models import ApplyCommandTicket +from .ticket import TicketApplySerializer + +__all__ = [ + 'ApplyCommandConfirmSerializer', +] + + +class ApplyCommandConfirmSerializer(TicketApplySerializer): + class Meta: + model = ApplyCommandTicket + fields = TicketApplySerializer.Meta.fields + [ + 'apply_run_user', 'apply_run_asset', 'apply_run_system_user', + 'apply_run_command', 'apply_from_session', 'apply_from_cmd_filter', + 'apply_from_cmd_filter_rule' + ] diff --git a/apps/tickets/serializers/ticket/common.py b/apps/tickets/serializers/ticket/common.py new file mode 100644 index 000000000..ef52c14d3 --- /dev/null +++ b/apps/tickets/serializers/ticket/common.py @@ -0,0 +1,63 @@ +from django.db.transaction import atomic +from django.db.models import Model +from django.utils.translation import ugettext as _ +from rest_framework import serializers + +from orgs.utils import tmp_to_org +from tickets.models import Ticket + +__all__ = ['DefaultPermissionName', 'get_default_permission_name', 'BaseApplyAssetApplicationSerializer'] + + +def get_default_permission_name(ticket): + name = '' + if isinstance(ticket, Ticket): + name = _('Created by ticket ({}-{})').format(ticket.title, str(ticket.id)[:4]) + return name + + +class DefaultPermissionName(object): + default = None + + @staticmethod + def _construct_default_permission_name(serializer_field): + permission_name = '' + ticket = serializer_field.root.instance + if isinstance(ticket, Ticket): + permission_name = get_default_permission_name(ticket) + return permission_name + + def set_context(self, serializer_field): + self.default = self._construct_default_permission_name(serializer_field) + + def __call__(self): + return self.default + + +class BaseApplyAssetApplicationSerializer(serializers.Serializer): + permission_model: Model + + def validate(self, attrs): + attrs = super().validate(attrs) + + apply_date_start = attrs['apply_date_start'].strftime('%Y-%m-%d %H:%M:%S') + apply_date_expired = attrs['apply_date_expired'].strftime('%Y-%m-%d %H:%M:%S') + + if apply_date_expired <= apply_date_start: + error = _('The expiration date should be greater than the start date') + raise serializers.ValidationError({'apply_date_expired': error}) + + attrs['apply_date_start'] = apply_date_start + attrs['apply_date_expired'] = apply_date_expired + return attrs + + @atomic + def create(self, validated_data): + instance = super().create(validated_data) + name = _('Created by ticket ({}-{})').format(instance.title, str(instance.id)[:4]) + with tmp_to_org(instance.org_id): + if not self.permission_model.objects.filter(name=name).exists(): + instance.apply_permission_name = name + instance.save() + return instance + raise serializers.ValidationError(_('Permission named `{}` already exists'.format(name))) diff --git a/apps/tickets/serializers/ticket/login_asset_confirm.py b/apps/tickets/serializers/ticket/login_asset_confirm.py new file mode 100644 index 000000000..9e3076f0f --- /dev/null +++ b/apps/tickets/serializers/ticket/login_asset_confirm.py @@ -0,0 +1,14 @@ +from tickets.models import ApplyLoginAssetTicket +from .ticket import TicketApplySerializer + +__all__ = [ + 'LoginAssetConfirmSerializer' +] + + +class LoginAssetConfirmSerializer(TicketApplySerializer): + class Meta: + model = ApplyLoginAssetTicket + fields = TicketApplySerializer.Meta.fields + [ + 'apply_login_user', 'apply_login_asset', 'apply_login_system_user' + ] diff --git a/apps/tickets/serializers/ticket/login_confirm.py b/apps/tickets/serializers/ticket/login_confirm.py new file mode 100644 index 000000000..e760c653f --- /dev/null +++ b/apps/tickets/serializers/ticket/login_confirm.py @@ -0,0 +1,14 @@ +from tickets.models import ApplyLoginTicket +from .ticket import TicketApplySerializer + +__all__ = [ + 'LoginConfirmSerializer' +] + + +class LoginConfirmSerializer(TicketApplySerializer): + class Meta: + model = ApplyLoginTicket + fields = TicketApplySerializer.Meta.fields + [ + 'apply_login_ip', 'apply_login_city', 'apply_login_datetime' + ] diff --git a/apps/tickets/serializers/ticket/meta/__init__.py b/apps/tickets/serializers/ticket/meta/__init__.py deleted file mode 100644 index 7b5fbad28..000000000 --- a/apps/tickets/serializers/ticket/meta/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .meta import * diff --git a/apps/tickets/serializers/ticket/meta/meta.py b/apps/tickets/serializers/ticket/meta/meta.py deleted file mode 100644 index 936977dfc..000000000 --- a/apps/tickets/serializers/ticket/meta/meta.py +++ /dev/null @@ -1,43 +0,0 @@ -from tickets import const -from .ticket_type import ( - apply_asset, apply_application, login_confirm, - login_asset_confirm, command_confirm -) - -__all__ = [ - 'type_serializer_classes_mapping', -] - -# ticket action -# ------------- - -action_open = const.TicketAction.open.value -action_approve = const.TicketAction.approve.value - - -# defines `meta` field dynamic mapping serializers -# ------------------------------------------------ - -type_serializer_classes_mapping = { - const.TicketType.apply_asset.value: { - 'default': apply_asset.ApplySerializer - }, - const.TicketType.apply_application.value: { - 'default': apply_application.ApplySerializer - }, - const.TicketType.login_confirm.value: { - 'default': login_confirm.LoginConfirmSerializer, - action_open: login_confirm.ApplySerializer, - action_approve: login_confirm.LoginConfirmSerializer(read_only=True), - }, - const.TicketType.login_asset_confirm.value: { - 'default': login_asset_confirm.LoginAssetConfirmSerializer, - action_open: login_asset_confirm.ApplySerializer, - action_approve: login_asset_confirm.LoginAssetConfirmSerializer(read_only=True), - }, - const.TicketType.command_confirm.value: { - 'default': command_confirm.CommandConfirmSerializer, - action_open: command_confirm.ApplySerializer, - action_approve: command_confirm.CommandConfirmSerializer(read_only=True) - } -} diff --git a/apps/tickets/serializers/ticket/meta/ticket_type/__init__.py b/apps/tickets/serializers/ticket/meta/ticket_type/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/tickets/serializers/ticket/meta/ticket_type/apply_application.py b/apps/tickets/serializers/ticket/meta/ticket_type/apply_application.py deleted file mode 100644 index a7f60ae82..000000000 --- a/apps/tickets/serializers/ticket/meta/ticket_type/apply_application.py +++ /dev/null @@ -1,93 +0,0 @@ -from django.utils.translation import ugettext_lazy as _ -from rest_framework import serializers - -from perms.models import ApplicationPermission -from applications.const import AppCategory, AppType -from orgs.utils import tmp_to_org -from tickets.models import Ticket -from applications.models import Application -from .common import DefaultPermissionName - -__all__ = [ - 'ApplySerializer', -] - - -class ApplySerializer(serializers.Serializer): - apply_permission_name = serializers.CharField( - max_length=128, default=DefaultPermissionName(), label=_('Apply name') - ) - # 申请信息 - apply_category = serializers.ChoiceField( - required=True, choices=AppCategory.choices, label=_('Category'), - allow_null=True, - ) - apply_category_display = serializers.CharField( - read_only=True, label=_('Category display'), allow_null=True, - ) - apply_type = serializers.ChoiceField( - required=True, choices=AppType.choices, label=_('Type'), - allow_null=True - ) - apply_type_display = serializers.CharField( - required=False, read_only=True, label=_('Type display'), - allow_null=True - ) - apply_applications = serializers.ListField( - required=True, child=serializers.UUIDField(), label=_('Apply applications'), - allow_null=True - ) - apply_applications_display = serializers.ListField( - required=False, read_only=True, child=serializers.CharField(), - label=_('Apply applications display'), allow_null=True, - default=list - ) - apply_system_users = serializers.ListField( - required=True, child=serializers.UUIDField(), label=_('Apply system users'), - allow_null=True - ) - apply_system_users_display = serializers.ListField( - required=False, read_only=True, child=serializers.CharField(), - label=_('Apply system user display'), allow_null=True, - default=list - ) - apply_date_start = serializers.DateTimeField( - required=True, label=_('Date start'), allow_null=True - ) - apply_date_expired = serializers.DateTimeField( - required=True, label=_('Date expired'), allow_null=True - ) - - def validate_approve_permission_name(self, permission_name): - if not isinstance(self.root.instance, Ticket): - return permission_name - - with tmp_to_org(self.root.instance.org_id): - already_exists = ApplicationPermission.objects.filter(name=permission_name).exists() - if not already_exists: - return permission_name - - raise serializers.ValidationError(_( - 'Permission named `{}` already exists'.format(permission_name) - )) - - def validate_apply_applications(self, apply_applications): - type = self.root.initial_data['meta'].get('apply_type') - org_id = self.root.initial_data.get('org_id') - with tmp_to_org(org_id): - applications = Application.objects.filter( - id__in=apply_applications, type=type - ).values_list('id', flat=True) - return list(applications) - - def validate(self, attrs): - apply_date_start = attrs['apply_date_start'].strftime('%Y-%m-%d %H:%M:%S') - apply_date_expired = attrs['apply_date_expired'].strftime('%Y-%m-%d %H:%M:%S') - - if apply_date_expired <= apply_date_start: - error = _('The expiration date should be greater than the start date') - raise serializers.ValidationError({'apply_date_expired': error}) - - attrs['apply_date_start'] = apply_date_start - attrs['apply_date_expired'] = apply_date_expired - return attrs diff --git a/apps/tickets/serializers/ticket/meta/ticket_type/apply_asset.py b/apps/tickets/serializers/ticket/meta/ticket_type/apply_asset.py deleted file mode 100644 index ddd77d769..000000000 --- a/apps/tickets/serializers/ticket/meta/ticket_type/apply_asset.py +++ /dev/null @@ -1,92 +0,0 @@ -from django.utils.translation import ugettext_lazy as _ -from rest_framework import serializers - -from perms.serializers.base import ActionsField -from perms.models import AssetPermission -from orgs.utils import tmp_to_org -from tickets.models import Ticket -from .common import DefaultPermissionName - -__all__ = [ - 'ApplySerializer', -] - -asset_or_node_help_text = _("Select at least one asset or node") - - -class ApplySerializer(serializers.Serializer): - apply_permission_name = serializers.CharField( - max_length=128, default=DefaultPermissionName(), label=_('Apply name') - ) - apply_nodes = serializers.ListField( - required=False, allow_null=True, child=serializers.UUIDField(), - label=_('Apply nodes'), help_text=asset_or_node_help_text, - default=list - ) - apply_nodes_display = serializers.ListField( - child=serializers.CharField(), label=_('Apply nodes display'), required=False - ) - # 申请信息 - apply_assets = serializers.ListField( - required=False, allow_null=True, child=serializers.UUIDField(), - label=_('Apply assets'), help_text=asset_or_node_help_text - ) - apply_assets_display = serializers.ListField( - required=False, read_only=True, child=serializers.CharField(), - label=_('Apply assets display'), allow_null=True, - default=list - ) - apply_system_users = serializers.ListField( - required=True, allow_null=True, child=serializers.UUIDField(), - label=_('Apply system users') - ) - apply_system_users_display = serializers.ListField( - required=False, read_only=True, child=serializers.CharField(), - label=_('Apply assets display'), allow_null=True, - default=list, - ) - apply_actions = ActionsField( - required=True, allow_null=True - ) - apply_actions_display = serializers.ListField( - required=False, read_only=True, child=serializers.CharField(), - label=_('Apply assets display'), allow_null=True, - default=list, - ) - apply_date_start = serializers.DateTimeField( - required=True, label=_('Date start'), allow_null=True, - ) - apply_date_expired = serializers.DateTimeField( - required=True, label=_('Date expired'), allow_null=True, - ) - - def validate_approve_permission_name(self, permission_name): - if not isinstance(self.root.instance, Ticket): - return permission_name - - with tmp_to_org(self.root.instance.org_id): - already_exists = AssetPermission.objects.filter(name=permission_name).exists() - if not already_exists: - return permission_name - - raise serializers.ValidationError(_( - 'Permission named `{}` already exists'.format(permission_name) - )) - - def validate(self, attrs): - if not attrs.get('apply_nodes') and not attrs.get('apply_assets'): - raise serializers.ValidationError({ - 'apply_nodes': asset_or_node_help_text, - 'apply_assets': asset_or_node_help_text, - }) - - apply_date_start = attrs['apply_date_start'].strftime('%Y-%m-%d %H:%M:%S') - apply_date_expired = attrs['apply_date_expired'].strftime('%Y-%m-%d %H:%M:%S') - - if apply_date_expired <= apply_date_start: - error = _('The expiration date should be greater than the start date') - raise serializers.ValidationError({'apply_date_expired': error}) - - attrs['apply_date_start'] = apply_date_start - attrs['apply_date_expired'] = apply_date_expired - return attrs diff --git a/apps/tickets/serializers/ticket/meta/ticket_type/command_confirm.py b/apps/tickets/serializers/ticket/meta/ticket_type/command_confirm.py deleted file mode 100644 index eb631fe98..000000000 --- a/apps/tickets/serializers/ticket/meta/ticket_type/command_confirm.py +++ /dev/null @@ -1,26 +0,0 @@ -from rest_framework import serializers -from django.utils.translation import ugettext_lazy as _ - - -__all__ = [ - 'ApplySerializer', 'CommandConfirmSerializer', -] - - -class ApplySerializer(serializers.Serializer): - # 申请信息 - apply_run_user = serializers.CharField(required=True, label=_('Run user')) - apply_run_asset = serializers.CharField(required=True, label=_('Run asset')) - apply_run_system_user = serializers.CharField( - required=True, max_length=64, label=_('Run system user') - ) - apply_run_command = serializers.CharField(required=True, label=_('Run command')) - apply_from_session_id = serializers.UUIDField(required=False, label=_('From session')) - apply_from_cmd_filter_rule_id = serializers.UUIDField( - required=False, label=_('From cmd filter rule') - ) - apply_from_cmd_filter_id = serializers.UUIDField(required=False, label=_('From cmd filter')) - - -class CommandConfirmSerializer(ApplySerializer): - pass diff --git a/apps/tickets/serializers/ticket/meta/ticket_type/common.py b/apps/tickets/serializers/ticket/meta/ticket_type/common.py deleted file mode 100644 index 2d43f6a81..000000000 --- a/apps/tickets/serializers/ticket/meta/ticket_type/common.py +++ /dev/null @@ -1,30 +0,0 @@ -from django.utils.translation import ugettext as _ -from tickets.models import Ticket - - -__all__ = ['DefaultPermissionName', 'get_default_permission_name'] - - -def get_default_permission_name(ticket): - name = '' - if isinstance(ticket, Ticket): - name = _('Created by ticket ({}-{})').format(ticket.title, str(ticket.id)[:4]) - return name - - -class DefaultPermissionName(object): - default = None - - @staticmethod - def _construct_default_permission_name(serializer_field): - permission_name = '' - ticket = serializer_field.root.instance - if isinstance(ticket, Ticket): - permission_name = get_default_permission_name(ticket) - return permission_name - - def set_context(self, serializer_field): - self.default = self._construct_default_permission_name(serializer_field) - - def __call__(self): - return self.default diff --git a/apps/tickets/serializers/ticket/meta/ticket_type/login_asset_confirm.py b/apps/tickets/serializers/ticket/meta/ticket_type/login_asset_confirm.py deleted file mode 100644 index 2570e4792..000000000 --- a/apps/tickets/serializers/ticket/meta/ticket_type/login_asset_confirm.py +++ /dev/null @@ -1,21 +0,0 @@ - -from rest_framework import serializers -from django.utils.translation import ugettext_lazy as _ - - -__all__ = [ - 'ApplySerializer', 'LoginAssetConfirmSerializer', -] - - -class ApplySerializer(serializers.Serializer): - # 申请信息 - apply_login_user = serializers.CharField(required=True, label=_('Login user')) - apply_login_asset = serializers.CharField(required=True, label=_('Login asset')) - apply_login_system_user = serializers.CharField( - required=True, max_length=64, label=_('Login system user') - ) - - -class LoginAssetConfirmSerializer(ApplySerializer): - pass diff --git a/apps/tickets/serializers/ticket/meta/ticket_type/login_confirm.py b/apps/tickets/serializers/ticket/meta/ticket_type/login_confirm.py deleted file mode 100644 index 9308d0ee2..000000000 --- a/apps/tickets/serializers/ticket/meta/ticket_type/login_confirm.py +++ /dev/null @@ -1,25 +0,0 @@ - -from rest_framework import serializers -from django.utils.translation import ugettext_lazy as _ - - -__all__ = [ - 'ApplySerializer', 'LoginConfirmSerializer', -] - - -class ApplySerializer(serializers.Serializer): - # 申请信息 - apply_login_ip = serializers.IPAddressField( - required=True, label=_('Login ip'), allow_null=True - ) - apply_login_city = serializers.CharField( - required=True, max_length=64, label=_('Login city'), allow_null=True - ) - apply_login_datetime = serializers.DateTimeField( - required=True, label=_('Login datetime'), allow_null=True - ) - - -class LoginConfirmSerializer(ApplySerializer): - pass diff --git a/apps/tickets/serializers/ticket/ticket.py b/apps/tickets/serializers/ticket/ticket.py index aec89cfae..5b71cb783 100644 --- a/apps/tickets/serializers/ticket/ticket.py +++ b/apps/tickets/serializers/ticket/ticket.py @@ -1,68 +1,42 @@ # -*- coding: utf-8 -*- # from django.utils.translation import ugettext_lazy as _ -from django.db.transaction import atomic from rest_framework import serializers -from common.drf.serializers import MethodSerializer -from orgs.mixins.serializers import OrgResourceModelSerializerMixin -from perms.models import AssetPermission + from orgs.models import Organization -from orgs.utils import tmp_to_org -from tickets.models import Ticket, TicketFlow, ApprovalRule -from tickets.const import TicketApprovalStrategy -from .meta import type_serializer_classes_mapping +from orgs.mixins.serializers import OrgResourceModelSerializerMixin +from tickets.models import Ticket, TicketFlow __all__ = [ - 'TicketDisplaySerializer', 'TicketApplySerializer', 'TicketApproveSerializer', 'TicketFlowSerializer' + 'TicketDisplaySerializer', 'TicketApplySerializer', 'TicketListSerializer' ] class TicketSerializer(OrgResourceModelSerializerMixin): type_display = serializers.ReadOnlyField(source='get_type_display', label=_('Type display')) status_display = serializers.ReadOnlyField(source='get_status_display', label=_('Status display')) - meta = MethodSerializer() class Meta: model = Ticket fields_mini = ['id', 'title'] fields_small = fields_mini + [ - 'type', 'type_display', 'meta', 'state', 'approval_step', - 'status', 'status_display', 'applicant_display', 'process_map', - 'date_created', 'date_updated', 'comment', 'org_id', 'org_name', 'body', - 'serial_num', + 'type', 'type_display', 'status', 'status_display', + 'state', 'approval_step', 'rel_snapshot', 'comment', + 'date_created', 'date_updated', 'org_id', 'rel_snapshot', + 'process_map', 'org_name', 'serial_num' ] fields_fk = ['applicant', ] fields = fields_small + fields_fk - def get_meta_serializer(self): - default_serializer = serializers.Serializer(read_only=True) - if isinstance(self.instance, Ticket): - _type = self.instance.type - else: - _type = self.context['request'].query_params.get('type') - if _type: - action_serializer_classes_mapping = type_serializer_classes_mapping.get(_type) - if action_serializer_classes_mapping: - query_action = self.context['request'].query_params.get('action') - action = query_action if query_action else self.context['view'].action - serializer_class = action_serializer_classes_mapping.get(action) - if not serializer_class: - serializer_class = action_serializer_classes_mapping.get('default') - else: - serializer_class = default_serializer - else: - serializer_class = default_serializer - - if not serializer_class: - serializer_class = default_serializer - - if isinstance(serializer_class, type): - serializer = serializer_class() - else: - serializer = serializer_class - - return serializer +class TicketListSerializer(TicketSerializer): + class Meta: + model = Ticket + fields = [ + 'id', 'title', 'serial_num', 'type', 'type_display', 'status', + 'state', 'rel_snapshot', 'date_created', 'rel_snapshot' + ] + read_only_fields = fields class TicketDisplaySerializer(TicketSerializer): @@ -80,24 +54,10 @@ class TicketApplySerializer(TicketSerializer): class Meta: model = Ticket fields = TicketSerializer.Meta.fields - writeable_fields = [ - 'id', 'title', 'type', 'meta', 'comment', 'org_id' - ] - read_only_fields = list(set(fields) - set(writeable_fields)) extra_kwargs = { - 'type': {'required': True}, + 'type': {'required': True} } - def validate_type(self, tp): - request_type = self.context['request'].query_params.get('type') - if tp != request_type: - error = _( - 'The `type` in the submission data (`{}`) is different from the type ' - 'in the request url (`{}`)'.format(tp, request_type) - ) - raise serializers.ValidationError(error) - return tp - @staticmethod def validate_org_id(org_id): org = Organization.get_instance(org_id) @@ -116,113 +76,3 @@ class TicketApplySerializer(TicketSerializer): error = _('The ticket flow `{}` does not exist'.format(ticket_type)) raise serializers.ValidationError(error) return attrs - - @atomic - def create(self, validated_data): - instance = super().create(validated_data) - name = _('Created by ticket ({}-{})').format(instance.title, str(instance.id)[:4]) - with tmp_to_org(instance.org_id): - if not AssetPermission.objects.filter(name=name).exists(): - instance.meta.update({'apply_permission_name': name}) - return instance - raise serializers.ValidationError(_('Permission named `{}` already exists'.format(name))) - - -class TicketApproveSerializer(TicketSerializer): - meta = serializers.ReadOnlyField() - - class Meta: - model = Ticket - fields = TicketSerializer.Meta.fields - read_only_fields = fields - - -class TicketFlowApproveSerializer(serializers.ModelSerializer): - strategy_display = serializers.ReadOnlyField(source='get_strategy_display', label=_('Approve strategy')) - assignees_read_only = serializers.SerializerMethodField(label=_('Assignees')) - assignees_display = serializers.SerializerMethodField(label=_('Assignees display')) - - class Meta: - model = ApprovalRule - fields_small = [ - 'level', 'strategy', 'assignees_read_only', 'assignees_display', 'strategy_display' - ] - fields_m2m = ['assignees', ] - fields = fields_small + fields_m2m - read_only_fields = ['level', 'assignees_display'] - extra_kwargs = { - 'assignees': {'write_only': True, 'allow_empty': True, 'required': False} - } - - def get_assignees_display(self, obj): - return [str(assignee) for assignee in obj.get_assignees()] - - def get_assignees_read_only(self, obj): - if obj.strategy == TicketApprovalStrategy.custom_user: - return obj.assignees.values_list('id', flat=True) - return [] - - def validate(self, attrs): - if attrs['strategy'] == TicketApprovalStrategy.custom_user and not attrs.get('assignees'): - error = _('Please select the Assignees') - raise serializers.ValidationError({'assignees': error}) - return super().validate(attrs) - - -class TicketFlowSerializer(OrgResourceModelSerializerMixin): - type_display = serializers.ReadOnlyField(source='get_type_display', label=_('Type display')) - rules = TicketFlowApproveSerializer(many=True, required=True) - - class Meta: - model = TicketFlow - fields_mini = ['id', ] - fields_small = fields_mini + [ - 'type', 'type_display', 'approval_level', 'created_by', 'date_created', 'date_updated', - 'org_id', 'org_name' - ] - fields = fields_small + ['rules', ] - read_only_fields = ['created_by', 'org_id', 'date_created', 'date_updated'] - extra_kwargs = { - 'type': {'required': True}, - 'approval_level': {'required': True} - } - - def validate_type(self, value): - if not self.instance or (self.instance and self.instance.type != value): - if self.Meta.model.objects.filter(type=value).exists(): - error = _('The current organization type already exists') - raise serializers.ValidationError(error) - return value - - def create_or_update(self, action, validated_data, instance=None): - related = 'rules' - assignees = 'assignees' - childs = validated_data.pop(related, []) - if not instance: - instance = getattr(super(), action)(validated_data) - else: - instance = getattr(super(), action)(instance, validated_data) - getattr(instance, related).all().delete() - instance_related = getattr(instance, related) - child_instances = [] - related_model = instance_related.model - # Todo: 这个权限的判断 - for level, data in enumerate(childs, 1): - data_m2m = data.pop(assignees, None) - child_instance = related_model.objects.create(**data, level=level) - getattr(child_instance, assignees).set(data_m2m) - child_instances.append(child_instance) - instance_related.set(child_instances) - return instance - - @atomic - def create(self, validated_data): - return self.create_or_update('create', validated_data) - - @atomic - def update(self, instance, validated_data): - if instance.org_id == Organization.ROOT_ID: - instance = self.create(validated_data) - else: - instance = self.create_or_update('update', validated_data, instance) - return instance diff --git a/apps/tickets/signal_handlers/ticket.py b/apps/tickets/signal_handlers/ticket.py index 1a343b18f..8d9a9ef32 100644 --- a/apps/tickets/signal_handlers/ticket.py +++ b/apps/tickets/signal_handlers/ticket.py @@ -1,19 +1,28 @@ # -*- coding: utf-8 -*- # from django.dispatch import receiver -from django.db.models.signals import post_save +from django.db.models.signals import post_save, m2m_changed + +from common.decorator import on_transaction_commit from common.utils import get_logger from tickets.models import Ticket -from ..signals import post_change_ticket_action logger = get_logger(__name__) -@receiver(post_change_ticket_action, sender=Ticket) -def on_post_change_ticket_action(sender, ticket, action, **kwargs): - ticket.handler.dispatch(action) +@on_transaction_commit +def after_save_set_rel_snapshot(sender, instance, update_fields=None, **kwargs): + if update_fields and list(update_fields)[0] == 'rel_snapshot': + return + instance.set_rel_snapshot() -@receiver(post_save, sender=Ticket) -def on_pre_save_ensure_serial_num(sender, instance: Ticket, **kwargs): - instance.update_serial_num_if_need() +@on_transaction_commit +def on_m2m_change(sender, action, instance, reverse=False, **kwargs): + if action.startswith('post'): + instance.set_rel_snapshot() + + +for ticket_cls in Ticket.__subclasses__(): + post_save.connect(after_save_set_rel_snapshot, sender=ticket_cls) + m2m_changed.connect(on_m2m_change, sender=ticket_cls) diff --git a/apps/tickets/signals.py b/apps/tickets/signals.py index b626cfa35..6fa73d8d5 100644 --- a/apps/tickets/signals.py +++ b/apps/tickets/signals.py @@ -1,5 +1,4 @@ from django.dispatch import Signal -post_change_ticket_action = Signal() - +active_step = Signal() post_or_update_change_ticket_flow_approval = Signal() diff --git a/apps/tickets/templates/tickets/_base_ticket_body.html b/apps/tickets/templates/tickets/_base_ticket_body.html deleted file mode 100644 index 593a1d35e..000000000 --- a/apps/tickets/templates/tickets/_base_ticket_body.html +++ /dev/null @@ -1,20 +0,0 @@ -{% load i18n %} -
-

- {{ ticket_info }} -

-
-

-

-

- -

- - {{ body | safe }} -
-
- - {% trans 'Click here to review' %} - -
-
\ No newline at end of file diff --git a/apps/tickets/templates/tickets/_msg_ticket.html b/apps/tickets/templates/tickets/_msg_ticket.html index 5f268592c..75d205080 100644 --- a/apps/tickets/templates/tickets/_msg_ticket.html +++ b/apps/tickets/templates/tickets/_msg_ticket.html @@ -4,7 +4,15 @@ {{ title | safe }}

- {{ body | safe }} + {% for child in content %} +

{{ child.title }}

+
+ {% for item in child.content %} +
  • + {{ item.title }}: {{ item.value }} +
  • + {% endfor %} + {% endfor %}

    diff --git a/apps/tickets/templates/tickets/approve_check_password.html b/apps/tickets/templates/tickets/approve_check_password.html index d75e9f956..78eadbf6f 100644 --- a/apps/tickets/templates/tickets/approve_check_password.html +++ b/apps/tickets/templates/tickets/approve_check_password.html @@ -10,7 +10,17 @@

    {% trans 'Ticket information' %}

    -
    {{ ticket_info | safe }}
    +
    + {% for child in content %} +

    {{ child.title }}

    +
    + {% for item in child.content %} +
  • + {{ item.title }}: {{ item.value }} +
  • + {% endfor %} + {% endfor %} +
    diff --git a/apps/tickets/urls/api_urls.py b/apps/tickets/urls/api_urls.py index bc6bb7f54..22715c527 100644 --- a/apps/tickets/urls/api_urls.py +++ b/apps/tickets/urls/api_urls.py @@ -10,6 +10,11 @@ app_name = 'tickets' router = BulkRouter() router.register('tickets', api.TicketViewSet, 'ticket') +router.register('apply-asset-tickets', api.ApplyAssetTicketViewSet, 'apply-asset-ticket') +router.register('apply-app-tickets', api.ApplyApplicationTicketViewSet, 'apply-app-ticket') +router.register('apply-login-tickets', api.ApplyLoginTicketViewSet, 'apply-login-ticket') +router.register('apply-login-asset-tickets', api.ApplyLoginAssetTicketViewSet, 'apply-login-asset-ticket') +router.register('apply-command-tickets', api.ApplyCommandTicketViewSet, 'apply-command-ticket') router.register('flows', api.TicketFlowViewSet, 'flows') router.register('comments', api.CommentViewSet, 'comment') router.register('ticket-session-relation', api.TicketSessionRelationViewSet, 'ticket-session-relation') diff --git a/apps/tickets/utils.py b/apps/tickets/utils.py index 66edb3828..231881846 100644 --- a/apps/tickets/utils.py +++ b/apps/tickets/utils.py @@ -3,21 +3,22 @@ from django.conf import settings from common.utils import get_logger -from .notifications import TicketAppliedToAssignee, TicketProcessedToApplicant +from .notifications import TicketAppliedToAssigneeMessage, TicketProcessedToApplicantMessage logger = get_logger(__file__) -def send_ticket_applied_mail_to_assignees(ticket): - ticket_assignees = ticket.current_node.first().ticket_assignees.all() - if not ticket_assignees: +def send_ticket_applied_mail_to_assignees(ticket, assignees): + if not assignees: logger.debug( - "Not found assignees, ticket: {}({}), assignees: {}".format(ticket, str(ticket.id), ticket_assignees) + "Not found assignees, ticket: {}({}), assignees: {}".format( + ticket, str(ticket.id), assignees + ) ) return - for ticket_assignee in ticket_assignees: - instance = TicketAppliedToAssignee(ticket_assignee.assignee, ticket) + for user in assignees: + instance = TicketAppliedToAssigneeMessage(user, ticket) if settings.DEBUG: logger.debug(instance) instance.publish_async() @@ -28,7 +29,7 @@ def send_ticket_processed_mail_to_applicant(ticket, processor): logger.error("Not found applicant: {}({})".format(ticket.title, ticket.id)) return - instance = TicketProcessedToApplicant(ticket.applicant, ticket, processor) + instance = TicketProcessedToApplicantMessage(ticket.applicant, ticket, processor) if settings.DEBUG: logger.debug(instance) instance.publish_async() diff --git a/apps/tickets/views/approve.py b/apps/tickets/views/approve.py index 6632dc813..5be230f0a 100644 --- a/apps/tickets/views/approve.py +++ b/apps/tickets/views/approve.py @@ -50,13 +50,13 @@ class TicketDirectApproveView(TemplateView): def get_context_data(self, **kwargs): # 放入工单信息 token = kwargs.get('token') - ticket_info = cache.get(token, {}).get('body', '') + content = cache.get(token, {}).get('content', []) if self.request.user.is_authenticated: prompt_msg = _('Click the button below to approve or reject') else: prompt_msg = _('After successful authentication, this ticket can be approved directly') kwargs.update({ - 'ticket_info': ticket_info, 'prompt_msg': prompt_msg, + 'content': content, 'prompt_msg': prompt_msg, 'login_url': '%s&next=%s' % ( self.login_url, reverse('tickets:direct-approve', kwargs={'token': token})