diff --git a/apps/acls/models/login_asset_acl.py b/apps/acls/models/login_asset_acl.py index d61e7ae23..bf47fa578 100644 --- a/apps/acls/models/login_asset_acl.py +++ b/apps/acls/models/login_asset_acl.py @@ -83,11 +83,11 @@ class LoginAssetACL(BaseACL, OrgModelMixin): @classmethod def create_login_asset_confirm_ticket(cls, user, asset, system_user, assignees, org_id): - from tickets.const import TicketTypeChoices + from tickets.const import TicketType from tickets.models import Ticket data = { 'title': _('Login asset confirm') + ' ({})'.format(user), - 'type': TicketTypeChoices.login_asset_confirm, + 'type': TicketType.login_asset_confirm, 'meta': { 'apply_login_user': str(user), 'apply_login_asset': str(asset), @@ -96,7 +96,7 @@ class LoginAssetACL(BaseACL, OrgModelMixin): 'org_id': org_id, } ticket = Ticket.objects.create(**data) - ticket.assignees.set(assignees) + ticket.create_process_map_and_node(assignees) ticket.open(applicant=user) return ticket diff --git a/apps/assets/models/cmd_filter.py b/apps/assets/models/cmd_filter.py index 1ef14bad0..bf91a16b2 100644 --- a/apps/assets/models/cmd_filter.py +++ b/apps/assets/models/cmd_filter.py @@ -105,11 +105,11 @@ class CommandFilterRule(OrgModelMixin): return '{} % {}'.format(self.type, self.content) def create_command_confirm_ticket(self, run_command, session, cmd_filter_rule, org_id): - from tickets.const import TicketTypeChoices + from tickets.const import TicketType from tickets.models import Ticket data = { 'title': _('Command confirm') + ' ({})'.format(session.user), - 'type': TicketTypeChoices.command_confirm, + 'type': TicketType.command_confirm, 'meta': { 'apply_run_user': session.user, 'apply_run_asset': session.asset, @@ -122,6 +122,6 @@ class CommandFilterRule(OrgModelMixin): 'org_id': org_id, } ticket = Ticket.objects.create(**data) - ticket.assignees.set(self.reviewers.all()) + ticket.create_process_map_and_node(self.reviewers.all()) ticket.open(applicant=session.user_obj) return ticket diff --git a/apps/authentication/mixins.py b/apps/authentication/mixins.py index 1eb39fee1..dd68cd483 100644 --- a/apps/authentication/mixins.py +++ b/apps/authentication/mixins.py @@ -363,14 +363,14 @@ class AuthMixin: raise errors.LoginConfirmOtherError('', "Not found") if ticket.status_open: raise errors.LoginConfirmWaitError(ticket.id) - elif ticket.action_approve: + elif ticket.state_approve: self.request.session["auth_confirm"] = "1" return - elif ticket.action_reject: + elif ticket.state_reject: raise errors.LoginConfirmOtherError( ticket.id, ticket.get_action_display() ) - elif ticket.action_close: + elif ticket.state_close: raise errors.LoginConfirmOtherError( ticket.id, ticket.get_action_display() ) diff --git a/apps/authentication/models.py b/apps/authentication/models.py index f4db736af..c74d06953 100644 --- a/apps/authentication/models.py +++ b/apps/authentication/models.py @@ -71,15 +71,14 @@ class LoginConfirmSetting(CommonModelMixin): from orgs.models import Organization ticket_title = _('Login confirm') + ' {}'.format(self.user) ticket_meta = self.construct_confirm_ticket_meta(request) - ticket_assignees = self.reviewers.all() data = { 'title': ticket_title, - 'type': const.TicketTypeChoices.login_confirm.value, + 'type': const.TicketType.login_confirm.value, 'meta': ticket_meta, 'org_id': Organization.ROOT_ID, } ticket = Ticket.objects.create(**data) - ticket.assignees.set(ticket_assignees) + ticket.create_process_map_and_node(self.reviewers.all()) ticket.open(self.user) return ticket diff --git a/apps/common/db/encoder.py b/apps/common/db/encoder.py new file mode 100644 index 000000000..314ea071d --- /dev/null +++ b/apps/common/db/encoder.py @@ -0,0 +1,20 @@ +import json +from datetime import datetime +import uuid + +from django.utils.translation import ugettext_lazy as _ +from django.conf import settings + + +class ModelJSONFieldEncoder(json.JSONEncoder): + """ 解决一些类型的字段不能序列化的问题 """ + + def default(self, obj): + if 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) + else: + return super().default(obj) diff --git a/apps/orgs/mixins/models.py b/apps/orgs/mixins/models.py index 498d90a5a..8176cb439 100644 --- a/apps/orgs/mixins/models.py +++ b/apps/orgs/mixins/models.py @@ -19,6 +19,7 @@ __all__ = [ class OrgManager(models.Manager): + def all_group_by_org(self): from ..models import Organization orgs = list(Organization.objects.all()) diff --git a/apps/tickets/api/assignee.py b/apps/tickets/api/assignee.py index d95729085..940ff900a 100644 --- a/apps/tickets/api/assignee.py +++ b/apps/tickets/api/assignee.py @@ -1,11 +1,11 @@ # -*- coding: utf-8 -*- # +from orgs.models import Organization from rest_framework import viewsets from common.permissions import IsValidUser from common.exceptions import JMSException from users.models import User -from orgs.models import Organization from .. import serializers @@ -15,8 +15,7 @@ class AssigneeViewSet(viewsets.ReadOnlyModelViewSet): filterset_fields = ('id', 'name', 'username', 'email', 'source') search_fields = filterset_fields - def get_org(self): - org_id = self.request.query_params.get('org_id') + def get_org(self, org_id): org = Organization.get_instance(org_id) if not org: error = ('The organization `{}` does not exist'.format(org_id)) @@ -24,6 +23,13 @@ class AssigneeViewSet(viewsets.ReadOnlyModelViewSet): return org def get_queryset(self): - org = self.get_org() - queryset = User.get_super_and_org_admins(org=org) + org_id = self.request.query_params.get('org_id') + type = self.request.query_params.get('type') + if type == 'super': + queryset = User.get_super_admins() + elif type == 'super_admin': + org = self.get_org(org_id) + queryset = User.get_super_and_org_admins(org=org) + else: + queryset = User.objects.all() return queryset diff --git a/apps/tickets/api/common.py b/apps/tickets/api/common.py index fe5a5d1e9..2838d23d0 100644 --- a/apps/tickets/api/common.py +++ b/apps/tickets/api/common.py @@ -15,16 +15,16 @@ class GenericTicketStatusRetrieveCloseAPI(RetrieveDestroyAPIView): permission_classes = (IsAppUser, ) def retrieve(self, request, *args, **kwargs): - if self.ticket.action_open: + if self.ticket.state_open: status = 'await' - elif self.ticket.action_approve: - status = 'approve' + elif self.ticket.state_approve: + status = 'approved' else: - status = 'reject' + status = 'rejected' data = { 'status': status, - 'action': self.ticket.action, - 'processor': self.ticket.processor_display + 'action': self.ticket.state, + 'processor': str(self.ticket.processor) } return Response(data=data, status=200) @@ -32,9 +32,9 @@ class GenericTicketStatusRetrieveCloseAPI(RetrieveDestroyAPIView): if self.ticket.status_open: self.ticket.close(processor=self.ticket.applicant) data = { - 'action': self.ticket.action, + 'action': self.ticket.state, 'status': self.ticket.status, - 'processor': self.ticket.processor_display + 'processor': str(self.ticket.processor) } return Response(data=data, status=200) diff --git a/apps/tickets/api/ticket.py b/apps/tickets/api/ticket.py index 086daef4d..cbbe72151 100644 --- a/apps/tickets/api/ticket.py +++ b/apps/tickets/api/ticket.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- # -from django.utils.translation import ugettext_lazy as _ from rest_framework import viewsets from rest_framework.decorators import action from rest_framework.exceptions import MethodNotAllowed @@ -8,14 +7,15 @@ from rest_framework.response import Response from common.const.http import POST, PUT from common.mixins.api import CommonApiMixin -from common.permissions import IsValidUser, IsOrgAdmin +from common.permissions import IsValidUser, IsOrgAdmin, IsSuperUser +from common.drf.api import JMSBulkModelViewSet from tickets import serializers -from tickets.models import Ticket -from tickets.permissions.ticket import IsAssignee, IsAssigneeOrApplicant, NotClosed +from tickets.models import Ticket, TicketFlow +from tickets.filters import TicketFilter +from tickets.permissions.ticket import IsAssignee, IsApplicant - -__all__ = ['TicketViewSet'] +__all__ = ['TicketViewSet', 'TicketFlowViewSet'] class TicketViewSet(CommonApiMixin, viewsets.ModelViewSet): @@ -25,12 +25,9 @@ class TicketViewSet(CommonApiMixin, viewsets.ModelViewSet): 'open': serializers.TicketApplySerializer, 'approve': serializers.TicketApproveSerializer, } - filterset_fields = [ - 'id', 'title', 'type', 'action', 'status', 'applicant', 'applicant_display', 'processor', - 'processor_display', 'assignees__id' - ] + filterset_class = TicketFilter search_fields = [ - 'title', 'action', 'type', 'status', 'applicant_display', 'processor_display' + 'title', 'action', 'type', 'status', 'applicant_display' ] def create(self, request, *args, **kwargs): @@ -48,6 +45,8 @@ class TicketViewSet(CommonApiMixin, viewsets.ModelViewSet): def perform_create(self, serializer): instance = serializer.save() + instance.create_related_node() + instance.process_map = instance.create_process_map() instance.open(applicant=self.request.user) @action(detail=False, methods=[POST], permission_classes=[IsValidUser, ]) @@ -57,24 +56,46 @@ class TicketViewSet(CommonApiMixin, viewsets.ModelViewSet): @action(detail=True, methods=[PUT], permission_classes=[IsAssignee, ]) def approve(self, request, *args, **kwargs): instance = self.get_object() - if instance.status_closed: - return Response(data={"error": _("Ticket already closed")}, status=400) - response = super().update(request, *args, **kwargs) - self.get_object().approve(processor=self.request.user) - return response + serializer = self.get_serializer(instance) + instance.approve(processor=request.user) + return Response(serializer.data) @action(detail=True, methods=[PUT], permission_classes=[IsAssignee, ]) def reject(self, request, *args, **kwargs): instance = self.get_object() - if instance.status_closed: - return Response(data={"error": _("Ticket already closed")}, status=400) serializer = self.get_serializer(instance) instance.reject(processor=request.user) return Response(serializer.data) - @action(detail=True, methods=[PUT], permission_classes=[IsAssigneeOrApplicant, NotClosed]) + @action(detail=True, methods=[PUT], permission_classes=[IsApplicant, ]) def close(self, request, *args, **kwargs): instance = self.get_object() serializer = self.get_serializer(instance) instance.close(processor=request.user) return Response(serializer.data) + + +class TicketFlowViewSet(JMSBulkModelViewSet): + permission_classes = (IsOrgAdmin, IsSuperUser) + 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() + instance.rules.model.change_assignees_display(instance.rules.all()) + + 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/const.py b/apps/tickets/const.py index 3397353d4..0a48cc907 100644 --- a/apps/tickets/const.py +++ b/apps/tickets/const.py @@ -1,10 +1,10 @@ -from django.db.models import TextChoices +from django.db.models import TextChoices, IntegerChoices from django.utils.translation import ugettext_lazy as _ TICKET_DETAIL_URL = '/ui/#/tickets/tickets/{id}' -class TicketTypeChoices(TextChoices): +class TicketType(TextChoices): general = 'general', _("General") login_confirm = 'login_confirm', _("Login confirm") apply_asset = 'apply_asset', _('Apply for asset') @@ -13,13 +13,38 @@ class TicketTypeChoices(TextChoices): command_confirm = 'command_confirm', _('Command confirm') -class TicketActionChoices(TextChoices): +class TicketState(TextChoices): open = 'open', _('Open') - approve = 'approve', _('Approve') - reject = 'reject', _('Reject') - close = 'close', _('Close') + approved = 'approved', _('Approved') + rejected = 'rejected', _('Rejected') + closed = 'closed', _('Closed') -class TicketStatusChoices(TextChoices): +class ProcessStatus(TextChoices): + notified = 'notified', _('Notified') + approved = 'approved', _('Approved') + rejected = 'rejected', _('Rejected') + + +class TicketStatus(TextChoices): open = 'open', _("Open") closed = 'closed', _("Closed") + + +class TicketAction(TextChoices): + open = 'open', _("Open") + close = 'close', _("Close") + approve = 'approve', _('Approve') + reject = 'reject', _('Reject') + + +class TicketApprovalLevel(IntegerChoices): + one = 1, _("One level") + two = 2, _("Two level") + + +class TicketApprovalStrategy(TextChoices): + super = 'super', _("Super user") + admin = 'admin', _("Admin user") + super_admin = 'super_admin', _("Super admin user") + custom = 'custom', _("Custom user") diff --git a/apps/tickets/errors.py b/apps/tickets/errors.py new file mode 100644 index 000000000..716eeca94 --- /dev/null +++ b/apps/tickets/errors.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +# +from django.utils.translation import ugettext_lazy as _ + +from common.exceptions import JMSException + + +class AlreadyClosed(JMSException): + default_detail = _("Ticket already closed") diff --git a/apps/tickets/filters.py b/apps/tickets/filters.py new file mode 100644 index 000000000..676efbea2 --- /dev/null +++ b/apps/tickets/filters.py @@ -0,0 +1,18 @@ +from django_filters import rest_framework as filters +from common.drf.filters import BaseFilterSet + +from tickets.models import Ticket + + +class TicketFilter(BaseFilterSet): + assignees__id = filters.UUIDFilter(method='filter_assignees_id') + + class Meta: + model = Ticket + fields = ( + 'id', 'title', 'type', 'status', 'applicant', 'assignees__id', + 'applicant_display', + ) + + def filter_assignees_id(self, queryset, name, value): + return queryset.filter(ticket_steps__ticket_assignees__assignee__id=value) diff --git a/apps/tickets/handler/apply_application.py b/apps/tickets/handler/apply_application.py index f4643b5c6..d25af61f6 100644 --- a/apps/tickets/handler/apply_application.py +++ b/apps/tickets/handler/apply_application.py @@ -1,17 +1,19 @@ from django.utils.translation import ugettext as _ from orgs.utils import tmp_to_org, tmp_to_root_org -from applications.models import Application from applications.const import AppCategory, AppType -from assets.models import SystemUser +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): - super()._on_approve() - self._create_application_permission() + is_finished = super()._on_approve() + if is_finished: + self._create_application_permission() # display def _construct_meta_display_of_open(self): @@ -22,27 +24,21 @@ class Handler(BaseHandler): 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)) - return meta_display + apply_system_users = self.ticket.meta.get('apply_system_users') + apply_applications = self.ticket.meta.get('apply_applications') + 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)] + }) - def _construct_meta_display_of_approve(self): - meta_display_fields = ['approve_applications_display', 'approve_system_users_display'] - approve_application_ids = self.ticket.meta.get('approve_applications', []) - approve_system_user_ids = self.ticket.meta.get('approve_system_users', []) - with tmp_to_org(self.ticket.org_id): - approve_applications = Application.objects.filter(id__in=approve_application_ids) - system_users = SystemUser.objects.filter(id__in=approve_system_user_ids) - approve_applications_display = [str(application) for application in approve_applications] - approve_system_users_display = [str(system_user) for system_user in system_users] - meta_display_values = [approve_applications_display, approve_system_users_display] - meta_display = dict(zip(meta_display_fields, meta_display_values)) 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_application_group = self.ticket.meta.get('apply_application_group', []) - apply_system_user_group = self.ticket.meta.get('apply_system_user_group', []) + 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') applied_body = '''{}: {}, @@ -54,31 +50,13 @@ class Handler(BaseHandler): '''.format( _('Applied category'), apply_category_display, _('Applied type'), apply_type_display, - _('Applied application group'), apply_application_group, - _('Applied system user group'), apply_system_user_group, + _('Applied application group'), apply_applications, + _('Applied system user group'), apply_system_users, _('Applied date start'), apply_date_start, _('Applied date expired'), apply_date_expired, ) return applied_body - def _construct_meta_body_of_approve(self): - # 审批信息 - approve_applications_display = self.ticket.meta.get('approve_applications_display', []) - approve_system_users_display = self.ticket.meta.get('approve_system_users_display', []) - approve_date_start = self.ticket.meta.get('approve_date_start') - approve_date_expired = self.ticket.meta.get('approve_date_expired') - approved_body = '''{}: {}, - {}: {}, - {}: {}, - {}: {}, - '''.format( - _('Approved applications'), approve_applications_display, - _('Approved system users'), approve_system_users_display, - _('Approved date start'), approve_date_start, - _('Approved date expired'), approve_date_expired - ) - return approved_body - # permission def _create_application_permission(self): with tmp_to_root_org(): @@ -88,11 +66,11 @@ class Handler(BaseHandler): apply_category = self.ticket.meta.get('apply_category') apply_type = self.ticket.meta.get('apply_type') - approve_permission_name = self.ticket.meta.get('approve_permission_name', '') - approved_application_ids = self.ticket.meta.get('approve_applications', []) - approve_system_user_ids = self.ticket.meta.get('approve_system_users', []) - approve_date_start = self.ticket.meta.get('approve_date_start') - approve_date_expired = self.ticket.meta.get('approve_date_expired') + 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) ) @@ -105,23 +83,23 @@ class Handler(BaseHandler): ).format( self.ticket.title, self.ticket.applicant_display, - self.ticket.processor_display, + str(self.ticket.processor), str(self.ticket.id) ) permissions_data = { 'id': self.ticket.id, - 'name': approve_permission_name, + 'name': apply_permission_name, 'category': apply_category, 'type': apply_type, 'comment': str(permission_comment), 'created_by': permission_created_by, - 'date_start': approve_date_start, - 'date_expired': approve_date_expired, + '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(approved_application_ids) - application_permission.system_users.set(approve_system_user_ids) + 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 index 9af310294..84de0c6b3 100644 --- a/apps/tickets/handler/apply_asset.py +++ b/apps/tickets/handler/apply_asset.py @@ -1,16 +1,19 @@ +from assets.models import Asset +from assets.models import SystemUser + from .base import BaseHandler from django.utils.translation import ugettext as _ from perms.models import AssetPermission, Action -from assets.models import Asset, SystemUser from orgs.utils import tmp_to_org, tmp_to_root_org class Handler(BaseHandler): def _on_approve(self): - super()._on_approve() - self._create_asset_permission() + is_finished = super()._on_approve() + if is_finished: + self._create_asset_permission() # display def _construct_meta_display_of_open(self): @@ -19,32 +22,18 @@ class Handler(BaseHandler): 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)) - return meta_display - - def _construct_meta_display_of_approve(self): - meta_display_fields = [ - 'approve_actions_display', 'approve_assets_display', 'approve_system_users_display' - ] - approve_actions = self.ticket.meta.get('approve_actions', Action.NONE) - approve_actions_display = Action.value_to_choices_display(approve_actions) - approve_asset_ids = self.ticket.meta.get('approve_assets', []) - approve_system_user_ids = self.ticket.meta.get('approve_system_users', []) - with tmp_to_org(self.ticket.org_id): - assets = Asset.objects.filter(id__in=approve_asset_ids) - system_users = SystemUser.objects.filter(id__in=approve_system_user_ids) - approve_assets_display = [str(asset) for asset in assets] - approve_system_users_display = [str(system_user) for system_user in system_users] - meta_display_values = [ - approve_actions_display, approve_assets_display, approve_system_users_display - ] - meta_display = dict(zip(meta_display_fields, meta_display_values)) + apply_assets = self.ticket.meta.get('apply_assets') + apply_system_users = self.ticket.meta.get('apply_system_users') + meta_display.update({ + '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_ip_group = self.ticket.meta.get('apply_ip_group', []) - apply_hostname_group = self.ticket.meta.get('apply_hostname_group', []) - apply_system_user_group = self.ticket.meta.get('apply_system_user_group', []) + apply_assets = self.ticket.meta.get('apply_assets', []) + apply_system_users = self.ticket.meta.get('apply_system_users', []) 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') @@ -54,35 +43,14 @@ class Handler(BaseHandler): {}: {}, {}: {} '''.format( - _('Applied IP group'), apply_ip_group, - _("Applied hostname group"), apply_hostname_group, - _("Applied system user group"), apply_system_user_group, + _("Applied hostname group"), apply_assets, + _("Applied system user group"), apply_system_users, _("Applied actions"), apply_actions_display, _('Applied date start'), apply_date_start, _('Applied date expired'), apply_date_expired, ) return applied_body - def _construct_meta_body_of_approve(self): - approve_assets_display = self.ticket.meta.get('approve_assets_display', []) - approve_system_users_display = self.ticket.meta.get('approve_system_users_display', []) - approve_actions_display = self.ticket.meta.get('approve_actions_display', []) - approve_date_start = self.ticket.meta.get('approve_date_start') - approve_date_expired = self.ticket.meta.get('approve_date_expired') - approved_body = '''{}: {}, - {}: {}, - {}: {}, - {}: {}, - {}: {} - '''.format( - _('Approved assets'), approve_assets_display, - _('Approved system users'), approve_system_users_display, - _('Approved actions'), ', '.join(approve_actions_display), - _('Approved date start'), approve_date_start, - _('Approved date expired'), approve_date_expired, - ) - return approved_body - # permission def _create_asset_permission(self): with tmp_to_root_org(): @@ -90,12 +58,12 @@ class Handler(BaseHandler): if asset_permission: return asset_permission - approve_permission_name = self.ticket.meta.get('approve_permission_name', ) - approve_asset_ids = self.ticket.meta.get('approve_assets', []) - approve_system_user_ids = self.ticket.meta.get('approve_system_users', []) - approve_actions = self.ticket.meta.get('approve_actions', Action.NONE) - approve_date_start = self.ticket.meta.get('approve_date_start') - approve_date_expired = self.ticket.meta.get('approve_date_expired') + apply_permission_name = self.ticket.meta.get('apply_permission_name', ) + 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) ) @@ -108,23 +76,23 @@ class Handler(BaseHandler): ).format( self.ticket.title, self.ticket.applicant_display, - self.ticket.processor_display, + str(self.ticket.processor), str(self.ticket.id) ) permission_data = { 'id': self.ticket.id, - 'name': approve_permission_name, + 'name': apply_permission_name, 'comment': str(permission_comment), 'created_by': permission_created_by, - 'actions': approve_actions, - 'date_start': approve_date_start, - 'date_expired': approve_date_expired, + '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.assets.set(approve_asset_ids) - asset_permission.system_users.set(approve_system_user_ids) + 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 index 24a35268a..e308d7255 100644 --- a/apps/tickets/handler/base.py +++ b/apps/tickets/handler/base.py @@ -3,7 +3,7 @@ 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__) @@ -16,48 +16,72 @@ class BaseHandler(object): # on action def _on_open(self): self.ticket.applicant_display = str(self.ticket.applicant) - self.ticket.assignees_display = [str(assignee) for assignee in self.ticket.assignees.all()] 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): - meta_display = getattr(self, '_construct_meta_display_of_approve', lambda: {})() - self.ticket.meta.update(meta_display) - self.__on_process() + if self.ticket.approval_step != len(self.ticket.process_map): + self.ticket.approval_step += 1 + self.ticket.create_related_node() + is_finished = False + else: + self.ticket.set_state_approve() + self.ticket.set_status_closed() + is_finished = True + self._send_applied_mail_to_assignees() + + self.__on_process(self.ticket.processor) + return is_finished def _on_reject(self): - self.__on_process() + self.ticket.set_state_reject() + self.ticket.set_status_closed() + self.__on_process(self.ticket.processor) def _on_close(self): - self.__on_process() - - def __on_process(self): - self.ticket.processor_display = str(self.ticket.processor) + self.ticket.set_state_closed() self.ticket.set_status_closed() - self._send_processed_mail_to_applicant() + 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): - self._create_comment_on_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): - logger.debug('Send applied email to assignees: {}'.format(self.ticket.assignees_display)) + 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): + 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) + send_ticket_processed_mail_to_applicant(self.ticket, processor) # comments - def _create_comment_on_action(self): - user = self.ticket.applicant if self.ticket.action_open else self.ticket.processor + def _create_comment_on_action(self, action): + user = self.ticket.processor + # 打开或关闭工单,备注显示是自己,其他是受理人 + if self.ticket.state_open or self.ticket.state_close: + user = self.ticket.applicant user_display = str(user) - action_display = self.ticket.get_action_display() + action_display = getattr(TicketAction, action).label data = { 'body': _('{} {} the ticket').format(user_display, action_display), 'user': user, @@ -85,18 +109,12 @@ class BaseHandler(object): {}: {}, {}: {}, {}: {}, - {}: {}, - {}: {} '''.format( _('Ticket title'), self.ticket.title, _('Ticket type'), self.ticket.get_type_display(), _('Ticket status'), self.ticket.get_status_display(), - _('Ticket action'), self.ticket.get_action_display(), _('Ticket applicant'), self.ticket.applicant_display, - _('Ticket assignees'), ', '.join(self.ticket.assignees_display), ) - if self.ticket.status_closed: - basic_body += '''{}: {}'''.format(_('Ticket processor'), self.ticket.processor_display) body = self.body_html_format.format(_("Ticket basic info"), basic_body) return body @@ -104,9 +122,6 @@ class BaseHandler(object): body = '' open_body = self._base_construct_meta_body_of_open() body += open_body - if self.ticket.action_approve: - approve_body = self._base_construct_meta_body_of_approve() - body += approve_body return body def _base_construct_meta_body_of_open(self): @@ -115,10 +130,3 @@ class BaseHandler(object): )() body = self.body_html_format.format(_('Ticket applied info'), meta_body_of_open) return body - - def _base_construct_meta_body_of_approve(self): - meta_body_of_approve = getattr( - self, '_construct_meta_body_of_approve', lambda: _('No content') - )() - body = self.body_html_format.format(_('Ticket approved info'), meta_body_of_approve) - return body diff --git a/apps/tickets/migrations/0010_auto_20210812_1618.py b/apps/tickets/migrations/0010_auto_20210812_1618.py new file mode 100644 index 000000000..cb41aaf23 --- /dev/null +++ b/apps/tickets/migrations/0010_auto_20210812_1618.py @@ -0,0 +1,235 @@ +# Generated by Django 3.1.6 on 2021-08-12 08:18 + +import common.db.encoder +from django.conf import settings +from django.db import migrations, models, transaction +import django.db.models.deletion +import uuid + +from tickets.const import TicketType + +ticket_assignee_m2m = list() + + +def get_ticket_assignee_m2m_info(apps, schema_editor): + ticket_model = apps.get_model("tickets", "Ticket") + for i in ticket_model.objects.only('id', 'assignees', 'action', 'created_by'): + ticket_assignee_m2m.append((i.id, list(i.assignees.values_list('id', flat=True)), i.action, i.created_by)) + + +def update_ticket_process_meta_state_status(apps, schema_editor): + ticket_model = apps.get_model("tickets", "Ticket") + updates = list() + with transaction.atomic(): + for instance in ticket_model.objects.all(): + if instance.action == 'open': + state = 'notified' + elif instance.action == 'approve': + state = 'approved' + elif instance.action == 'reject': + state = 'rejected' + else: + state = 'closed' + instance.process_map = [{ + 'state': state, + 'approval_level': 1, + 'approval_date': str(instance.date_updated), + 'processor': instance.processor.id if instance.processor else '', + 'processor_display': instance.processor_display if instance.processor_display else '', + 'assignees': list(instance.assignees.values_list('id', flat=True)) if instance.assignees else [], + 'assignees_display': instance.assignees_display if instance.assignees_display else [] + }, ] + instance.state = state + instance.meta['apply_assets'] = instance.meta.pop('approve_assets', []) + instance.meta['apply_assets_display'] = instance.meta.pop('approve_assets_display', []) + instance.meta['apply_actions'] = instance.meta.pop('approve_actions', 0) + instance.meta['apply_actions_display'] = instance.meta.pop('approve_actions_display', []) + instance.meta['apply_applications'] = instance.meta.pop('approve_applications', []) + instance.meta['apply_applications_display'] = instance.meta.pop('approve_applications_display', []) + instance.meta['apply_system_users'] = instance.meta.pop('approve_system_users', []) + instance.meta['apply_system_users_display'] = instance.meta.pop('approve_system_users_display', []) + updates.append(instance) + ticket_model.objects.bulk_update(updates, ['process_map', 'state', 'meta', 'status']) + + +def create_step_and_assignee(apps, schema_editor): + ticket_step_model = apps.get_model("tickets", "TicketStep") + ticket_assignee_model = apps.get_model("tickets", "TicketAssignee") + creates = list() + with transaction.atomic(): + for ticket_id, assignees, action, created_by in ticket_assignee_m2m: + if action == 'open': + state = 'notified' + elif action == 'approve': + state = 'approved' + else: + state = 'rejected' + step_instance = ticket_step_model.objects.create(ticket_id=ticket_id, state=state, created_by=created_by) + for assignee_id in assignees: + creates.append( + ticket_assignee_model( + step=step_instance, assignee_id=assignee_id, state=state, created_by=created_by + ) + ) + ticket_assignee_model.objects.bulk_create(creates) + + +def create_ticket_flow_and_approval_rule(apps, schema_editor): + user_model = apps.get_model("users", "User") + org_id = '00000000-0000-0000-0000-000000000000' + ticket_flow_model = apps.get_model("tickets", "TicketFlow") + approval_rule_model = apps.get_model("tickets", "ApprovalRule") + super_user = user_model.objects.filter(role='Admin') + assignees_display = ['{0.name}({0.username})'.format(i) for i in super_user] + with transaction.atomic(): + for ticket_type in TicketType.values: + ticket_flow_instance = ticket_flow_model.objects.create(created_by='System', + type=ticket_type, org_id=org_id) + approval_rule_instance = approval_rule_model.objects.create(strategy='super', + assignees_display=assignees_display) + approval_rule_instance.assignees.set(list(super_user)) + ticket_flow_instance.rules.set([approval_rule_instance, ]) + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('tickets', '0009_auto_20210426_1720'), + ] + + operations = [ + migrations.CreateModel( + name='ApprovalRule', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('created_by', models.CharField(blank=True, max_length=32, null=True, verbose_name='Created by')), + ('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Date created')), + ('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')), + ('level', models.SmallIntegerField(choices=[(1, 'One level'), (2, 'Two level')], default=1, + verbose_name='Approve level')), + ('strategy', models.CharField( + choices=[('super', 'Super user'), ('admin', 'Admin user'), ('super_admin', 'Super admin user'), + ('custom', 'Custom user')], + default='super', max_length=64, verbose_name='Approve strategy')), + ('assignees_display', models.JSONField(default=list, encoder=common.db.encoder.ModelJSONFieldEncoder, + verbose_name='Assignees display')), + ('assignees', + models.ManyToManyField(related_name='assigned_ticket_flow_approval_rule', to=settings.AUTH_USER_MODEL, + verbose_name='Assignees')), + ], + options={ + 'verbose_name': 'Ticket flow approval rule', + }, + ), + migrations.RunPython(get_ticket_assignee_m2m_info), + migrations.AddField( + model_name='ticket', + name='process_map', + field=models.JSONField(default=list, encoder=common.db.encoder.ModelJSONFieldEncoder, + verbose_name='Process'), + ), + migrations.AddField( + model_name='ticket', + name='state', + field=models.CharField( + choices=[('open', 'Open'), ('approved', 'Approved'), ('rejected', 'Rejected'), ('closed', 'Closed')], + default='open', max_length=16, verbose_name='State'), + ), + migrations.RunPython(update_ticket_process_meta_state_status), + migrations.RemoveField( + model_name='ticket', + name='action', + ), + migrations.RemoveField( + model_name='ticket', + name='assignees', + ), + migrations.RemoveField( + model_name='ticket', + name='assignees_display', + ), + migrations.RemoveField( + model_name='ticket', + name='processor', + ), + migrations.RemoveField( + model_name='ticket', + name='processor_display', + ), + migrations.AddField( + model_name='ticket', + name='approval_step', + field=models.SmallIntegerField(choices=[(1, 'One level'), (2, 'Two level')], default=1, + verbose_name='Approval step'), + ), + migrations.CreateModel( + name='TicketStep', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('created_by', models.CharField(blank=True, max_length=32, null=True, verbose_name='Created by')), + ('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Date created')), + ('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')), + ('level', models.SmallIntegerField(choices=[(1, 'One level'), (2, 'Two level')], default=1, + verbose_name='Approve level')), + ('state', models.CharField( + choices=[('notified', 'Notified'), ('approved', 'Approved'), ('rejected', 'Rejected')], + default='notified', max_length=64)), + ('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_steps', + to='tickets.ticket', verbose_name='Ticket')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='TicketFlow', + fields=[ + ('org_id', + models.CharField(blank=True, db_index=True, default='', max_length=36, verbose_name='Organization')), + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('created_by', models.CharField(blank=True, max_length=32, null=True, verbose_name='Created by')), + ('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Date created')), + ('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')), + ('type', models.CharField(choices=[('general', 'General'), ('login_confirm', 'Login confirm'), + ('apply_asset', 'Apply for asset'), + ('apply_application', 'Apply for application'), + ('login_asset_confirm', 'Login asset confirm'), + ('command_confirm', 'Command confirm')], default='general', + max_length=64, verbose_name='Type')), + ('approval_level', models.SmallIntegerField(choices=[(1, 'One level'), (2, 'Two level')], default=1, + verbose_name='Approval level')), + ('rules', models.ManyToManyField(related_name='ticket_flows', to='tickets.ApprovalRule')), + ], + options={ + 'verbose_name': 'Ticket flow', + }, + ), + migrations.CreateModel( + name='TicketAssignee', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('created_by', models.CharField(blank=True, max_length=32, null=True, verbose_name='Created by')), + ('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Date created')), + ('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')), + ('state', models.CharField( + choices=[('notified', 'Notified'), ('approved', 'Approved'), ('rejected', 'Rejected')], + default='notified', max_length=64)), + ('assignee', + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_assignees', + to=settings.AUTH_USER_MODEL, verbose_name='Assignee')), + ('step', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_assignees', + to='tickets.ticketstep')), + ], + options={ + 'verbose_name': 'Ticket assignee', + }, + ), + migrations.RunPython(create_step_and_assignee), + migrations.RunPython(create_ticket_flow_and_approval_rule), + migrations.AddField( + model_name='ticket', + name='flow', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tickets', + to='tickets.ticketflow', verbose_name='TicketFlow'), + ), + ] diff --git a/apps/tickets/models/__init__.py b/apps/tickets/models/__init__.py index 4f7ca772b..fd2bd9057 100644 --- a/apps/tickets/models/__init__.py +++ b/apps/tickets/models/__init__.py @@ -2,3 +2,5 @@ # from .ticket import * from .comment import * +from .flow import * + diff --git a/apps/tickets/models/flow.py b/apps/tickets/models/flow.py new file mode 100644 index 000000000..3273056d6 --- /dev/null +++ b/apps/tickets/models/flow.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# +from django.db import models +from django.utils.translation import ugettext_lazy as _ + +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 +from ..const import TicketType, TicketApprovalLevel, TicketApprovalStrategy +from ..signals import post_or_update_change_ticket_flow_approval + +__all__ = ['TicketFlow', 'ApprovalRule'] + + +class ApprovalRule(CommonModelMixin): + level = models.SmallIntegerField( + default=TicketApprovalLevel.one, choices=TicketApprovalLevel.choices, + verbose_name=_('Approve level') + ) + strategy = models.CharField( + max_length=64, default=TicketApprovalStrategy.super, + choices=TicketApprovalStrategy.choices, + verbose_name=_('Approve strategy') + ) + # 受理人列表 + assignees = models.ManyToManyField( + 'users.User', related_name='assigned_ticket_flow_approval_rule', + verbose_name=_("Assignees") + ) + assignees_display = models.JSONField( + encoder=ModelJSONFieldEncoder, default=list, + verbose_name=_('Assignees display') + ) + + class Meta: + verbose_name = _('Ticket flow approval rule') + + def __str__(self): + return '{}({})'.format(self.id, self.level) + + @classmethod + def change_assignees_display(cls, qs): + post_or_update_change_ticket_flow_approval.send(sender=cls, qs=qs) + + +class TicketFlow(CommonModelMixin, OrgModelMixin): + type = models.CharField( + max_length=64, choices=TicketType.choices, + default=TicketType.general, verbose_name=_("Type") + ) + approval_level = models.SmallIntegerField( + default=TicketApprovalLevel.one, + choices=TicketApprovalLevel.choices, + verbose_name=_('Approval level') + ) + rules = models.ManyToManyField(ApprovalRule, related_name='ticket_flows') + + class Meta: + verbose_name = _('Ticket flow') + + def __str__(self): + return '{}'.format(self.type) + + @classmethod + def get_org_related_flows(cls): + flows = cls.objects.all() + cur_flow_types = flows.values_list('type', flat=True) + with tmp_to_root_org(): + diff_global_flows = cls.objects.exclude(type__in=cur_flow_types) + return flows | diff_global_flows diff --git a/apps/tickets/models/ticket.py b/apps/tickets/models/ticket.py index 41d23c8b0..9abd29c96 100644 --- a/apps/tickets/models/ticket.py +++ b/apps/tickets/models/ticket.py @@ -1,76 +1,78 @@ # -*- coding: utf-8 -*- # -import json -import uuid -from datetime import datetime from django.db import models from django.db.models import Q from django.utils.translation import ugettext_lazy as _ -from django.conf import settings 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 TicketTypeChoices, TicketActionChoices, TicketStatusChoices +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', 'ModelJSONFieldEncoder'] +__all__ = ['Ticket'] -class ModelJSONFieldEncoder(json.JSONEncoder): - """ 解决一些类型的字段不能序列化的问题 """ - def default(self, obj): - if 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) - else: - return super().default(obj) +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 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 Ticket(CommonModelMixin, OrgModelMixin): title = models.CharField(max_length=256, verbose_name=_("Title")) type = models.CharField( - max_length=64, choices=TicketTypeChoices.choices, - default=TicketTypeChoices.general.value, verbose_name=_("Type") + max_length=64, choices=TicketType.choices, + default=TicketType.general, verbose_name=_("Type") ) meta = models.JSONField(encoder=ModelJSONFieldEncoder, default=dict, verbose_name=_("Meta")) - action = models.CharField( - choices=TicketActionChoices.choices, max_length=16, - default=TicketActionChoices.open.value, verbose_name=_("Action") + state = models.CharField( + max_length=16, choices=TicketState.choices, + default=TicketState.open, verbose_name=_("State") ) status = models.CharField( - max_length=16, choices=TicketStatusChoices.choices, - default=TicketStatusChoices.open.value, verbose_name=_("Status") + 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") - ) - # 处理人 - processor = models.ForeignKey( - 'users.User', related_name='processed_tickets', on_delete=models.SET_NULL, null=True, - verbose_name=_("Processor") - ) - processor_display = models.CharField( - max_length=256, blank=True, null=True, default='', verbose_name=_("Processor display") - ) - # 受理人列表 - assignees = models.ManyToManyField( - 'users.User', related_name='assigned_tickets', verbose_name=_("Assignees") - ) - assignees_display = models.JSONField( - encoder=ModelJSONFieldEncoder, default=list, verbose_name=_('Assignees display') - ) + 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") + ) class Meta: ordering = ('-date_created',) @@ -81,77 +83,142 @@ class Ticket(CommonModelMixin, OrgModelMixin): # type @property def type_apply_asset(self): - return self.type == TicketTypeChoices.apply_asset.value + return self.type == TicketType.apply_asset.value @property def type_apply_application(self): - return self.type == TicketTypeChoices.apply_application.value + return self.type == TicketType.apply_application.value @property def type_login_confirm(self): - return self.type == TicketTypeChoices.login_confirm.value + return self.type == TicketType.login_confirm.value # status - @property - def status_closed(self): - return self.status == TicketStatusChoices.closed.value - @property def status_open(self): - return self.status == TicketStatusChoices.open.value + 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 + + @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 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 = TicketStatusChoices.closed.value + self.status = TicketStatus.closed - # action - @property - def action_open(self): - return self.action == TicketActionChoices.open.value + def create_related_node(self): + approval_rule = self.get_current_ticket_flow_approve() + ticket_step = TicketStep.objects.create(ticket=self, level=self.approval_step) + ticket_assignees = [] + assignees = approval_rule.assignees.all() + for assignee in assignees: + ticket_assignees.append(TicketAssignee(step=ticket_step, assignee=assignee)) + TicketAssignee.objects.bulk_create(ticket_assignees) - @property - def action_approve(self): - return self.action == TicketActionChoices.approve.value + def create_process_map(self): + approval_rules = self.flow.rules.order_by('level') + nodes = list() + for node in approval_rules: + nodes.append( + { + 'approval_level': node.level, + 'state': ProcessStatus.notified, + 'assignees': [i for i in node.assignees.values_list('id', flat=True)], + 'assignees_display': node.assignees_display + } + ) + return nodes - @property - def action_reject(self): - return self.action == TicketActionChoices.reject.value - - @property - def action_close(self): - return self.action == TicketActionChoices.close.value + # TODO 兼容不存在流的工单 + def create_process_map_and_node(self, assignees): + 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) # action changed def open(self, applicant): self.applicant = applicant - self._change_action(action=TicketActionChoices.open.value) + self._change_action(TicketAction.open) + + def update_current_step_state_and_assignee(self, processor, state): + if self.status_closed: + raise AlreadyClosed + 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 approve(self, processor): - self.processor = processor - self._change_action(action=TicketActionChoices.approve.value) + self.update_current_step_state_and_assignee(processor, TicketState.approved) + self._change_action(TicketAction.approve) def reject(self, processor): - self.processor = processor - self._change_action(action=TicketActionChoices.reject.value) + self.update_current_step_state_and_assignee(processor, TicketState.rejected) + self._change_action(TicketAction.reject) def close(self, processor): - self.processor = processor - self._change_action(action=TicketActionChoices.close.value) + self.update_current_step_state_and_assignee(processor, TicketState.closed) + self._change_action(TicketAction.close) def _change_action(self, action): - self.action = action self.save() post_change_ticket_action.send(sender=self.__class__, ticket=self, action=action) # ticket def has_assignee(self, assignee): - return self.assignees.filter(id=assignee.id).exists() + return self.ticket_steps.filter(ticket_assignees__assignee=assignee, level=self.approval_step).exists() @classmethod def get_user_related_tickets(cls, user): - queries = Q(applicant=user) | Q(assignees=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(): diff --git a/apps/tickets/permissions/ticket.py b/apps/tickets/permissions/ticket.py index dbc74e6a9..bd77421a8 100644 --- a/apps/tickets/permissions/ticket.py +++ b/apps/tickets/permissions/ticket.py @@ -1,4 +1,3 @@ - from rest_framework import permissions @@ -7,12 +6,7 @@ class IsAssignee(permissions.BasePermission): return obj.has_assignee(request.user) -class IsAssigneeOrApplicant(IsAssignee): +class IsApplicant(permissions.BasePermission): def has_object_permission(self, request, view, obj): - return super().has_object_permission(request, view, obj) or obj.applicant == request.user - - -class NotClosed(permissions.BasePermission): - def has_object_permission(self, request, view, obj): - return not obj.status_closed + return obj.applicant == request.user diff --git a/apps/tickets/serializers/comment.py b/apps/tickets/serializers/comment.py index 5bf57180e..6cb8ab9d5 100644 --- a/apps/tickets/serializers/comment.py +++ b/apps/tickets/serializers/comment.py @@ -26,7 +26,7 @@ class CommentSerializer(serializers.ModelSerializer): 'body', 'user_display', 'date_created', 'date_updated' ] - fields_fk = ['ticket', 'user',] + fields_fk = ['ticket', 'user', ] fields = fields_small + fields_fk read_only_fields = [ 'user_display', 'date_created', 'date_updated' diff --git a/apps/tickets/serializers/ticket/meta/meta.py b/apps/tickets/serializers/ticket/meta/meta.py index 12b576857..936977dfc 100644 --- a/apps/tickets/serializers/ticket/meta/meta.py +++ b/apps/tickets/serializers/ticket/meta/meta.py @@ -1,6 +1,7 @@ from tickets import const from .ticket_type import ( - apply_asset, apply_application, login_confirm, login_asset_confirm, command_confirm + apply_asset, apply_application, login_confirm, + login_asset_confirm, command_confirm ) __all__ = [ @@ -10,35 +11,31 @@ __all__ = [ # ticket action # ------------- -action_open = const.TicketActionChoices.open.value -action_approve = const.TicketActionChoices.approve.value +action_open = const.TicketAction.open.value +action_approve = const.TicketAction.approve.value # defines `meta` field dynamic mapping serializers # ------------------------------------------------ type_serializer_classes_mapping = { - const.TicketTypeChoices.apply_asset.value: { - 'default': apply_asset.ApplyAssetSerializer, - action_open: apply_asset.ApplySerializer, - action_approve: apply_asset.ApproveSerializer, + const.TicketType.apply_asset.value: { + 'default': apply_asset.ApplySerializer }, - const.TicketTypeChoices.apply_application.value: { - 'default': apply_application.ApplyApplicationSerializer, - action_open: apply_application.ApplySerializer, - action_approve: apply_application.ApproveSerializer, + const.TicketType.apply_application.value: { + 'default': apply_application.ApplySerializer }, - const.TicketTypeChoices.login_confirm.value: { + const.TicketType.login_confirm.value: { 'default': login_confirm.LoginConfirmSerializer, action_open: login_confirm.ApplySerializer, action_approve: login_confirm.LoginConfirmSerializer(read_only=True), }, - const.TicketTypeChoices.login_asset_confirm.value: { + 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.TicketTypeChoices.command_confirm.value: { + 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/apply_application.py b/apps/tickets/serializers/ticket/meta/ticket_type/apply_application.py index 382a8d789..e8217bf2b 100644 --- a/apps/tickets/serializers/ticket/meta/ticket_type/apply_application.py +++ b/apps/tickets/serializers/ticket/meta/ticket_type/apply_application.py @@ -1,20 +1,20 @@ from rest_framework import serializers from django.utils.translation import ugettext_lazy as _ -from django.db.models import Q from perms.models import ApplicationPermission -from applications.models import Application from applications.const import AppCategory, AppType -from assets.models import SystemUser from orgs.utils import tmp_to_org from tickets.models import Ticket from .common import DefaultPermissionName __all__ = [ - 'ApplyApplicationSerializer', 'ApplySerializer', 'ApproveSerializer', + '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'), @@ -31,13 +31,23 @@ class ApplySerializer(serializers.Serializer): required=False, read_only=True, label=_('Type display'), allow_null=True ) - apply_application_group = serializers.ListField( - required=False, child=serializers.CharField(), label=_('Application group'), - default=list, allow_null=True + apply_applications = serializers.ListField( + required=True, child=serializers.UUIDField(), label=_('Apply applications'), + allow_null=True ) - apply_system_user_group = serializers.ListField( - required=False, child=serializers.CharField(), label=_('System user group'), - default=list, 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 @@ -46,37 +56,6 @@ class ApplySerializer(serializers.Serializer): required=True, label=_('Date expired'), allow_null=True ) - -class ApproveSerializer(serializers.Serializer): - # 审批信息 - approve_permission_name = serializers.CharField( - max_length=128, default=DefaultPermissionName(), label=_('Permission name') - ) - approve_applications = serializers.ListField( - required=True, child=serializers.UUIDField(), label=_('Approve applications'), - allow_null=True - ) - approve_applications_display = serializers.ListField( - required=False, read_only=True, child=serializers.CharField(), - label=_('Approve applications display'), allow_null=True, - default=list - ) - approve_system_users = serializers.ListField( - required=True, child=serializers.UUIDField(), label=_('Approve system users'), - allow_null=True - ) - approve_system_users_display = serializers.ListField( - required=False, read_only=True, child=serializers.CharField(), - label=_('Approve system user display'), allow_null=True, - default=list - ) - approve_date_start = serializers.DateTimeField( - required=True, label=_('Date start'), allow_null=True - ) - approve_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 @@ -90,83 +69,5 @@ class ApproveSerializer(serializers.Serializer): 'Permission named `{}` already exists'.format(permission_name) )) - def validate_approve_applications(self, approve_applications): - if not isinstance(self.root.instance, Ticket): - return [] - - with tmp_to_org(self.root.instance.org_id): - apply_type = self.root.instance.meta.get('apply_type') - queries = Q(type=apply_type) - queries &= Q(id__in=approve_applications) - application_ids = Application.objects.filter(queries).values_list('id', flat=True) - application_ids = [str(application_id) for application_id in application_ids] - if application_ids: - return application_ids - - raise serializers.ValidationError(_( - 'No `Application` are found under Organization `{}`'.format(self.root.instance.org_name) - )) - - def validate_approve_system_users(self, approve_system_users): - if not isinstance(self.root.instance, Ticket): - return [] - - with tmp_to_org(self.root.instance.org_id): - apply_type = self.root.instance.meta.get('apply_type') - protocol = SystemUser.get_protocol_by_application_type(apply_type) - queries = Q(protocol=protocol) - queries &= Q(id__in=approve_system_users) - system_user_ids = SystemUser.objects.filter(queries).values_list('id', flat=True) - system_user_ids = [str(system_user_id) for system_user_id in system_user_ids] - if system_user_ids: - return system_user_ids - - raise serializers.ValidationError(_( - 'No `SystemUser` are found under Organization `{}`'.format(self.root.instance.org_name) - )) -class ApplyApplicationSerializer(ApplySerializer, ApproveSerializer): - # 推荐信息 - recommend_applications = serializers.SerializerMethodField() - recommend_system_users = serializers.SerializerMethodField() - - def get_recommend_applications(self, value): - if not isinstance(self.root.instance, Ticket): - return [] - - apply_application_group = value.get('apply_application_group', []) - if not apply_application_group: - return [] - - apply_type = value.get('apply_type') - queries = Q() - for application in apply_application_group: - queries |= Q(name__icontains=application) - queries &= Q(type=apply_type) - - with tmp_to_org(self.root.instance.org_id): - application_ids = Application.objects.filter(queries).values_list('id', flat=True)[:15] - application_ids = [str(application_id) for application_id in application_ids] - return application_ids - - def get_recommend_system_users(self, value): - if not isinstance(self.root.instance, Ticket): - return [] - - apply_system_user_group = value.get('apply_system_user_group', []) - if not apply_system_user_group: - return [] - - apply_type = value.get('apply_type') - protocol = SystemUser.get_protocol_by_application_type(apply_type) - queries = Q() - for system_user in apply_system_user_group: - queries |= Q(username__icontains=system_user) - queries |= Q(name__icontains=system_user) - queries &= Q(protocol=protocol) - - with tmp_to_org(self.root.instance.org_id): - system_user_ids = SystemUser.objects.filter(queries).values_list('id', flat=True)[:5] - system_user_ids = [str(system_user_id) for system_user_id in system_user_ids] - return system_user_ids diff --git a/apps/tickets/serializers/ticket/meta/ticket_type/apply_asset.py b/apps/tickets/serializers/ticket/meta/ticket_type/apply_asset.py index 2ed9107c8..489ded1a8 100644 --- a/apps/tickets/serializers/ticket/meta/ticket_type/apply_asset.py +++ b/apps/tickets/serializers/ticket/meta/ticket_type/apply_asset.py @@ -1,39 +1,44 @@ from django.utils.translation import ugettext_lazy as _ -from django.db.models import Q from rest_framework import serializers from perms.serializers import ActionsField from perms.models import AssetPermission -from assets.models import Asset, SystemUser from orgs.utils import tmp_to_org from tickets.models import Ticket from .common import DefaultPermissionName - __all__ = [ - 'ApplyAssetSerializer', 'ApplySerializer', 'ApproveSerializer', + 'ApplySerializer', ] class ApplySerializer(serializers.Serializer): + apply_permission_name = serializers.CharField( + max_length=128, default=DefaultPermissionName(), label=_('Apply name') + ) # 申请信息 - apply_ip_group = serializers.ListField( - required=False, child=serializers.IPAddressField(), label=_('IP group'), - default=list, allow_null=True, + apply_assets = serializers.ListField( + required=True, allow_null=True, child=serializers.UUIDField(), label=_('Apply assets') ) - apply_hostname_group = serializers.ListField( - required=False, child=serializers.CharField(), label=_('Hostname group'), - default=list, allow_null=True, + apply_assets_display = serializers.ListField( + required=False, read_only=True, child=serializers.CharField(), + label=_('Approve assets display'), allow_null=True, + default=list, ) - apply_system_user_group = serializers.ListField( - required=False, child=serializers.CharField(), label=_('System user group'), - default=list, allow_null=True + apply_system_users = serializers.ListField( + required=True, allow_null=True, child=serializers.UUIDField(), + label=_('Approve 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=_('Approve assets display'), allow_null=True, + label=_('Apply assets display'), allow_null=True, default=list, ) apply_date_start = serializers.DateTimeField( @@ -43,44 +48,6 @@ class ApplySerializer(serializers.Serializer): required=True, label=_('Date expired'), allow_null=True, ) - -class ApproveSerializer(serializers.Serializer): - # 审批信息 - approve_permission_name = serializers.CharField( - max_length=128, default=DefaultPermissionName(), label=_('Permission name') - ) - approve_assets = serializers.ListField( - required=True, allow_null=True, child=serializers.UUIDField(), label=_('Approve assets') - ) - approve_assets_display = serializers.ListField( - required=False, read_only=True, child=serializers.CharField(), - label=_('Approve assets display'), allow_null=True, - default=list, - ) - approve_system_users = serializers.ListField( - required=True, allow_null=True, child=serializers.UUIDField(), - label=_('Approve system users') - ) - approve_system_users_display = serializers.ListField( - required=False, read_only=True, child=serializers.CharField(), - label=_('Approve assets display'), allow_null=True, - default=list, - ) - approve_actions = ActionsField( - required=True, allow_null=True, - ) - approve_actions_display = serializers.ListField( - required=False, read_only=True, child=serializers.CharField(), - label=_('Approve assets display'), allow_null=True, - default=list, - ) - approve_date_start = serializers.DateTimeField( - required=True, label=_('Date start'), allow_null=True, - ) - approve_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 @@ -93,76 +60,3 @@ class ApproveSerializer(serializers.Serializer): raise serializers.ValidationError(_( 'Permission named `{}` already exists'.format(permission_name) )) - - def validate_approve_assets(self, approve_assets): - if not isinstance(self.root.instance, Ticket): - return [] - - with tmp_to_org(self.root.instance.org_id): - asset_ids = Asset.objects.filter(id__in=approve_assets).values_list('id', flat=True) - asset_ids = [str(asset_id) for asset_id in asset_ids] - if asset_ids: - return asset_ids - - raise serializers.ValidationError(_( - 'No `Asset` are found under Organization `{}`'.format(self.root.instance.org_name) - )) - - def validate_approve_system_users(self, approve_system_users): - if not isinstance(self.root.instance, Ticket): - return [] - - with tmp_to_org(self.root.instance.org_id): - queries = Q(protocol__in=SystemUser.ASSET_CATEGORY_PROTOCOLS) - queries &= Q(id__in=approve_system_users) - system_user_ids = SystemUser.objects.filter(queries).values_list('id', flat=True) - system_user_ids = [str(system_user_id) for system_user_id in system_user_ids] - if system_user_ids: - return system_user_ids - - raise serializers.ValidationError(_( - 'No `SystemUser` are found under Organization `{}`'.format(self.root.instance.org_name) - )) - - -class ApplyAssetSerializer(ApplySerializer, ApproveSerializer): - # 推荐信息 - recommend_assets = serializers.SerializerMethodField() - recommend_system_users = serializers.SerializerMethodField() - - def get_recommend_assets(self, value): - if not isinstance(self.root.instance, Ticket): - return [] - - apply_ip_group = value.get('apply_ip_group', []) - apply_hostname_group = value.get('apply_hostname_group', []) - queries = Q() - if apply_ip_group: - queries |= Q(ip__in=apply_ip_group) - for hostname in apply_hostname_group: - queries |= Q(hostname__icontains=hostname) - if not queries: - return [] - with tmp_to_org(self.root.instance.org_id): - asset_ids = Asset.objects.filter(queries).values_list('id', flat=True)[:100] - asset_ids = [str(asset_id) for asset_id in asset_ids] - return asset_ids - - def get_recommend_system_users(self, value): - if not isinstance(self.root.instance, Ticket): - return [] - - apply_system_user_group = value.get('apply_system_user_group', []) - if not apply_system_user_group: - return [] - - queries = Q() - for system_user in apply_system_user_group: - queries |= Q(username__icontains=system_user) - queries |= Q(name__icontains=system_user) - queries &= Q(protocol__in=SystemUser.ASSET_CATEGORY_PROTOCOLS) - - with tmp_to_org(self.root.instance.org_id): - system_user_ids = SystemUser.objects.filter(queries).values_list('id', flat=True)[:5] - system_user_ids = [str(system_user_id) for system_user_id in system_user_ids] - return system_user_ids diff --git a/apps/tickets/serializers/ticket/ticket.py b/apps/tickets/serializers/ticket/ticket.py index fdf281b73..7408da11f 100644 --- a/apps/tickets/serializers/ticket/ticket.py +++ b/apps/tickets/serializers/ticket/ticket.py @@ -1,43 +1,38 @@ # -*- 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 users.models import User -from tickets.models import Ticket +from tickets.models import Ticket, TicketFlow, ApprovalRule +from tickets.const import TicketApprovalStrategy from .meta import type_serializer_classes_mapping - __all__ = [ - 'TicketDisplaySerializer', 'TicketApplySerializer', 'TicketApproveSerializer', + 'TicketDisplaySerializer', 'TicketApplySerializer', 'TicketApproveSerializer', 'TicketFlowSerializer' ] class TicketSerializer(OrgResourceModelSerializerMixin): type_display = serializers.ReadOnlyField(source='get_type_display', label=_('Type display')) - action_display = serializers.ReadOnlyField( - source='get_action_display', label=_('Action display') - ) - status_display = serializers.ReadOnlyField( - source='get_status_display', label=_('Status 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', 'body', - 'action', 'action_display', 'status', 'status_display', - 'applicant_display', 'processor_display', 'assignees_display', - 'date_created', 'date_updated', - 'comment', 'org_id', 'org_name', + 'type', 'type_display', 'meta', 'state', 'approval_step', + 'status', 'status_display', 'applicant_display', 'process_map', + 'date_created', 'date_updated', 'comment', 'org_id', 'org_name', 'body' ] - fields_fk = ['applicant', 'processor',] - fields_m2m = ['assignees'] - fields = fields_small + fields_fk + fields_m2m + fields_fk = ['applicant', ] + fields = fields_small + fields_fk def get_meta_serializer(self): default_serializer = serializers.Serializer(read_only=True) @@ -71,7 +66,6 @@ class TicketSerializer(OrgResourceModelSerializerMixin): class TicketDisplaySerializer(TicketSerializer): - class Meta: model = Ticket fields = TicketSerializer.Meta.fields @@ -87,7 +81,7 @@ class TicketApplySerializer(TicketSerializer): model = Ticket fields = TicketSerializer.Meta.fields writeable_fields = [ - 'id', 'title', 'type', 'meta', 'assignees', 'comment', 'org_id' + 'id', 'title', 'type', 'meta', 'comment', 'org_id' ] read_only_fields = list(set(fields) - set(writeable_fields)) extra_kwargs = { @@ -112,27 +106,115 @@ class TicketApplySerializer(TicketSerializer): raise serializers.ValidationError(error) return org_id - def validate_assignees(self, assignees): - org_id = self.initial_data.get('org_id') - self.validate_org_id(org_id) - org = Organization.get_instance(org_id) - admins = User.get_super_and_org_admins(org) - valid_assignees = list(set(assignees) & set(admins)) - if not valid_assignees: - error = _('None of the assignees belong to Organization `{}` admins'.format(org.name)) + def validate(self, attrs): + ticket_type = attrs.get('type') + flow = TicketFlow.get_org_related_flows().filter(type=ticket_type).first() + if flow: + attrs['flow'] = flow + else: + error = _('The ticket flow `{}` does not exist'.format(ticket_type)) raise serializers.ValidationError(error) - return valid_assignees + 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 - writeable_fields = ['meta'] - read_only_fields = list(set(fields) - set(writeable_fields)) + read_only_fields = fields - def validate_meta(self, meta): - _meta = self.instance.meta if self.instance else {} - _meta.update(meta) - return _meta + +class TicketFlowApproveSerializer(serializers.ModelSerializer): + strategy_display = serializers.ReadOnlyField(source='get_strategy_display', label=_('Approve strategy')) + assignees_read_only = serializers.SerializerMethodField(label=_("Assignees")) + + 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} + } + + def get_assignees_read_only(self, obj): + if obj.strategy == TicketApprovalStrategy.custom: + return obj.assignees.values_list('id', flat=True) + return [] + + +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, related, assignees, instance=None): + 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 + for level, data in enumerate(childs, 1): + data_m2m = data.pop(assignees, None) + child_instance = related_model.objects.create(**data, level=level) + if child_instance.strategy == 'super': + data_m2m = list(User.get_super_admins()) + elif child_instance.strategy == 'admin': + data_m2m = list(User.get_org_admins()) + elif child_instance.strategy == 'super_admin': + data_m2m = list(User.get_super_and_org_admins()) + 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, 'rules', 'assignees') + + @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, 'rules', 'assignees', instance) + return instance diff --git a/apps/tickets/signals.py b/apps/tickets/signals.py index 10951716d..b626cfa35 100644 --- a/apps/tickets/signals.py +++ b/apps/tickets/signals.py @@ -1,4 +1,5 @@ from django.dispatch import Signal - post_change_ticket_action = Signal() + +post_or_update_change_ticket_flow_approval = Signal() diff --git a/apps/tickets/signals_handler/ticket.py b/apps/tickets/signals_handler/ticket.py index aae620295..2d036f936 100644 --- a/apps/tickets/signals_handler/ticket.py +++ b/apps/tickets/signals_handler/ticket.py @@ -3,9 +3,8 @@ from django.dispatch import receiver from common.utils import get_logger -from tickets.models import Ticket -from ..signals import post_change_ticket_action - +from tickets.models import Ticket, ApprovalRule +from ..signals import post_change_ticket_action, post_or_update_change_ticket_flow_approval logger = get_logger(__name__) @@ -13,3 +12,12 @@ 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) + + +@receiver(post_or_update_change_ticket_flow_approval, sender=ApprovalRule) +def post_or_update_change_ticket_flow_approval(sender, qs, **kwargs): + updates = [] + for instance in qs: + instance.assignees_display = [str(assignee) for assignee in instance.assignees.all()] + updates.append(instance) + sender.objects.bulk_update(updates, ['assignees_display', ]) diff --git a/apps/tickets/urls/api_urls.py b/apps/tickets/urls/api_urls.py index 93286f645..1f284e7f1 100644 --- a/apps/tickets/urls/api_urls.py +++ b/apps/tickets/urls/api_urls.py @@ -8,6 +8,7 @@ app_name = 'tickets' router = BulkRouter() router.register('tickets', api.TicketViewSet, 'ticket') +router.register('flows', api.TicketFlowViewSet, 'flows') router.register('assignees', api.AssigneeViewSet, 'assignee') router.register('comments', api.CommentViewSet, 'comment') diff --git a/apps/tickets/utils.py b/apps/tickets/utils.py index 1bd789fda..7999a5e1d 100644 --- a/apps/tickets/utils.py +++ b/apps/tickets/utils.py @@ -26,9 +26,10 @@ EMAIL_TEMPLATE = ''' def send_ticket_applied_mail_to_assignees(ticket): - if not ticket.assignees: + assignees = ticket.current_node.first().ticket_assignees.all() + if not assignees: logger.debug("Not found assignees, ticket: {}({}), assignees: {}".format( - ticket, str(ticket.id), ticket.assignees) + ticket, str(ticket.id), assignees) ) return @@ -42,24 +43,24 @@ def send_ticket_applied_mail_to_assignees(ticket): ) if settings.DEBUG: logger.debug(message) - recipient_list = [assignee.email for assignee in ticket.assignees.all()] + recipient_list = [i.assignee.email for i in assignees] send_mail_async.delay(subject, message, recipient_list, html_message=message) -def send_ticket_processed_mail_to_applicant(ticket): +def send_ticket_processed_mail_to_applicant(ticket, processor): if not ticket.applicant: logger.error("Not found applicant: {}({})".format(ticket.title, ticket.id)) return - + processor_display = str(processor) ticket_detail_url = urljoin(settings.SITE_URL, const.TICKET_DETAIL_URL.format(id=str(ticket.id))) - subject = _('Ticket has processed - {} ({})').format(ticket.title, ticket.processor_display) + subject = _('Ticket has processed - {} ({})').format(ticket.title, processor_display) message = EMAIL_TEMPLATE.format( - title=_('Your ticket has been processed, processor - {}').format(ticket.processor_display), + title=_('Your ticket has been processed, processor - {}').format(processor_display), ticket_detail_url=ticket_detail_url, ticket_detail_url_description=_('click here to review'), body=ticket.body.replace('\n', '
'), ) if settings.DEBUG: logger.debug(message) - recipient_list = [ticket.applicant.email] + recipient_list = [ticket.applicant.email, ] send_mail_async.delay(subject, message, recipient_list, html_message=message) diff --git a/apps/users/models/user.py b/apps/users/models/user.py index 836e0b383..b87592ae7 100644 --- a/apps/users/models/user.py +++ b/apps/users/models/user.py @@ -27,7 +27,6 @@ from common.db.models import TextChoices from users.exceptions import MFANotEnabled from ..signals import post_user_change_password - __all__ = ['User', 'UserPasswordHistory'] logger = get_logger(__file__) @@ -358,6 +357,10 @@ class RoleMixin: def get_super_admins(cls): return cls.objects.filter(role=cls.ROLE.ADMIN) + @classmethod + def get_auditor_and_users(cls): + return cls.objects.filter(role__in=[cls.ROLE.USER, cls.ROLE.AUDITOR]) + @classmethod def get_org_admins(cls, org=None): from orgs.models import Organization @@ -369,12 +372,9 @@ class RoleMixin: @classmethod def get_super_and_org_admins(cls, org=None): super_admins = cls.get_super_admins() - super_admin_ids = list(super_admins.values_list('id', flat=True)) - org_admins = cls.get_org_admins(org) - org_admin_ids = list(org_admins.values_list('id', flat=True)) - admin_ids = set(org_admin_ids + super_admin_ids) - admins = User.objects.filter(id__in=admin_ids) - return admins + org_admins = cls.get_org_admins(org=org) + admins = org_admins | super_admins + return admins.distinct() class TokenMixin: