diff --git a/apps/assets/models/user.py b/apps/assets/models/user.py index 5a1f47284..082aaa412 100644 --- a/apps/assets/models/user.py +++ b/apps/assets/models/user.py @@ -87,6 +87,23 @@ class SystemUser(BaseUser): (PROTOCOL_POSTGRESQL, 'postgresql'), (PROTOCOL_K8S, 'k8s'), ) + ASSET_CATEGORY_PROTOCOLS = [ + PROTOCOL_SSH, PROTOCOL_RDP, PROTOCOL_TELNET, PROTOCOL_VNC + ] + APPLICATION_CATEGORY_REMOTE_APP_PROTOCOLS = [ + PROTOCOL_RDP + ] + APPLICATION_CATEGORY_DB_PROTOCOLS = [ + PROTOCOL_MYSQL, PROTOCOL_ORACLE, PROTOCOL_MARIADB, PROTOCOL_POSTGRESQL + ] + APPLICATION_CATEGORY_CLOUD_PROTOCOLS = [ + PROTOCOL_K8S + ] + APPLICATION_CATEGORY_PROTOCOLS = [ + *APPLICATION_CATEGORY_REMOTE_APP_PROTOCOLS, + *APPLICATION_CATEGORY_DB_PROTOCOLS, + *APPLICATION_CATEGORY_CLOUD_PROTOCOLS + ] LOGIN_AUTO = 'auto' LOGIN_MANUAL = 'manual' @@ -133,24 +150,6 @@ class SystemUser(BaseUser): def login_mode_display(self): return self.get_login_mode_display() - @property - def db_application_protocols(self): - return [ - self.PROTOCOL_MYSQL, self.PROTOCOL_ORACLE, self.PROTOCOL_MARIADB, - self.PROTOCOL_POSTGRESQL - ] - - @property - def cloud_application_protocols(self): - return [self.PROTOCOL_K8S] - - @property - def application_category_protocols(self): - protocols = [] - protocols.extend(self.db_application_protocols) - protocols.extend(self.cloud_application_protocols) - return protocols - def is_need_push(self): if self.auto_push and self.protocol in [self.PROTOCOL_SSH, self.PROTOCOL_RDP]: return True @@ -163,7 +162,7 @@ class SystemUser(BaseUser): @property def is_need_test_asset_connective(self): - return self.protocol not in self.application_category_protocols + return self.protocol in self.ASSET_CATEGORY_PROTOCOLS def has_special_auth(self, asset=None, username=None): if username is None and self.username_same_with_user: @@ -172,7 +171,7 @@ class SystemUser(BaseUser): @property def can_perm_to_asset(self): - return self.protocol not in self.application_category_protocols + return self.protocol in self.ASSET_CATEGORY_PROTOCOLS def _merge_auth(self, other): super()._merge_auth(other) @@ -205,6 +204,18 @@ class SystemUser(BaseUser): assets = Asset.objects.filter(id__in=assets_ids) return assets + @classmethod + def get_protocol_by_application_type(cls, application_type): + from applications.models import Category + remote_app_types = list(dict(Category.get_type_choices(Category.remote_app)).keys()) + if application_type in remote_app_types: + return cls.PROTOCOL_RDP + cloud_types = list(dict(Category.get_type_choices(Category.cloud)).keys()) + db_types = list(dict(Category.get_type_choices(Category.db)).keys()) + other_types = [*cloud_types, *db_types] + if application_type in other_types: + return application_type + class Meta: ordering = ['name'] unique_together = [('name', 'org_id')] diff --git a/apps/authentication/api/login_confirm.py b/apps/authentication/api/login_confirm.py index 4fbf3d5d3..6561962a9 100644 --- a/apps/authentication/api/login_confirm.py +++ b/apps/authentication/api/login_confirm.py @@ -45,5 +45,5 @@ class TicketStatusApi(mixins.AuthMixin, APIView): ticket = self.get_ticket() if ticket: request.session.pop('auth_ticket_id', '') - ticket.perform_status('closed', request.user) + ticket.close(processor=request.user) return Response('', status=200) diff --git a/apps/authentication/mixins.py b/apps/authentication/mixins.py index 87b989283..4e9684685 100644 --- a/apps/authentication/mixins.py +++ b/apps/authentication/mixins.py @@ -187,12 +187,12 @@ class AuthMixin: if not ticket_id: ticket = None else: - ticket = Ticket.origin_objects.get(pk=ticket_id) + ticket = Ticket.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 == ticket.STATUS.CLOSED: + if not ticket or ticket.status_closed: ticket = confirm_setting.create_confirm_ticket(self.request) self.request.session['auth_ticket_id'] = str(ticket.id) return ticket @@ -201,12 +201,16 @@ class AuthMixin: ticket = self.get_ticket() if not ticket: raise errors.LoginConfirmOtherError('', "Not found") - if ticket.status == ticket.STATUS.OPEN: + if ticket.status_open: raise errors.LoginConfirmWaitError(ticket.id) - elif ticket.action == ticket.ACTION.APPROVE: + elif ticket.is_approved: self.request.session["auth_confirm"] = "1" return - elif ticket.action == ticket.ACTION.REJECT: + elif ticket.is_rejected: + raise errors.LoginConfirmOtherError( + ticket.id, ticket.get_action_display() + ) + elif ticket.is_closed: raise errors.LoginConfirmOtherError( ticket.id, ticket.get_action_display() ) diff --git a/apps/authentication/models.py b/apps/authentication/models.py index a205a5190..b2f6ad602 100644 --- a/apps/authentication/models.py +++ b/apps/authentication/models.py @@ -49,29 +49,37 @@ class LoginConfirmSetting(CommonModelMixin): def get_user_confirm_setting(cls, user): return get_object_or_none(cls, user=user) - def create_confirm_ticket(self, request=None): - from tickets.models import Ticket - title = _('Login confirm') + ' {}'.format(self.user) + @staticmethod + def construct_confirm_ticket_meta(request=None): if request: - remote_addr = get_request_ip(request) - city = get_ip_city(remote_addr) - datetime = timezone.now().strftime('%Y-%m-%d %H:%M:%S') - body = __("{user_key}: {username}
" - "IP: {ip}
" - "{city_key}: {city}
" - "{date_key}: {date}
").format( - user_key=__("User"), username=self.user, - ip=remote_addr, city_key=_("City"), city=city, - date_key=__("Datetime"), date=datetime - ) + login_ip = get_request_ip(request) else: - body = '' - reviewer = self.reviewers.all() - ticket = Ticket.objects.create( - user=self.user, title=title, body=body, - type=Ticket.TYPE.LOGIN_CONFIRM, - ) - ticket.assignees.set(reviewer) + login_ip = '' + login_ip = login_ip or '0.0.0.0' + login_city = get_ip_city(login_ip) + login_datetime = timezone.now().strftime('%Y-%m-%d %H:%M:%S') + 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 + ticket_title = _('Login confirm') + ' {}'.format(self.user) + ticket_applicant = 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, + 'applicant': ticket_applicant, + 'meta': ticket_meta, + } + ticket = Ticket.objects.create(**data) + ticket.assignees.set(ticket_assignees) return ticket def __str__(self): diff --git a/apps/authentication/views/login.py b/apps/authentication/views/login.py index dcff66905..48bb16fba 100644 --- a/apps/authentication/views/login.py +++ b/apps/authentication/views/login.py @@ -19,7 +19,6 @@ from django.conf import settings from django.urls import reverse_lazy from django.contrib.auth import BACKEND_SESSION_KEY -from common.const.front_urls import TICKET_DETAIL from common.utils import get_request_ip, get_object_or_none from users.utils import ( redirect_user_first_login_or_index @@ -181,6 +180,7 @@ class UserLoginWaitConfirmView(TemplateView): def get_context_data(self, **kwargs): from tickets.models import Ticket + from tickets.const import TICKET_DETAIL_URL ticket_id = self.request.session.get("auth_ticket_id") if not ticket_id: ticket = None @@ -189,7 +189,7 @@ class UserLoginWaitConfirmView(TemplateView): context = super().get_context_data(**kwargs) if ticket: timestamp_created = datetime.datetime.timestamp(ticket.date_created) - ticket_detail_url = TICKET_DETAIL.format(id=ticket_id) + ticket_detail_url = TICKET_DETAIL_URL.format(id=ticket_id) msg = _("""Wait for {} confirm, You also can copy link to her/him
Don't close this page""").format(ticket.assignees_display) else: diff --git a/apps/common/const/front_urls.py b/apps/common/const/front_urls.py deleted file mode 100644 index 12d47ed17..000000000 --- a/apps/common/const/front_urls.py +++ /dev/null @@ -1,2 +0,0 @@ - -TICKET_DETAIL = '/ui/#/tickets/tickets/{id}' diff --git a/apps/common/fields/serializer.py b/apps/common/fields/serializer.py index 9cd630650..dc7f43428 100644 --- a/apps/common/fields/serializer.py +++ b/apps/common/fields/serializer.py @@ -7,7 +7,7 @@ import six __all__ = [ 'StringIDField', 'StringManyToManyField', 'ChoiceDisplayField', - 'CustomMetaDictField' + 'CustomMetaDictField', 'ReadableHiddenField', ] @@ -44,6 +44,17 @@ class DictField(serializers.DictField): return super().to_representation(value) +class ReadableHiddenField(serializers.HiddenField): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.write_only = False + + def to_representation(self, value): + if hasattr(value, 'id'): + return getattr(value, 'id') + return value + + class CustomMetaDictField(serializers.DictField): """ In use: diff --git a/apps/perms/forms/asset_permission.py b/apps/perms/forms/asset_permission.py index aa75b8be5..afa1bd2db 100644 --- a/apps/perms/forms/asset_permission.py +++ b/apps/perms/forms/asset_permission.py @@ -60,7 +60,7 @@ class AssetPermissionForm(OrgModelForm): # 过滤系统用户 system_users_field = self.fields.get('system_users') system_users_field.queryset = SystemUser.objects.exclude( - protocol__in=SystemUser.application_category_protocols + protocol__in=SystemUser.ASSET_CATEGORY_PROTOCOLS ) def set_nodes_initial(self, nodes): diff --git a/apps/perms/forms/database_app_permission.py b/apps/perms/forms/database_app_permission.py index 9074491c9..0dffadf93 100644 --- a/apps/perms/forms/database_app_permission.py +++ b/apps/perms/forms/database_app_permission.py @@ -25,7 +25,7 @@ class DatabaseAppPermissionCreateUpdateForm(OrgModelForm): # 过滤系统用户 system_users_field = self.fields.get('system_users') system_users_field.queryset = SystemUser.objects.filter( - protocol__in=SystemUser.application_category_protocols + protocol__in=SystemUser.APPLICATION_CATEGORY_DB_PROTOCOLS ) class Meta: diff --git a/apps/perms/models/asset_permission.py b/apps/perms/models/asset_permission.py index 983805720..df2041154 100644 --- a/apps/perms/models/asset_permission.py +++ b/apps/perms/models/asset_permission.py @@ -68,6 +68,11 @@ class Action: choices = [cls.NAME_MAP[i] for i, j in cls.DB_CHOICES if value & i == i] return choices + @classmethod + def value_to_choices_display(cls, value): + choices = cls.value_to_choices(value) + return [dict(cls.choices())[i] for i in choices] + @classmethod def choices_to_value(cls, value): if not isinstance(value, list): diff --git a/apps/tickets/api/__init__.py b/apps/tickets/api/__init__.py index 4eb820587..6b519ef80 100644 --- a/apps/tickets/api/__init__.py +++ b/apps/tickets/api/__init__.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- # from .ticket import * -from .request_asset_perm import * +from .assignee import * +from .comment import * diff --git a/apps/tickets/api/assignee.py b/apps/tickets/api/assignee.py new file mode 100644 index 000000000..eb835c522 --- /dev/null +++ b/apps/tickets/api/assignee.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# + +from rest_framework import viewsets + +from users.models import User +from common.permissions import IsValidUser +from common.exceptions import JMSException +from orgs.utils import get_org_by_id +from .. import serializers + + +class AssigneeViewSet(viewsets.ReadOnlyModelViewSet): + permission_classes = (IsValidUser,) + serializer_class = serializers.AssigneeSerializer + filter_fields = ('id', 'name', 'username', 'email', 'source') + search_fields = filter_fields + + def get_org(self): + org_id = self.request.query_params.get('org_id') + org = get_org_by_id(org_id) + if not org: + raise JMSException('The organization `{}` does not exist'.format(org_id)) + return org + + def get_queryset(self): + org = self.get_org() + queryset = User.get_super_and_org_admins(org=org) + return queryset diff --git a/apps/tickets/api/comment.py b/apps/tickets/api/comment.py new file mode 100644 index 000000000..e96176580 --- /dev/null +++ b/apps/tickets/api/comment.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# + +from rest_framework import viewsets, mixins +from django.shortcuts import get_object_or_404 +from common.exceptions import JMSException +from common.utils import lazyproperty +from tickets import serializers +from tickets.models import Ticket +from tickets.permissions.comment import IsAssignee, IsApplicant, IsSwagger + + +__all__ = ['CommentViewSet'] + + +class CommentViewSet(mixins.CreateModelMixin, viewsets.ReadOnlyModelViewSet): + serializer_class = serializers.CommentSerializer + permission_classes = (IsSwagger| IsAssignee | IsApplicant,) + + @lazyproperty + def ticket(self): + if getattr(self, 'swagger_fake_view', False): + return None + ticket_id = self.request.query_params.get('ticket_id') + try: + ticket = get_object_or_404(Ticket, pk=ticket_id) + return ticket + except Exception as e: + raise JMSException(str(e)) + + def get_serializer_context(self): + context = super().get_serializer_context() + context['ticket'] = self.ticket + return context + + def get_queryset(self): + queryset = self.ticket.comments.all() + return queryset diff --git a/apps/tickets/api/request_asset_perm.py b/apps/tickets/api/request_asset_perm.py deleted file mode 100644 index 8bac63481..000000000 --- a/apps/tickets/api/request_asset_perm.py +++ /dev/null @@ -1,157 +0,0 @@ -import textwrap - -from django.db.models import Q -from django.utils.translation import ugettext_lazy as _ -from rest_framework.mixins import ListModelMixin -from rest_framework.decorators import action -from rest_framework.response import Response - -from orgs.models import Organization, ROLE as ORG_ROLE -from users.models.user import User -from common.const.http import POST -from common.drf.api import JMSModelViewSet, JmsGenericViewSet -from common.permissions import IsValidUser, IsObjectOwner -from common.utils.timezone import dt_parser -from common.drf.serializers import EmptySerializer -from perms.models.asset_permission import AssetPermission, Asset -from perms.models import Action -from assets.models.user import SystemUser -from ..exceptions import ( - ConfirmedAssetsChanged, ConfirmedSystemUserChanged, - TicketClosed, TicketActionAlready, NotHaveConfirmedAssets, - NotHaveConfirmedSystemUser -) -from .. import serializers -from ..models import Ticket -from ..permissions import IsAssignee - - -class RequestAssetPermTicketViewSet(JMSModelViewSet): - queryset = Ticket.origin_objects.filter(type=Ticket.TYPE.REQUEST_ASSET_PERM) - serializer_classes = { - 'default': serializers.RequestAssetPermTicketSerializer, - 'approve': EmptySerializer, - 'reject': EmptySerializer, - 'close': EmptySerializer, - 'assignees': serializers.AssigneeSerializer, - } - permission_classes = (IsValidUser,) - filter_fields = ['status', 'title', 'action', 'user_display', 'org_id'] - search_fields = ['user_display', 'title'] - - def _check_can_set_action(self, instance, action): - if instance.status == instance.STATUS.CLOSED: - raise TicketClosed - if instance.action == action: - action_display = instance.ACTION.get(action) - raise TicketActionAlready(detail=_('Ticket has %s') % action_display) - - def _get_extra_comment(self, instance): - meta = instance.meta - ips = ', '.join(meta.get('ips', [])) - confirmed_assets_id = meta.get('confirmed_assets', []) - confirmed_system_users_id = meta.get('confirmed_system_users', []) - confirmed_assets = Asset.objects.filter(id__in=confirmed_assets_id) - confirmed_system_users = SystemUser.objects.filter(id__in=confirmed_system_users_id) - confirmed_assets_display = ', '.join([str(i) for i in confirmed_assets]) - confirmed_system_users_display = ', '.join([str(i) for i in confirmed_system_users]) - - return textwrap.dedent(''' - {}: {} - {}: {} - {}: {} - {}: {} - {}: {} - '''.format( - _('IP group'), ips, - _('Hostname'), meta.get('hostname', ''), - _('System user'), meta.get('system_user', ''), - _('Confirmed assets'), confirmed_assets_display, - _('Confirmed system users'), confirmed_system_users_display - )) - - @action(detail=True, methods=[POST], permission_classes=[IsAssignee, IsValidUser]) - def reject(self, request, *args, **kwargs): - instance = self.get_object() - action = instance.ACTION.REJECT - self._check_can_set_action(instance, action) - instance.perform_action(action, request.user, self._get_extra_comment(instance)) - return Response() - - @action(detail=True, methods=[POST], permission_classes=[IsAssignee, IsValidUser]) - def approve(self, request, *args, **kwargs): - instance = self.get_object() - action = instance.ACTION.APPROVE - self._check_can_set_action(instance, action) - - meta = instance.meta - confirmed_assets = meta.get('confirmed_assets', []) - assets = list(Asset.objects.filter(id__in=confirmed_assets)) - if not assets: - raise NotHaveConfirmedAssets(detail=_('Confirm assets first')) - - if len(assets) != len(confirmed_assets): - raise ConfirmedAssetsChanged(detail=_('Confirmed assets changed')) - - confirmed_system_users = meta.get('confirmed_system_users', []) - if not confirmed_system_users: - raise NotHaveConfirmedSystemUser(detail=_('Confirm system-users first')) - - system_users = SystemUser.objects.filter(id__in=confirmed_system_users) - if system_users is None: - raise ConfirmedSystemUserChanged(detail=_('Confirmed system-users changed')) - - instance.perform_action(instance.ACTION.APPROVE, - request.user, - self._get_extra_comment(instance)) - self._create_asset_permission(instance, assets, system_users) - return Response({'detail': _('Succeed')}) - - @action(detail=True, methods=[POST], permission_classes=[IsAssignee | IsObjectOwner]) - def close(self, request, *args, **kwargs): - instance = self.get_object() - instance.status = Ticket.STATUS.CLOSED - instance.save() - return Response({'detail': _('Succeed')}) - - def _create_asset_permission(self, instance: Ticket, assets, system_users): - meta = instance.meta - actions = meta.get('actions', Action.CONNECT) - - ap_kwargs = { - 'name': _('From request ticket: {} {}').format(instance.user_display, instance.id), - 'created_by': self.request.user.username, - 'comment': _('{} request assets, approved by {}').format(instance.user_display, - instance.assignee_display), - 'actions': actions, - } - date_start = dt_parser(meta.get('date_start')) - date_expired = dt_parser(meta.get('date_expired')) - if date_start: - ap_kwargs['date_start'] = date_start - if date_expired: - ap_kwargs['date_expired'] = date_expired - - ap = AssetPermission.objects.create(**ap_kwargs) - ap.system_users.add(*system_users) - ap.assets.add(*assets) - ap.users.add(instance.user) - - return ap - - -class AssigneeViewSet(ListModelMixin, JmsGenericViewSet): - serializer_class = serializers.AssigneeSerializer - permission_classes = (IsValidUser,) - filter_fields = ('username', 'email', 'name', 'id', 'source') - search_fields = filter_fields - - def get_queryset(self): - user = self.request.user - org_id = self.request.query_params.get('org_id', Organization.DEFAULT_ID) - - q = Q(role=User.ROLE.ADMIN) - if org_id != Organization.DEFAULT_ID: - q |= Q(m2m_org_members__role=ORG_ROLE.ADMIN, orgs__id=org_id, orgs__members=user) - org_admins = User.objects.filter(q).distinct() - return org_admins diff --git a/apps/tickets/api/ticket.py b/apps/tickets/api/ticket.py deleted file mode 100644 index 5a49c746d..000000000 --- a/apps/tickets/api/ticket.py +++ /dev/null @@ -1,44 +0,0 @@ -# -*- coding: utf-8 -*- -# - -from rest_framework import viewsets -from django.shortcuts import get_object_or_404 - -from common.permissions import IsValidUser -from common.utils import lazyproperty -from .. import serializers, models, mixins - - -class TicketViewSet(mixins.TicketMixin, viewsets.ModelViewSet): - serializer_class = serializers.TicketSerializer - queryset = models.Ticket.origin_objects.all() - permission_classes = (IsValidUser,) - filter_fields = ['status', 'title', 'action', 'user_display'] - search_fields = ['user_display', 'title'] - - -class TicketCommentViewSet(viewsets.ModelViewSet): - serializer_class = serializers.CommentSerializer - http_method_names = ['get', 'post'] - - def check_permissions(self, request): - ticket = self.ticket - if request.user == ticket.user or \ - request.user in ticket.assignees.all(): - return True - return False - - def get_serializer_context(self): - context = super().get_serializer_context() - context['ticket'] = self.ticket - return context - - @lazyproperty - def ticket(self): - ticket_id = self.kwargs.get('ticket_id') - ticket = get_object_or_404(models.Ticket, pk=ticket_id) - return ticket - - def get_queryset(self): - queryset = self.ticket.comments.all() - return queryset diff --git a/apps/tickets/api/ticket/__init__.py b/apps/tickets/api/ticket/__init__.py new file mode 100644 index 000000000..4a3a5e8c7 --- /dev/null +++ b/apps/tickets/api/ticket/__init__.py @@ -0,0 +1 @@ +from .ticket import * diff --git a/apps/tickets/api/ticket/mixin.py b/apps/tickets/api/ticket/mixin.py new file mode 100644 index 000000000..69f748741 --- /dev/null +++ b/apps/tickets/api/ticket/mixin.py @@ -0,0 +1,66 @@ +from common.exceptions import JMSException +from tickets import const, serializers + + +__all__ = ['TicketMetaSerializerViewMixin'] + + +class TicketMetaSerializerViewMixin: + apply_asset_meta_serializer_classes = { + 'apply': serializers.TicketMetaApplyAssetApplySerializer, + 'approve': serializers.TicketMetaApplyAssetApproveSerializer, + } + apply_application_meta_serializer_classes = { + 'apply': serializers.TicketMetaApplyApplicationApplySerializer, + 'approve': serializers.TicketMetaApplyApplicationApproveSerializer, + } + login_confirm_meta_serializer_classes = { + 'apply': serializers.TicketMetaLoginConfirmApplySerializer, + } + meta_serializer_classes = { + const.TicketTypeChoices.apply_asset.value: apply_asset_meta_serializer_classes, + const.TicketTypeChoices.apply_application.value: apply_application_meta_serializer_classes, + const.TicketTypeChoices.login_confirm.value: login_confirm_meta_serializer_classes, + } + + def get_serializer_meta_field_class(self): + tp = self.request.query_params.get('type') + if not tp: + return None + tp_choices = const.TicketTypeChoices.types() + if tp not in tp_choices: + raise JMSException( + 'Invalid query parameter `type`, select from the following options: {}' + ''.format(tp_choices) + ) + meta_class = self.meta_serializer_classes.get(tp, {}).get(self.action) + return meta_class + + def get_serializer_meta_field(self): + if self.action not in ['apply', 'approve']: + return None + meta_class = self.get_serializer_meta_field_class() + if not meta_class: + return None + return meta_class(required=True) + + def reset_view_metadata_action(self): + if self.action not in ['metadata']: + return + view_action = self.request.query_params.get('action') + if not view_action: + raise JMSException('The `metadata` methods must carry parameter `action`') + setattr(self, 'action', view_action) + + def get_serializer_class(self): + self.reset_view_metadata_action() + serializer_class = super().get_serializer_class() + if getattr(self, 'swagger_fake_view', False): + return serializer_class + meta_field = self.get_serializer_meta_field() + if not meta_field: + return serializer_class + serializer_class = type( + meta_field.__class__.__name__, (serializer_class,), {'meta': meta_field} + ) + return serializer_class diff --git a/apps/tickets/api/ticket/ticket.py b/apps/tickets/api/ticket/ticket.py new file mode 100644 index 000000000..a1ec4dc6c --- /dev/null +++ b/apps/tickets/api/ticket/ticket.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +# + +from rest_framework import viewsets +from rest_framework.decorators import action +from rest_framework.exceptions import MethodNotAllowed + +from common.mixins.api import CommonApiMixin +from common.permissions import IsValidUser, IsOrgAdmin +from common.const.http import POST, PUT +from tickets import serializers +from tickets.permissions.ticket import IsAssignee, NotClosed +from tickets.models import Ticket +from tickets.api.ticket.mixin import TicketMetaSerializerViewMixin + + +__all__ = ['TicketViewSet'] + + +class TicketViewSet(TicketMetaSerializerViewMixin, CommonApiMixin, viewsets.ModelViewSet): + permission_classes = (IsValidUser,) + serializer_class = serializers.TicketSerializer + serializer_classes = { + 'default': serializers.TicketDisplaySerializer, + 'display': serializers.TicketDisplaySerializer, + 'apply': serializers.TicketApplySerializer, + 'approve': serializers.TicketApproveSerializer, + 'reject': serializers.TicketRejectSerializer, + 'close': serializers.TicketCloseSerializer, + } + filter_fields = [ + 'id', 'title', 'type', 'action', 'status', 'applicant', 'applicant_display', 'processor', + 'processor_display', 'assignees__id' + ] + search_fields = [ + 'title', 'action', 'type', 'status', 'applicant_display', 'processor_display' + ] + + def create(self, request, *args, **kwargs): + raise MethodNotAllowed(self.action) + + def update(self, request, *args, **kwargs): + raise MethodNotAllowed(self.action) + + def destroy(self, request, *args, **kwargs): + raise MethodNotAllowed(self.action) + + def get_queryset(self): + queryset = Ticket.get_user_related_tickets(self.request.user) + return queryset + + @action(detail=False, methods=[POST]) + def apply(self, request, *args, **kwargs): + return super().create(request, *args, **kwargs) + + @action(detail=True, methods=[PUT], permission_classes=[IsOrgAdmin, IsAssignee, NotClosed]) + def approve(self, request, *args, **kwargs): + return super().update(request, *args, **kwargs) + + @action(detail=True, methods=[PUT], permission_classes=[IsOrgAdmin, IsAssignee, NotClosed]) + def reject(self, request, *args, **kwargs): + return super().update(request, *args, **kwargs) + + @action(detail=True, methods=[PUT], permission_classes=[IsOrgAdmin, IsAssignee, NotClosed]) + def close(self, request, *args, **kwargs): + return super().update(request, *args, **kwargs) diff --git a/apps/tickets/const.py b/apps/tickets/const.py new file mode 100644 index 000000000..7c503a6c0 --- /dev/null +++ b/apps/tickets/const.py @@ -0,0 +1,27 @@ +from django.db.models import TextChoices +from django.utils.translation import ugettext_lazy as _ + +TICKET_DETAIL_URL = '/ui/#/tickets/tickets/{id}' + + +class TicketTypeChoices(TextChoices): + general = 'general', _("General") + login_confirm = 'login_confirm', _("Login confirm") + apply_asset = 'apply_asset', _('Apply for asset') + apply_application = 'apply_application', _('Apply for application') + + @classmethod + def types(cls): + return set(dict(cls.choices).keys()) + + +class TicketActionChoices(TextChoices): + apply = 'apply', _('Apply') + approve = 'approve', _('Approve') + reject = 'reject', _('Reject') + close = 'close', _('Close') + + +class TicketStatusChoices(TextChoices): + open = 'open', _("Open") + closed = 'closed', _("Closed") diff --git a/apps/tickets/exceptions.py b/apps/tickets/exceptions.py deleted file mode 100644 index 5e5dedd21..000000000 --- a/apps/tickets/exceptions.py +++ /dev/null @@ -1,38 +0,0 @@ -from django.utils.translation import gettext_lazy as _ - -from common.exceptions import JMSException - - -class NotHaveConfirmedAssets(JMSException): - pass - - -class ConfirmedAssetsChanged(JMSException): - pass - - -class NotHaveConfirmedSystemUser(JMSException): - pass - - -class ConfirmedSystemUserChanged(JMSException): - pass - - -class TicketClosed(JMSException): - default_detail = _('Ticket closed') - default_code = 'ticket_closed' - - -class TicketActionAlready(JMSException): - pass - - -class OnlyTicketAssigneeCanOperate(JMSException): - default_detail = _('Only assignee can operate ticket') - default_code = 'can_not_operate' - - -class TicketCanNotOperate(JMSException): - default_detail = _('Ticket can not be operated') - default_code = 'ticket_can_not_be_operated' diff --git a/apps/tickets/migrations/0007_auto_20201224_1821.py b/apps/tickets/migrations/0007_auto_20201224_1821.py new file mode 100644 index 000000000..5367e0356 --- /dev/null +++ b/apps/tickets/migrations/0007_auto_20201224_1821.py @@ -0,0 +1,160 @@ +# Generated by Django 3.1 on 2020-12-24 10:21 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import tickets.models.ticket + +TICKET_TYPE_APPLY_ASSET = 'apply_asset' + + +def migrate_field_type(tp): + if tp == 'request_asset': + return TICKET_TYPE_APPLY_ASSET + return tp + + +def migrate_field_meta(tp, old_meta): + if tp != TICKET_TYPE_APPLY_ASSET or not old_meta: + return old_meta + old_meta_hostname = old_meta.get('hostname') + old_meta_system_user = old_meta.get('system_user') + new_meta = { + 'apply_ip_group': old_meta.get('ips', []), + 'apply_hostname_group': [old_meta_hostname] if old_meta_hostname else [], + 'apply_system_user_group': [old_meta_system_user] if old_meta_system_user else [], + 'apply_actions': old_meta.get('actions'), + 'apply_date_start': old_meta.get('date_start'), + 'apply_date_expired': old_meta.get('date_expired'), + + 'approve_assets': old_meta.get('confirmed_assets', []), + 'approve_system_users': old_meta.get('confirmed_system_users', []), + 'approve_actions': old_meta.get('actions'), + 'approve_date_start': old_meta.get('date_start'), + 'approve_date_expired': old_meta.get('date_expired'), + } + return new_meta + + +ACTION_APPLY = 'apply' +ACTION_CLOSE = 'close' +STATUS_OPEN = 'open' +STATUS_CLOSED = 'closed' + + +def migrate_field_action(old_action, old_status): + if old_action: + return old_action + if old_status == STATUS_OPEN: + return ACTION_APPLY + if old_status == STATUS_CLOSED: + return ACTION_CLOSE + + +def migrate_tickets_fields_name(apps, schema_editor): + ticket_model = apps.get_model("tickets", "Ticket") + tickets = ticket_model.origin_objects.all() + + for ticket in tickets: + ticket.applicant = ticket.user + ticket.applicant_display = ticket.user_display + ticket.processor = ticket.assignee + ticket.processor_display = ticket.assignee_display + ticket.action = migrate_field_action(ticket.action, ticket.status) + ticket.type = migrate_field_type(ticket.type) + ticket.meta = migrate_field_meta(ticket.type, ticket.meta) + ticket.meta['body'] = ticket.body + ticket.save() + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('tickets', '0006_auto_20201023_1628'), + ] + + operations = [ + # model ticket + migrations.AddField( + model_name='ticket', + name='applicant', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='applied_tickets', to=settings.AUTH_USER_MODEL, verbose_name='Applicant'), + ), + migrations.AddField( + model_name='ticket', + name='applicant_display', + field=models.CharField(default='No', max_length=256, verbose_name='Applicant display'), + ), + migrations.AddField( + model_name='ticket', + name='processor', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='processed_tickets', to=settings.AUTH_USER_MODEL, verbose_name='Processor'), + ), + migrations.AddField( + model_name='ticket', + name='processor_display', + field=models.CharField(blank=True, default='No', max_length=256, null=True, verbose_name='Processor display'), + ), + migrations.AlterField( + model_name='ticket', + name='assignees', + field=models.ManyToManyField(related_name='assigned_tickets', to=settings.AUTH_USER_MODEL, verbose_name='Assignees'), + ), + migrations.AlterField( + model_name='ticket', + name='assignees_display', + field=models.TextField(blank=True, default='No', verbose_name='Assignees display'), + ), + migrations.AlterField( + model_name='ticket', + name='meta', + field=models.JSONField(encoder=tickets.models.ticket.ModelJSONFieldEncoder, verbose_name='Meta'), + ), + migrations.AlterField( + model_name='ticket', + name='type', + field=models.CharField(choices=[('general', 'General'), ('login_confirm', 'Login confirm'), ('apply_asset', 'Apply for asset'), ('apply_application', 'Apply for application')], default='general', max_length=64, verbose_name='Type'), + ), + migrations.AlterField( + model_name='ticket', + name='action', + field=models.CharField(choices=[('apply', 'Apply'), ('approve', 'Approve'), ('reject', 'Reject'), ('close', 'Close')], default='apply', max_length=16, verbose_name='Action')), + migrations.AlterField( + model_name='ticket', + name='status', + field=models.CharField(choices=[('open', 'Open'), ('closed', 'Closed')], default='open', max_length=16, verbose_name='Status'), + ), + migrations.RunPython(migrate_tickets_fields_name), + migrations.RemoveField( + model_name='ticket', + name='user', + ), + migrations.RemoveField( + model_name='ticket', + name='user_display', + ), + migrations.RemoveField( + model_name='ticket', + name='assignee', + ), + migrations.RemoveField( + model_name='ticket', + name='assignee_display', + ), + migrations.RemoveField( + model_name='ticket', + name='body', + ), + migrations.AlterModelManagers( + name='ticket', + managers=[ + ], + ), + # model comment + migrations.AlterField( + model_name='comment', + name='user_display', + field=models.CharField(max_length=256, verbose_name='User display name'), + ), + ] diff --git a/apps/tickets/mixins.py b/apps/tickets/mixins.py deleted file mode 100644 index c4a48d866..000000000 --- a/apps/tickets/mixins.py +++ /dev/null @@ -1,17 +0,0 @@ -# -*- coding: utf-8 -*- -# -from django.db.models import Q -from .models import Ticket - - -class TicketMixin: - def get_queryset(self): - queryset = super().get_queryset() - assign = self.request.GET.get('assign', None) - if assign is None: - queryset = Ticket.get_related_tickets(self.request.user, queryset) - elif assign in ['1']: - queryset = Ticket.get_assigned_tickets(self.request.user, queryset) - else: - queryset = Ticket.get_my_tickets(self.request.user, queryset) - return queryset diff --git a/apps/tickets/models/__init__.py b/apps/tickets/models/__init__.py index 0b5dd0e7d..4f7ca772b 100644 --- a/apps/tickets/models/__init__.py +++ b/apps/tickets/models/__init__.py @@ -1,3 +1,4 @@ # -*- coding: utf-8 -*- # from .ticket import * +from .comment import * diff --git a/apps/tickets/models/comment.py b/apps/tickets/models/comment.py new file mode 100644 index 000000000..4d45de98c --- /dev/null +++ b/apps/tickets/models/comment.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# +from django.db import models +from django.utils.translation import ugettext_lazy as _ + +from common.mixins.models import CommonModelMixin + +__all__ = ['Comment'] + + +class Comment(CommonModelMixin): + ticket = models.ForeignKey( + 'tickets.Ticket', on_delete=models.CASCADE, related_name='comments' + ) + user = models.ForeignKey( + 'users.User', on_delete=models.SET_NULL, null=True, related_name='comments', + verbose_name=_("User") + ) + user_display = models.CharField(max_length=256, verbose_name=_("User display name")) + body = models.TextField(verbose_name=_("Body")) + + class Meta: + ordering = ('date_created', ) diff --git a/apps/tickets/models/ticket.py b/apps/tickets/models/ticket.py deleted file mode 100644 index 8c5f50aea..000000000 --- a/apps/tickets/models/ticket.py +++ /dev/null @@ -1,141 +0,0 @@ -# -*- coding: utf-8 -*- -# - -from django.db import models -from django.db.models import Q -from django.utils.translation import ugettext_lazy as _ - -from common.db.models import ChoiceSet -from common.mixins.models import CommonModelMixin -from common.fields.model import JsonDictTextField -from orgs.mixins.models import OrgModelMixin - -__all__ = ['Ticket', 'Comment'] - - -class Ticket(OrgModelMixin, CommonModelMixin): - class STATUS(ChoiceSet): - OPEN = 'open', _("Open") - CLOSED = 'closed', _("Closed") - - class TYPE(ChoiceSet): - GENERAL = 'general', _("General") - LOGIN_CONFIRM = 'login_confirm', _("Login confirm") - REQUEST_ASSET_PERM = 'request_asset', _('Request asset permission') - - class ACTION(ChoiceSet): - APPROVE = 'approve', _('Approve') - REJECT = 'reject', _('Reject') - - user = models.ForeignKey('users.User', on_delete=models.SET_NULL, null=True, related_name='%(class)s_requested', verbose_name=_("User")) - user_display = models.CharField(max_length=128, verbose_name=_("User display name")) - - title = models.CharField(max_length=256, verbose_name=_("Title")) - body = models.TextField(verbose_name=_("Body")) - meta = JsonDictTextField(verbose_name=_("Meta"), default='{}') - assignee = models.ForeignKey('users.User', on_delete=models.SET_NULL, null=True, related_name='%(class)s_handled', verbose_name=_("Assignee")) - assignee_display = models.CharField(max_length=128, blank=True, null=True, verbose_name=_("Assignee display name"), default='') - assignees = models.ManyToManyField('users.User', related_name='%(class)s_assigned', verbose_name=_("Assignees")) - assignees_display = models.CharField(max_length=128, verbose_name=_("Assignees display name"), blank=True) - type = models.CharField(max_length=16, choices=TYPE.choices, default=TYPE.GENERAL, verbose_name=_("Type")) - status = models.CharField(choices=STATUS.choices, max_length=16, default='open') - action = models.CharField(choices=ACTION.choices, max_length=16, default='', blank=True) - comment = models.TextField(default='', blank=True, verbose_name=_('Comment')) - - origin_objects = models.Manager() - - def __str__(self): - return '{}: {}'.format(self.user_display, self.title) - - @property - def body_as_html(self): - return self.body.replace('\n', '
') - - @property - def status_display(self): - return self.get_status_display() - - @property - def type_display(self): - return self.get_type_display() - - @property - def action_display(self): - return self.get_action_display() - - def create_status_comment(self, status, user): - if status == self.STATUS.CLOSED: - action = _("Close") - else: - action = _("Open") - body = _('{} {} this ticket').format(self.user, action) - self.comments.create(user=user, body=body) - - def perform_status(self, status, user, extra_comment=None): - self.create_comment( - self.STATUS.get(status), - user, - extra_comment - ) - self.status = status - self.assignee = user - self.save() - - def create_comment(self, action_display, user, extra_comment=None): - body = '{} {} {}'.format(user, action_display, _("this ticket")) - if extra_comment is not None: - body += extra_comment - self.comments.create(body=body, user=user, user_display=str(user)) - - def perform_action(self, action, user, extra_comment=None): - self.create_comment( - self.ACTION.get(action), - user, - extra_comment - ) - self.action = action - self.status = self.STATUS.CLOSED - self.assignee = user - self.save() - - def is_assignee(self, user): - return self.assignees.filter(id=user.id).exists() - - def is_user(self, user): - return self.user == user - - @classmethod - def get_related_tickets(cls, user, queryset=None): - if queryset is None: - queryset = cls.objects.all() - queryset = queryset.filter( - Q(assignees=user) | Q(user=user) - ).distinct() - return queryset - - @classmethod - def get_assigned_tickets(cls, user, queryset=None): - if queryset is None: - queryset = cls.objects.all() - queryset = queryset.filter(assignees=user) - return queryset - - @classmethod - def get_my_tickets(cls, user, queryset=None): - if queryset is None: - queryset = cls.objects.all() - queryset = queryset.filter(user=user) - return queryset - - class Meta: - ordering = ('-date_created',) - - -class Comment(CommonModelMixin): - ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name='comments') - user = models.ForeignKey('users.User', on_delete=models.SET_NULL, null=True, verbose_name=_("User"), related_name='comments') - user_display = models.CharField(max_length=128, verbose_name=_("User display name")) - body = models.TextField(verbose_name=_("Body")) - - class Meta: - ordering = ('date_created', ) diff --git a/apps/tickets/models/ticket/__init__.py b/apps/tickets/models/ticket/__init__.py new file mode 100644 index 000000000..4a3a5e8c7 --- /dev/null +++ b/apps/tickets/models/ticket/__init__.py @@ -0,0 +1 @@ +from .ticket import * diff --git a/apps/tickets/models/ticket/mixin/__init__.py b/apps/tickets/models/ticket/mixin/__init__.py new file mode 100644 index 000000000..d3a9538d1 --- /dev/null +++ b/apps/tickets/models/ticket/mixin/__init__.py @@ -0,0 +1 @@ +from .ticket import TicketModelMixin diff --git a/apps/tickets/models/ticket/mixin/apply_application.py b/apps/tickets/models/ticket/mixin/apply_application.py new file mode 100644 index 000000000..df1eef7ac --- /dev/null +++ b/apps/tickets/models/ticket/mixin/apply_application.py @@ -0,0 +1,100 @@ +from django.utils.translation import ugettext as __ +from orgs.utils import tmp_to_org, tmp_to_root_org +from applications.models import Application, Category +from assets.models import SystemUser +from perms.models import ApplicationPermission + + +class ConstructBodyMixin: + + def construct_apply_application_applied_body(self): + apply_category = self.meta['apply_category'] + apply_category_display = dict(Category.choices)[apply_category] + apply_type = self.meta['apply_type'] + apply_type_display = dict(Category.get_type_choices(apply_category))[apply_type] + apply_application_group = self.meta['apply_application_group'] + apply_system_user_group = self.meta['apply_system_user_group'] + apply_date_start = self.meta['apply_date_start'] + apply_date_expired = self.meta['apply_date_expired'] + applied_body = '''{}: {}, + {}: {}, + {}: {}, + {}: {}, + {}: {}, + {}: {}, + '''.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 date start'), apply_date_start, + __('Applied date expired'), apply_date_expired, + ) + return applied_body + + def construct_apply_application_approved_body(self): + # 审批信息 + approve_applications_id = self.meta['approve_applications'] + approve_system_users_id = self.meta['approve_system_users'] + with tmp_to_org(self.org_id): + approve_applications = Application.objects.filter(id__in=approve_applications_id) + approve_system_users = SystemUser.objects.filter(id__in=approve_system_users_id) + approve_applications_display = [str(application) for application in approve_applications] + approve_system_users_display = [str(system_user) for system_user in approve_system_users] + approve_date_start = self.meta['approve_date_start'] + approve_date_expired = self.meta['approve_date_expired'] + approved_body = '''{}: {}, + {}: {}, + {}: {}, + {}: {}, + '''.format( + __('Approved applications'), ', '.join(approve_applications_display), + __('Approved system users'), ', '.join(approve_system_users_display), + __('Approved date start'), approve_date_start, + __('Approved date expired'), approve_date_expired + ) + return approved_body + + +class CreatePermissionMixin: + + def create_apply_application_permission(self): + with tmp_to_root_org(): + application_permission = ApplicationPermission.objects.filter(id=self.id).first() + if application_permission: + return application_permission + + apply_category = self.meta['apply_category'] + apply_type = self.meta['apply_type'] + approved_applications_id = self.meta['approve_applications'] + approve_system_users_id = self.meta['approve_system_users'] + approve_date_start = self.meta['approve_date_start'] + approve_date_expired = self.meta['approve_date_expired'] + permission_name = '{}({})'.format( + __('Created by ticket ({})'.format(self.title)), str(self.id)[:4] + ) + permission_comment = __( + 'Created by the ticket, ' + 'ticket title: {}, ' + 'ticket applicant: {}, ' + 'ticket processor: {}, ' + 'ticket ID: {}' + ''.format(self.title, self.applicant_display, self.processor_display, str(self.id)) + ) + permissions_data = { + 'id': self.id, + 'name': permission_name, + 'category': apply_category, + 'type': apply_type, + 'comment': permission_comment, + 'created_by': self.processor_display, + 'date_start': approve_date_start, + 'date_expired': approve_date_expired, + } + with tmp_to_org(self.org_id): + application_permission = ApplicationPermission.objects.create(**permissions_data) + application_permission.users.add(self.applicant) + application_permission.applications.set(approved_applications_id) + application_permission.system_users.set(approve_system_users_id) + + return application_permission diff --git a/apps/tickets/models/ticket/mixin/apply_asset.py b/apps/tickets/models/ticket/mixin/apply_asset.py new file mode 100644 index 000000000..e9521e746 --- /dev/null +++ b/apps/tickets/models/ticket/mixin/apply_asset.py @@ -0,0 +1,99 @@ +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 ConstructBodyMixin: + def construct_apply_asset_applied_body(self): + apply_ip_group = self.meta['apply_ip_group'] + apply_hostname_group = self.meta['apply_hostname_group'] + apply_system_user_group = self.meta['apply_system_user_group'] + apply_actions = self.meta['apply_actions'] + apply_actions_display = Action.value_to_choices_display(apply_actions) + apply_actions_display = [str(action_display) for action_display in apply_actions_display] + apply_date_start = self.meta['apply_date_start'] + apply_date_expired = self.meta['apply_date_expired'] + applied_body = '''{}: {}, + {}: {}, + {}: {}, + {}: {}, + {}: {} + '''.format( + __('Applied IP group'), apply_ip_group, + __("Applied hostname group"), apply_hostname_group, + __("Applied system user group"), apply_system_user_group, + __("Applied actions"), apply_actions_display, + __('Applied date start'), apply_date_start, + __('Applied date expired'), apply_date_expired, + ) + return applied_body + + def construct_apply_asset_approved_body(self): + approve_assets_id = self.meta['approve_assets'] + approve_system_users_id = self.meta['approve_system_users'] + with tmp_to_org(self.org_id): + approve_assets = Asset.objects.filter(id__in=approve_assets_id) + approve_system_users = SystemUser.objects.filter(id__in=approve_system_users_id) + approve_assets_display = [str(asset) for asset in approve_assets] + approve_system_users_display = [str(system_user) for system_user in approve_system_users] + approve_actions = self.meta['approve_actions'] + approve_actions_display = Action.value_to_choices_display(approve_actions) + approve_actions_display = [str(action_display) for action_display in approve_actions_display] + approve_date_start = self.meta['approve_date_start'] + approve_date_expired = self.meta['approve_date_expired'] + approved_body = '''{}: {}, + {}: {}, + {}: {}, + {}: {}, + {}: {} + '''.format( + __('Approved assets'), ', '.join(approve_assets_display), + __('Approved system users'), ', '.join(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 + + +class CreatePermissionMixin: + def create_apply_asset_permission(self): + with tmp_to_root_org(): + asset_permission = AssetPermission.objects.filter(id=self.id).first() + if asset_permission: + return asset_permission + + approve_assets_id = self.meta['approve_assets'] + approve_system_users_id = self.meta['approve_system_users'] + approve_actions = self.meta['approve_actions'] + approve_date_start = self.meta['approve_date_start'] + approve_date_expired = self.meta['approve_date_expired'] + permission_name = '{}({})'.format( + __('Created by ticket ({})'.format(self.title)), str(self.id)[:4] + ) + permission_comment = __( + 'Created by the ticket, ' + 'ticket title: {}, ' + 'ticket applicant: {}, ' + 'ticket processor: {}, ' + 'ticket ID: {}' + ''.format(self.title, self.applicant_display, self.processor_display, str(self.id)) + ) + permission_data = { + 'id': self.id, + 'name': permission_name, + 'comment': permission_comment, + 'created_by': self.processor_display, + 'actions': approve_actions, + 'date_start': approve_date_start, + 'date_expired': approve_date_expired, + } + with tmp_to_org(self.org_id): + asset_permission = AssetPermission.objects.create(**permission_data) + asset_permission.users.add(self.applicant) + asset_permission.assets.set(approve_assets_id) + asset_permission.system_users.set(approve_system_users_id) + + return asset_permission diff --git a/apps/tickets/models/ticket/mixin/base.py b/apps/tickets/models/ticket/mixin/base.py new file mode 100644 index 000000000..ea021f038 --- /dev/null +++ b/apps/tickets/models/ticket/mixin/base.py @@ -0,0 +1,100 @@ +import textwrap +from django.utils.translation import ugettext as __ + + +class ConstructBodyMixin: + # applied body + def construct_applied_body(self): + construct_method = getattr(self, f'construct_{self.type}_applied_body', lambda: 'No') + applied_body = construct_method() + body = ''' + {}: + {} + '''.format( + __('Ticket applied info'), + applied_body + ) + return body + + # approved body + def construct_approved_body(self): + construct_method = getattr(self, f'construct_{self.type}_approved_body', lambda: 'No') + approved_body = construct_method() + body = ''' + {}: + {} + '''.format( + __('Ticket approved info'), + approved_body + ) + return body + + # meta body + def construct_meta_body(self): + applied_body = self.construct_applied_body() + if not self.is_approved: + return applied_body + approved_body = self.construct_approved_body() + return applied_body + approved_body + + # basic body + def construct_basic_body(self): + basic_body = ''' + {}: + {}: {}, + {}: {}, + {}: {}, + {}: {}, + {}: {}, + {}: {}, + {}: {} + '''.format( + __("Ticket basic info"), + __('Ticket title'), self.title, + __('Ticket type'), self.get_type_display(), + __('Ticket applicant'), self.applicant_display, + __('Ticket assignees'), self.assignees_display, + __('Ticket processor'), self.processor_display, + __('Ticket action'), self.get_action_display(), + __('Ticket status'), self.get_status_display() + ) + return basic_body + + @property + def body(self): + old_body = self.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 + + +class CreatePermissionMixin: + # create permission + def create_permission(self): + create_method = getattr(self, f'create_{self.type}_permission', lambda: None) + create_method() + + +class CreateCommentMixin: + def create_comment(self, comment_body): + comment_data = { + 'body': comment_body, + 'user': self.processor, + 'user_display': self.processor_display + } + return self.comments.create(**comment_data) + + def create_approved_comment(self): + comment_body = self.construct_approved_body() + # 页面展示需要取消缩进 + comment_body = textwrap.dedent(comment_body) + self.create_comment(comment_body) + + def create_action_comment(self): + comment_body = __( + 'User {} {} the ticket'.format(self.processor_display, self.get_action_display()) + ) + self.create_comment(comment_body) diff --git a/apps/tickets/models/ticket/mixin/login_confirm.py b/apps/tickets/models/ticket/mixin/login_confirm.py new file mode 100644 index 000000000..92ac57194 --- /dev/null +++ b/apps/tickets/models/ticket/mixin/login_confirm.py @@ -0,0 +1,18 @@ +from django.utils.translation import ugettext as __ + + +class ConstructBodyMixin: + + def construct_login_confirm_applied_body(self): + apply_login_ip = self.meta['apply_login_ip'] + apply_login_city = self.meta['apply_login_city'] + apply_login_datetime = self.meta['apply_login_datetime'] + applied_body = '''{}: {}, + {}: {}, + {}: {} + '''.format( + __("Applied login IP"), apply_login_ip, + __("Applied login city"), apply_login_city, + __("Applied login datetime"), apply_login_datetime, + ) + return applied_body diff --git a/apps/tickets/models/ticket/mixin/ticket.py b/apps/tickets/models/ticket/mixin/ticket.py new file mode 100644 index 000000000..1d051b714 --- /dev/null +++ b/apps/tickets/models/ticket/mixin/ticket.py @@ -0,0 +1,32 @@ +from . import base, apply_asset, apply_application, login_confirm + +__all__ = ['TicketModelMixin'] + + +class TicketConstructBodyMixin( + base.ConstructBodyMixin, + apply_asset.ConstructBodyMixin, + apply_application.ConstructBodyMixin, + login_confirm.ConstructBodyMixin +): + pass + + +class TicketCreatePermissionMixin( + base.CreatePermissionMixin, + apply_asset.CreatePermissionMixin, + apply_application.CreatePermissionMixin +): + pass + + +class TicketCreateCommentMixin( + base.CreateCommentMixin +): + pass + + +class TicketModelMixin( + TicketConstructBodyMixin, TicketCreatePermissionMixin, TicketCreateCommentMixin +): + pass diff --git a/apps/tickets/models/ticket/ticket.py b/apps/tickets/models/ticket/ticket.py new file mode 100644 index 000000000..fb5fe13ae --- /dev/null +++ b/apps/tickets/models/ticket/ticket.py @@ -0,0 +1,159 @@ +# -*- 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 orgs.mixins.models import OrgModelMixin +from orgs.utils import tmp_to_root_org, tmp_to_org +from tickets import const +from .mixin import TicketModelMixin + +__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) + else: + return super().default(obj) + + +class Ticket(TicketModelMixin, CommonModelMixin, OrgModelMixin): + title = models.CharField(max_length=256, verbose_name=_("Title")) + type = models.CharField( + max_length=64, choices=const.TicketTypeChoices.choices, + default=const.TicketTypeChoices.general.value, verbose_name=_("Type") + ) + meta = models.JSONField(encoder=ModelJSONFieldEncoder, verbose_name=_("Meta")) + action = models.CharField( + choices=const.TicketActionChoices.choices, max_length=16, + default=const.TicketActionChoices.apply.value, verbose_name=_("Action") + ) + status = models.CharField( + max_length=16, choices=const.TicketStatusChoices.choices, + default=const.TicketStatusChoices.open.value, verbose_name=_("Status") + ) + # 申请人 + 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='No', 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='No', verbose_name=_("Processor display") + ) + # 受理人列表 + assignees = models.ManyToManyField( + 'users.User', related_name='assigned_tickets', verbose_name=_("Assignees") + ) + assignees_display = models.TextField( + blank=True, default='No', verbose_name=_("Assignees display") + ) + # 评论 + comment = models.TextField(default='', blank=True, verbose_name=_('Comment')) + + class Meta: + ordering = ('-date_created',) + + def __str__(self): + return '{}({})'.format(self.title, self.applicant_display) + + + def has_assignee(self, assignee): + return self.assignees.filter(id=assignee.id).exists() + + # status + @property + def status_closed(self): + return self.status == const.TicketStatusChoices.closed.value + + @property + def status_open(self): + return self.status == const.TicketStatusChoices.open.value + + # action + @property + def is_applied(self): + return self.action == const.TicketActionChoices.apply.value + + @property + def is_approved(self): + return self.action == const.TicketActionChoices.approve.value + + @property + def is_rejected(self): + return self.action == const.TicketActionChoices.reject.value + + @property + def is_closed(self): + return self.action == const.TicketActionChoices.close.value + + @property + def is_processed(self): + return self.is_approved or self.is_rejected or self.is_closed + + # perform action + def close(self, processor): + self.processor = processor + self.action = const.TicketActionChoices.close.value + self.save() + + # tickets + @classmethod + def all(cls): + with tmp_to_root_org(): + return Ticket.objects.all() + + @classmethod + def get_user_related_tickets(cls, user): + queries = None + tickets = cls.all() + if user.is_superuser: + pass + elif user.is_super_auditor: + pass + elif user.is_org_admin: + admin_orgs_id = [ + str(org_id) for org_id in user.admin_orgs.values_list('id', flat=True) + ] + assigned_tickets_id = [ + str(ticket_id) for ticket_id in user.assigned_tickets.values_list('id', flat=True) + ] + queries = Q(applicant=user) + queries |= Q(processor=user) + queries |= Q(org_id__in=admin_orgs_id) + queries |= Q(id__in=assigned_tickets_id) + elif user.is_org_auditor: + audit_orgs_id = [ + str(org_id) for org_id in user.audit_orgs.values_list('id', flat=True) + ] + queries = Q(org_id__in=audit_orgs_id) + elif user.is_common_user: + queries = Q(applicant=user) + else: + tickets = cls.objects.none() + if queries: + tickets = tickets.filter(queries) + return tickets.distinct() + + def save(self, *args, **kwargs): + with tmp_to_org(self.org_id): + # 确保保存的org_id的是自身的值 + return super().save(*args, **kwargs) diff --git a/apps/tickets/permissions.py b/apps/tickets/permissions.py deleted file mode 100644 index 80db4cb94..000000000 --- a/apps/tickets/permissions.py +++ /dev/null @@ -1,9 +0,0 @@ -# -*- coding: utf-8 -*- -# - -from rest_framework.permissions import BasePermission - - -class IsAssignee(BasePermission): - def has_object_permission(self, request, view, obj): - return obj.is_assignee(request.user) diff --git a/apps/tickets/permissions/__init__.py b/apps/tickets/permissions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/tickets/permissions/comment.py b/apps/tickets/permissions/comment.py new file mode 100644 index 000000000..c478c7529 --- /dev/null +++ b/apps/tickets/permissions/comment.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# +from rest_framework import permissions + + +class IsSwagger(permissions.BasePermission): + def has_permission(self, request, view): + return getattr(view, 'swagger_fake_view', False) + + +class IsApplicant(permissions.BasePermission): + def has_permission(self, request, view): + return request.user == view.ticket.applicant + + +class IsAssignee(permissions.BasePermission): + def has_permission(self, request, view): + return view.ticket.has_assignee(request.user) diff --git a/apps/tickets/permissions/ticket.py b/apps/tickets/permissions/ticket.py new file mode 100644 index 000000000..c16db9fe6 --- /dev/null +++ b/apps/tickets/permissions/ticket.py @@ -0,0 +1,12 @@ + +from rest_framework import permissions + + +class IsAssignee(permissions.BasePermission): + def has_object_permission(self, request, view, obj): + return obj.has_assignee(request.user) + + +class NotClosed(permissions.BasePermission): + def has_object_permission(self, request, view, obj): + return not obj.status_closed diff --git a/apps/tickets/serializers/__init__.py b/apps/tickets/serializers/__init__.py index 4eb820587..6b519ef80 100644 --- a/apps/tickets/serializers/__init__.py +++ b/apps/tickets/serializers/__init__.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- # from .ticket import * -from .request_asset_perm import * +from .assignee import * +from .comment import * diff --git a/apps/tickets/serializers/assignee.py b/apps/tickets/serializers/assignee.py new file mode 100644 index 000000000..217c26fa9 --- /dev/null +++ b/apps/tickets/serializers/assignee.py @@ -0,0 +1,9 @@ +from rest_framework import serializers + +__all__ = ['AssigneeSerializer'] + + +class AssigneeSerializer(serializers.Serializer): + id = serializers.UUIDField() + name = serializers.CharField() + username = serializers.CharField() diff --git a/apps/tickets/serializers/comment.py b/apps/tickets/serializers/comment.py new file mode 100644 index 000000000..30ed5144c --- /dev/null +++ b/apps/tickets/serializers/comment.py @@ -0,0 +1,29 @@ +from rest_framework import serializers +from ..models import Comment +from common.fields.serializer import ReadableHiddenField + +__all__ = ['CommentSerializer'] + + +class CurrentTicket(object): + ticket = None + + def set_context(self, serializer_field): + self.ticket = serializer_field.context['ticket'] + + def __call__(self): + return self.ticket + + +class CommentSerializer(serializers.ModelSerializer): + ticket = ReadableHiddenField(default=CurrentTicket()) + user = ReadableHiddenField(default=serializers.CurrentUserDefault()) + + class Meta: + model = Comment + fields = [ + 'id', 'ticket', 'body', 'user', 'user_display', 'date_created', 'date_updated' + ] + read_only_fields = [ + 'user_display', 'date_created', 'date_updated' + ] diff --git a/apps/tickets/serializers/request_asset_perm.py b/apps/tickets/serializers/request_asset_perm.py deleted file mode 100644 index 9bc70b79a..000000000 --- a/apps/tickets/serializers/request_asset_perm.py +++ /dev/null @@ -1,241 +0,0 @@ -from rest_framework import serializers -from django.conf import settings -from django.utils.translation import ugettext_lazy as _ -from django.urls import reverse -from django.db.models import Q - -from common.utils.timezone import dt_parser, dt_formater -from orgs.utils import tmp_to_root_org -from orgs.models import Organization, ROLE as ORG_ROLE -from assets.models import Asset, SystemUser -from users.models.user import User -from perms.serializers import ActionsField -from perms.models import Action -from ..models import Ticket - - -class RequestAssetPermTicketSerializer(serializers.ModelSerializer): - actions = ActionsField(source='meta.actions', choices=Action.DB_CHOICES, - default=Action.CONNECT) - ips = serializers.ListField(child=serializers.IPAddressField(), source='meta.ips', - default=list, label=_('IP group')) - hostname = serializers.CharField(max_length=256, source='meta.hostname', default='', - allow_blank=True, label=_('Hostname')) - system_user = serializers.CharField(max_length=256, source='meta.system_user', default='', - allow_blank=True, label=_('System user')) - date_start = serializers.DateTimeField(source='meta.date_start', allow_null=True, - required=False, label=_('Date start')) - date_expired = serializers.DateTimeField(source='meta.date_expired', allow_null=True, - required=False, label=_('Date expired')) - confirmed_assets = serializers.ListField(child=serializers.UUIDField(), - source='meta.confirmed_assets', - default=list, required=False, - label=_('Confirmed assets')) - confirmed_system_users = serializers.ListField(child=serializers.UUIDField(), - source='meta.confirmed_system_users', - default=list, required=False, - label=_('Confirmed system user')) - assets_waitlist_url = serializers.SerializerMethodField() - system_users_waitlist_url = serializers.SerializerMethodField() - - class Meta: - model = Ticket - mini_fields = ['id', 'title'] - small_fields = [ - 'status', 'action', 'date_created', 'date_updated', 'system_users_waitlist_url', - 'type', 'type_display', 'action_display', 'ips', 'confirmed_assets', - 'date_start', 'date_expired', 'confirmed_system_users', 'hostname', - 'assets_waitlist_url', 'system_user', 'org_id', 'actions', 'comment' - ] - m2m_fields = [ - 'user', 'user_display', 'assignees', 'assignees_display', - 'assignee', 'assignee_display' - ] - - fields = mini_fields + small_fields + m2m_fields - read_only_fields = [ - 'user_display', 'assignees_display', 'type', 'user', 'status', - 'date_created', 'date_updated', 'action', 'id', 'assignee', - 'assignee_display', - ] - extra_kwargs = { - 'status': {'label': _('Status')}, - 'action': {'label': _('Action')}, - 'user_display': {'label': _('User')}, - 'org_id': {'required': True} - } - - def validate(self, attrs): - org_id = attrs.get('org_id') - assignees = attrs.get('assignees') - - instance = self.instance - if instance is not None: - if org_id and not assignees: - assignees = list(instance.assignees.all()) - elif assignees and not org_id: - org_id = instance.org_id - elif assignees and org_id: - pass - else: - return attrs - - user = self.context['request'].user - org = Organization.get_instance(org_id) - if org is None: - raise serializers.ValidationError(_('Invalid `org_id`')) - - q = Q(role=User.ROLE.ADMIN) - if not org.is_default(): - q |= Q(m2m_org_members__role=ORG_ROLE.ADMIN, orgs__id=org_id, orgs__members=user) - - q &= Q(id__in=[assignee.id for assignee in assignees]) - count = User.objects.filter(q).distinct().count() - if count != len(assignees): - raise serializers.ValidationError(_('Field `assignees` must be organization admin or superuser')) - return attrs - - def get_system_users_waitlist_url(self, instance: Ticket): - if not self._is_assignee(instance): - return None - return reverse('api-assets:system-user-list') - - def get_assets_waitlist_url(self, instance: Ticket): - if not self._is_assignee(instance): - return None - - asset_api = reverse('api-assets:asset-list') - query = '' - - meta = instance.meta - hostname = meta.get('hostname') - if hostname: - query = '?search=%s' % hostname - - return asset_api + query - - def _recommend_assets(self, data, instance): - confirmed_assets = data.get('confirmed_assets') - if not confirmed_assets and self._is_assignee(instance): - ips = data.get('ips') - hostname = data.get('hostname') - limit = 5 - - q = Q(id=None) - if ips: - limit = len(ips) + 2 - q |= Q(ip__in=ips) - if hostname: - q |= Q(hostname__icontains=hostname) - - recomand_assets_id = Asset.objects.filter(q)[:limit].values_list('id', flat=True) - data['confirmed_assets'] = [str(i) for i in recomand_assets_id] - - def _recommend_system_users(self, data, instance): - confirmed_system_users = data.get('confirmed_system_users') - system_user = data.get('system_user') - - if all((not confirmed_system_users, self._is_assignee(instance), system_user)): - recomand_system_users_id = SystemUser.objects.filter( - name__icontains=system_user - )[:3].values_list('id', flat=True) - data['confirmed_system_users'] = [str(i) for i in recomand_system_users_id] - - def to_representation(self, instance): - data = super().to_representation(instance) - self._recommend_assets(data, instance) - self._recommend_system_users(data, instance) - return data - - def _create_body(self, validated_data): - meta = validated_data['meta'] - type = Ticket.TYPE.get(validated_data.get('type', '')) - date_start = dt_parser(meta.get('date_start')).strftime(settings.DATETIME_DISPLAY_FORMAT) - date_expired = dt_parser(meta.get('date_expired')).strftime(settings.DATETIME_DISPLAY_FORMAT) - - validated_data['body'] = _(''' - Type: {type}
- User: {username}
- Ip group: {ips}
- Hostname: {hostname}
- System user: {system_user}
- Date start: {date_start}
- Date expired: {date_expired}
- ''').format( - type=type, - username=validated_data.get('user', ''), - ips=', '.join(meta.get('ips', [])), - hostname=meta.get('hostname', ''), - system_user=meta.get('system_user', ''), - date_start=date_start, - date_expired=date_expired - ) - - def create(self, validated_data): - # `type` 与 `user` 用户不可提交, - validated_data['type'] = self.Meta.model.TYPE.REQUEST_ASSET_PERM - validated_data['user'] = self.context['request'].user - # `confirmed` 相关字段只能审批人修改,所以创建时直接清理掉 - self._pop_confirmed_fields() - self._create_body(validated_data) - return super().create(validated_data) - - def save(self, **kwargs): - """ - 做了一些数据转换 - """ - meta = self.validated_data.get('meta', {}) - - org_id = self.validated_data.get('org_id') - if org_id is not None and org_id == Organization.DEFAULT_ID: - self.validated_data['org_id'] = '' - - # 时间的转换,好烦😭,可能有更好的办法吧 - date_start = meta.get('date_start') - if date_start: - meta['date_start'] = dt_formater(date_start) - - date_expired = meta.get('date_expired') - if date_expired: - meta['date_expired'] = dt_formater(date_expired) - - # UUID 的转换 - confirmed_system_users = meta.get('confirmed_system_users') - if confirmed_system_users: - meta['confirmed_system_users'] = [str(system_user) for system_user in confirmed_system_users] - - confirmed_assets = meta.get('confirmed_assets') - if confirmed_assets: - meta['confirmed_assets'] = [str(asset) for asset in confirmed_assets] - - with tmp_to_root_org(): - return super().save(**kwargs) - - def update(self, instance, validated_data): - new_meta = validated_data['meta'] - if not self._is_assignee(instance): - self._pop_confirmed_fields() - - # Json 字段保存的坑😭 - old_meta = instance.meta - meta = {} - meta.update(old_meta) - meta.update(new_meta) - validated_data['meta'] = meta - - return super().update(instance, validated_data) - - def _pop_confirmed_fields(self): - meta = self.validated_data['meta'] - meta.pop('confirmed_assets', None) - meta.pop('confirmed_system_users', None) - - def _is_assignee(self, obj: Ticket): - user = self.context['request'].user - return obj.is_assignee(user) - - -class AssigneeSerializer(serializers.Serializer): - id = serializers.UUIDField() - name = serializers.CharField() - username = serializers.CharField() diff --git a/apps/tickets/serializers/ticket.py b/apps/tickets/serializers/ticket.py deleted file mode 100644 index 34724be3a..000000000 --- a/apps/tickets/serializers/ticket.py +++ /dev/null @@ -1,95 +0,0 @@ -# -*- coding: utf-8 -*- -# -from django.utils.translation import ugettext_lazy as _ -from rest_framework import serializers - -from ..exceptions import ( - TicketClosed, OnlyTicketAssigneeCanOperate, - TicketCanNotOperate -) -from ..models import Ticket, Comment - -__all__ = ['TicketSerializer', 'CommentSerializer'] - - -class TicketSerializer(serializers.ModelSerializer): - class Meta: - model = Ticket - fields = [ - 'id', 'user', 'user_display', 'title', 'body', - 'assignees', 'assignees_display', 'assignee', 'assignee_display', - 'status', 'action', 'date_created', 'date_updated', - 'type', 'type_display', 'action_display', - ] - read_only_fields = [ - 'user_display', 'assignees_display', - 'date_created', 'date_updated', - ] - extra_kwargs = { - 'status': {'label': _('Status')}, - 'action': {'label': _('Action')}, - 'user_display': {'label': _('User')} - } - - def create(self, validated_data): - validated_data.pop('action') - return super().create(validated_data) - - def update(self, instance, validated_data): - action = validated_data.get('action') - user = self.context['request'].user - - if instance.type not in (Ticket.TYPE.GENERAL, - Ticket.TYPE.LOGIN_CONFIRM): - # 暂时的兼容操作吧,后期重构工单 - raise TicketCanNotOperate - - if instance.status == instance.STATUS.CLOSED: - raise TicketClosed - - if action: - if user not in instance.assignees.all(): - raise OnlyTicketAssigneeCanOperate - - # 有 `action` 时忽略 `status` - validated_data.pop('status', None) - - instance = super().update(instance, validated_data) - if not instance.status == instance.STATUS.CLOSED and action: - instance.perform_action(action, user) - else: - status = validated_data.get('status') - instance = super().update(instance, validated_data) - if status: - instance.perform_status(status, user) - - return instance - - -class CurrentTicket(object): - ticket = None - - def set_context(self, serializer_field): - self.ticket = serializer_field.context['ticket'] - - def __call__(self): - return self.ticket - - -class CommentSerializer(serializers.ModelSerializer): - user = serializers.HiddenField( - default=serializers.CurrentUserDefault(), - ) - ticket = serializers.HiddenField( - default=CurrentTicket() - ) - - class Meta: - model = Comment - fields = [ - 'id', 'ticket', 'body', 'user', 'user_display', - 'date_created', 'date_updated' - ] - read_only_fields = [ - 'user_display', 'date_created', 'date_updated' - ] diff --git a/apps/tickets/serializers/ticket/__init__.py b/apps/tickets/serializers/ticket/__init__.py new file mode 100644 index 000000000..bb2ee74d6 --- /dev/null +++ b/apps/tickets/serializers/ticket/__init__.py @@ -0,0 +1,2 @@ +from .ticket import * +from .meta import * diff --git a/apps/tickets/serializers/ticket/meta/__init__.py b/apps/tickets/serializers/ticket/meta/__init__.py new file mode 100644 index 000000000..5b44f1c85 --- /dev/null +++ b/apps/tickets/serializers/ticket/meta/__init__.py @@ -0,0 +1,3 @@ +from .apply_asset import * +from .apply_application import * +from .login_confirm import * diff --git a/apps/tickets/serializers/ticket/meta/apply_application.py b/apps/tickets/serializers/ticket/meta/apply_application.py new file mode 100644 index 000000000..1391d321c --- /dev/null +++ b/apps/tickets/serializers/ticket/meta/apply_application.py @@ -0,0 +1,93 @@ +from rest_framework import serializers +from django.utils.translation import ugettext_lazy as _ + +from applications.models import Category, Application +from assets.models import SystemUser +from .base import BaseTicketMetaSerializer, BaseTicketMetaApproveSerializerMixin + +__all__ = [ + 'TicketMetaApplyApplicationApplySerializer', + 'TicketMetaApplyApplicationApproveSerializer', +] + + +class TicketMetaApplyApplicationSerializer(BaseTicketMetaSerializer): + # 申请信息 + apply_category = serializers.ChoiceField( + choices=Category.choices, required=True, label=_('Category') + ) + apply_type = serializers.ChoiceField( + choices=Category.get_all_type_choices(), required=True, label=_('Type') + ) + apply_application_group = serializers.ListField( + child=serializers.CharField(), default=list, label=_('Application group') + ) + apply_system_user_group = serializers.ListField( + child=serializers.CharField(), default=list, label=_('System user group') + ) + apply_date_start = serializers.DateTimeField( + required=True, label=_('Date start') + ) + apply_date_expired = serializers.DateTimeField( + required=True, label=_('Date expired') + ) + # 审批信息 + approve_applications = serializers.ListField( + child=serializers.UUIDField(), required=True, + label=_('Approve applications') + ) + approve_system_users = serializers.ListField( + child=serializers.UUIDField(), required=True, + label=_('Approve system users') + ) + approve_date_start = serializers.DateTimeField( + required=True, label=_('Date start') + ) + approve_date_expired = serializers.DateTimeField( + required=True, label=_('Date expired') + ) + + +class TicketMetaApplyApplicationApplySerializer(TicketMetaApplyApplicationSerializer): + + class Meta: + fields = [ + 'apply_category', 'apply_type', + 'apply_application_group', 'apply_system_user_group', + 'apply_date_start', 'apply_date_expired' + ] + + def validate_apply_type(self, tp): + category = self.root.initial_data['meta'].get('apply_category') + if not category: + return tp + valid_type_types = list((dict(Category.get_type_choices(category)).keys())) + if tp in valid_type_types: + return tp + error = _('Type `{}` is not a valid choice `({}){}`'.format(tp, category, valid_type_types)) + raise serializers.ValidationError(error) + + +class TicketMetaApplyApplicationApproveSerializer(BaseTicketMetaApproveSerializerMixin, + TicketMetaApplyApplicationSerializer): + + class Meta: + fields = { + 'approve_applications', 'approve_system_users', + 'approve_date_start', 'approve_date_expired' + } + + def validate_approve_applications(self, approve_applications): + application_type = self.root.instance.meta['apply_type'] + queries = {'type': application_type} + applications_id = self.filter_approve_resources( + resource_model=Application, resources_id=approve_applications, queries=queries + ) + return applications_id + + def validate_approve_system_users(self, approve_system_users): + application_type = self.root.instance.meta['apply_type'] + protocol = SystemUser.get_protocol_by_application_type(application_type) + queries = {'protocol': protocol} + system_users_id = self.filter_approve_system_users(approve_system_users, queries) + return system_users_id diff --git a/apps/tickets/serializers/ticket/meta/apply_asset.py b/apps/tickets/serializers/ticket/meta/apply_asset.py new file mode 100644 index 000000000..eba34b9f8 --- /dev/null +++ b/apps/tickets/serializers/ticket/meta/apply_asset.py @@ -0,0 +1,80 @@ +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers +from perms.serializers import ActionsField +from perms.models import Action +from assets.models import Asset, SystemUser +from .base import BaseTicketMetaSerializer, BaseTicketMetaApproveSerializerMixin + + +__all__ = [ + 'TicketMetaApplyAssetApplySerializer', + 'TicketMetaApplyAssetApproveSerializer', +] + + +class TicketMetaApplyAssetSerializer(BaseTicketMetaSerializer): + # 申请信息 + apply_ip_group = serializers.ListField( + child=serializers.IPAddressField(), default=list, label=_('IP group') + ) + apply_hostname_group = serializers.ListField( + child=serializers.CharField(), default=list, label=_('Hostname group') + ) + apply_system_user_group = serializers.ListField( + child=serializers.CharField(), default=list, label=_('System user group') + ) + apply_actions = ActionsField( + choices=Action.DB_CHOICES, default=Action.ALL + ) + apply_date_start = serializers.DateTimeField( + required=True, label=_('Date start') + ) + apply_date_expired = serializers.DateTimeField( + required=True, label=_('Date expired') + ) + # 审批信息 + approve_assets = serializers.ListField( + required=True, child=serializers.UUIDField(), label=_('Approve assets') + ) + approve_system_users = serializers.ListField( + required=True, child=serializers.UUIDField(), label=_('Approve system users') + ) + approve_actions = ActionsField( + required=False, choices=Action.DB_CHOICES, default=Action.ALL + ) + approve_date_start = serializers.DateTimeField( + required=True, label=_('Date start') + ) + approve_date_expired = serializers.DateTimeField( + required=True, label=_('Date expired') + ) + + +class TicketMetaApplyAssetApplySerializer(TicketMetaApplyAssetSerializer): + + class Meta: + fields = [ + 'apply_ip_group', 'apply_hostname_group', + 'apply_system_user_group', 'apply_actions', + 'apply_date_start', 'apply_date_expired' + ] + + +class TicketMetaApplyAssetApproveSerializer(BaseTicketMetaApproveSerializerMixin, + TicketMetaApplyAssetSerializer): + + class Meta: + fields = [ + 'approve_assets', 'approve_system_users', + 'approve_actions', 'approve_date_start', + 'approve_date_expired' + ] + + def validate_approve_assets(self, approve_assets): + assets_id = self.filter_approve_resources(resource_model=Asset, resources_id=approve_assets) + return assets_id + + def validate_approve_system_users(self, approve_system_users): + queries = {'protocol__in': SystemUser.ASSET_CATEGORY_PROTOCOLS} + system_users_id = self.filter_approve_system_users(approve_system_users, queries) + return system_users_id diff --git a/apps/tickets/serializers/ticket/meta/base.py b/apps/tickets/serializers/ticket/meta/base.py new file mode 100644 index 000000000..dc6f3a913 --- /dev/null +++ b/apps/tickets/serializers/ticket/meta/base.py @@ -0,0 +1,58 @@ +from collections import OrderedDict +from rest_framework import serializers +from django.utils.translation import ugettext_lazy as _ + +from orgs.utils import tmp_to_org +from assets.models import SystemUser + + +class BaseTicketMetaSerializer(serializers.Serializer): + + def get_fields(self): + fields = super().get_fields() + required_fields = self.Meta.fields + if required_fields == '__all__': + return fields + + fields = OrderedDict({ + field_name: fields.pop(field_name) for field_name in set(required_fields) + if field_name in fields.keys() + }) + return fields + + class Meta: + fields = '__all__' + + +class BaseTicketMetaApproveSerializerMixin: + + def _filter_approve_resources_by_org(self, model, resources_id): + with tmp_to_org(self.root.instance.org_id): + org_resources = model.objects.filter(id__in=resources_id) + if not org_resources: + error = _('None of the approved `{}` belong to Organization `{}`' + ''.format(model.__name__, self.root.instance.org_name)) + raise serializers.ValidationError(error) + return org_resources + + @staticmethod + def _filter_approve_resources_by_queries(model, resources, queries=None): + if queries: + resources = resources.filter(**queries) + if not resources: + error = _('None of the approved `{}` does not comply with the filtering rules `{}`' + ''.format(model.__name__, queries)) + raise serializers.ValidationError(error) + return resources + + def filter_approve_resources(self, resource_model, resources_id, queries=None): + resources = self._filter_approve_resources_by_org(resource_model, resources_id) + resources = self._filter_approve_resources_by_queries(resource_model, resources, queries) + resources_id = list(resources.values_list('id', flat=True)) + return resources_id + + def filter_approve_system_users(self, system_users_id, queries=None): + system_users_id = self.filter_approve_resources( + resource_model=SystemUser, resources_id=system_users_id, queries=queries + ) + return system_users_id diff --git a/apps/tickets/serializers/ticket/meta/login_confirm.py b/apps/tickets/serializers/ticket/meta/login_confirm.py new file mode 100644 index 000000000..205112b81 --- /dev/null +++ b/apps/tickets/serializers/ticket/meta/login_confirm.py @@ -0,0 +1,24 @@ + +from rest_framework import serializers +from django.utils.translation import ugettext_lazy as _ +from .base import BaseTicketMetaSerializer + +__all__ = [ + 'TicketMetaLoginConfirmApplySerializer', +] + + +class TicketMetaLoginConfirmSerializer(BaseTicketMetaSerializer): + apply_login_ip = serializers.IPAddressField( + required=True, label=_('Login ip') + ) + apply_login_city = serializers.CharField( + required=True, max_length=64, label=_('Login city') + ) + apply_login_datetime = serializers.DateTimeField( + required=True, label=_('Login datetime') + ) + + +class TicketMetaLoginConfirmApplySerializer(TicketMetaLoginConfirmSerializer): + pass diff --git a/apps/tickets/serializers/ticket/ticket.py b/apps/tickets/serializers/ticket/ticket.py new file mode 100644 index 000000000..cb8fc385a --- /dev/null +++ b/apps/tickets/serializers/ticket/ticket.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- +# +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers +from common.fields.serializer import ReadableHiddenField +from orgs.utils import get_org_by_id +from orgs.mixins.serializers import OrgResourceModelSerializerMixin +from users.models import User +from tickets import const +from tickets.models import Ticket + +__all__ = [ + 'TicketSerializer', 'TicketDisplaySerializer', + 'TicketApplySerializer', 'TicketApproveSerializer', + 'TicketRejectSerializer', 'TicketCloseSerializer', +] + + +class TicketSerializer(OrgResourceModelSerializerMixin): + type_display = serializers.ReadOnlyField(source='get_type_display', label=_('Type')) + status_display = serializers.ReadOnlyField(source='get_status_display', label=_('Status')) + action_display = serializers.ReadOnlyField(source='get_action_display', label=_('Action')) + + class Meta: + model = Ticket + fields = [ + 'id', 'title', 'type', 'type_display', + 'meta', 'action', 'action_display', 'status', 'status_display', + 'applicant', 'applicant_display', 'processor', 'processor_display', + 'assignees', 'assignees_display', + 'date_created', 'date_updated', + 'org_id', 'org_name', + 'body' + ] + + +class TicketDisplaySerializer(TicketSerializer): + + class Meta(TicketSerializer.Meta): + read_only_fields = TicketSerializer.Meta.fields + + +class TicketActionSerializer(TicketSerializer): + action = ReadableHiddenField(default=const.TicketActionChoices.apply.value) + + class Meta(TicketSerializer.Meta): + required_fields = ['action'] + read_only_fields = list(set(TicketDisplaySerializer.Meta.fields) - set(required_fields)) + + +class TicketApplySerializer(TicketActionSerializer): + applicant = ReadableHiddenField(default=serializers.CurrentUserDefault()) + org_id = serializers.CharField( + max_length=36, allow_blank=True, required=True, label=_("Organization") + ) + + class Meta(TicketActionSerializer.Meta): + required_fields = TicketActionSerializer.Meta.required_fields + [ + 'id', 'title', 'type', 'applicant', 'meta', 'assignees', 'org_id' + ] + read_only_fields = list(set(TicketDisplaySerializer.Meta.fields) - set(required_fields)) + extra_kwargs = { + '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 = get_org_by_id(org_id) + if not org: + error = _('The organization `{}` does not exist'.format(org_id)) + 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 = get_org_by_id(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)) + raise serializers.ValidationError(error) + return valid_assignees + + @staticmethod + def validate_action(action): + return const.TicketActionChoices.apply.value + + +class TicketProcessSerializer(TicketActionSerializer): + processor = ReadableHiddenField(default=serializers.CurrentUserDefault()) + + class Meta(TicketActionSerializer.Meta): + required_fields = TicketActionSerializer.Meta.required_fields + ['processor'] + read_only_fields = list(set(TicketDisplaySerializer.Meta.fields) - set(required_fields)) + + +class TicketApproveSerializer(TicketProcessSerializer): + + class Meta(TicketProcessSerializer.Meta): + required_fields = TicketProcessSerializer.Meta.required_fields + ['meta'] + read_only_fields = list(set(TicketDisplaySerializer.Meta.fields) - set(required_fields)) + extra_kwargs = { + 'meta': {'read_only': True} + } + + def validate_meta(self, meta): + meta.update(self.instance.meta) + return meta + + @staticmethod + def validate_action(action): + return const.TicketActionChoices.approve.value + + +class TicketRejectSerializer(TicketProcessSerializer): + + @staticmethod + def validate_action(action): + return const.TicketActionChoices.reject.value + + +class TicketCloseSerializer(TicketProcessSerializer): + + @staticmethod + def validate_action(action): + return const.TicketActionChoices.close.value + + diff --git a/apps/tickets/signals_handler.py b/apps/tickets/signals_handler.py index 811f6dabf..0a5889126 100644 --- a/apps/tickets/signals_handler.py +++ b/apps/tickets/signals_handler.py @@ -6,44 +6,55 @@ from django.db.models.signals import m2m_changed, post_save, pre_save from common.utils import get_logger from .models import Ticket, Comment from .utils import ( - send_new_ticket_mail_to_assignees, - send_ticket_action_mail_to_user + send_ticket_applied_mail_to_assignees, + send_ticket_processed_mail_to_applicant ) +from . import const logger = get_logger(__name__) -@receiver(m2m_changed, sender=Ticket.assignees.through) -def on_ticket_assignees_set(sender, instance=None, action=None, - reverse=False, model=None, - pk_set=None, **kwargs): - if action == 'post_add': - logger.debug('New ticket create, send mail: {}'.format(instance.id)) - assignees = model.objects.filter(pk__in=pk_set) - send_new_ticket_mail_to_assignees(instance, assignees) - if action.startswith('post') and not reverse: - instance.assignees_display = ', '.join([ - str(u) for u in instance.assignees.all() - ]) - instance.save() +@receiver(pre_save, sender=Ticket) +def on_ticket_pre_save(sender, instance=None, **kwargs): + if instance.is_applied: + instance.applicant_display = str(instance.applicant) + if instance.is_processed: + instance.processor_display = str(instance.processor) + instance.status = const.TicketStatusChoices.closed.value @receiver(post_save, sender=Ticket) -def on_ticket_status_change(sender, instance=None, created=False, **kwargs): - if created or instance.status == "open": +def on_ticket_processed(sender, instance=None, created=False, **kwargs): + if not instance.is_processed: return - logger.debug('Ticket changed, send mail: {}'.format(instance.id)) - send_ticket_action_mail_to_user(instance) + logger.debug('Ticket is processed, send mail: {}'.format(instance.id)) + instance.create_action_comment() + if instance.is_approved: + instance.create_permission() + instance.create_approved_comment() + send_ticket_processed_mail_to_applicant(instance) -@receiver(pre_save, sender=Ticket) -def on_ticket_create(sender, instance=None, **kwargs): - instance.user_display = str(instance.user) - if instance.assignee: - instance.assignee_display = str(instance.assignee) +@receiver(m2m_changed, sender=Ticket.assignees.through) +def on_ticket_assignees_changed(sender, instance=None, action=None, reverse=False, model=None, pk_set=None, **kwargs): + if reverse: + return + if action != 'post_add': + return + ticket = instance + assignees_display = [str(assignee) for assignee in ticket.assignees.all()] + logger.debug( + 'Receives ticket and assignees changed signal, ticket: {}, assignees: {}' + ''.format(ticket.title, assignees_display) + ) + ticket.assignees_display = ', '.join(assignees_display) + ticket.save() + logger.debug('Send applied email to assignees: {}'.format(assignees_display)) + assignees = model.objects.filter(pk__in=pk_set) + send_ticket_applied_mail_to_assignees(ticket, assignees) @receiver(pre_save, sender=Comment) -def on_comment_create(sender, instance=None, **kwargs): +def on_comment_create(sender, instance=None, created=False, **kwargs): instance.user_display = str(instance.user) diff --git a/apps/tickets/tests.py b/apps/tickets/tests.py index ee696d223..e69de29bb 100644 --- a/apps/tickets/tests.py +++ b/apps/tickets/tests.py @@ -1,89 +0,0 @@ -import datetime - -from common.utils.timezone import now -from django.urls import reverse -from rest_framework.test import APITestCase -from rest_framework import status - -from orgs.models import Organization, OrganizationMember, ROLE as ORG_ROLE -from orgs.utils import set_current_org -from users.models.user import User -from assets.models import Asset, AdminUser, SystemUser - - -class TicketTest(APITestCase): - def setUp(self): - Organization.objects.bulk_create([ - Organization(name='org-01'), - Organization(name='org-02'), - Organization(name='org-03'), - ]) - org_01, org_02, org_03 = Organization.objects.all() - self.org_01, self.org_02, self.org_03 = org_01, org_02, org_03 - - set_current_org(org_01) - - AdminUser.objects.bulk_create([ - AdminUser(name='au-01', username='au-01'), - AdminUser(name='au-02', username='au-02'), - AdminUser(name='au-03', username='au-03'), - ]) - - SystemUser.objects.bulk_create([ - SystemUser(name='su-01', username='su-01'), - SystemUser(name='su-02', username='su-02'), - SystemUser(name='su-03', username='su-03'), - ]) - - admin_users = AdminUser.objects.all() - Asset.objects.bulk_create([ - Asset(hostname='asset-01', ip='192.168.1.1', public_ip='192.168.1.1', admin_user=admin_users[0]), - Asset(hostname='asset-02', ip='192.168.1.2', public_ip='192.168.1.2', admin_user=admin_users[0]), - Asset(hostname='asset-03', ip='192.168.1.3', public_ip='192.168.1.3', admin_user=admin_users[0]), - ]) - - new_user = User.objects.create - new_org_member = OrganizationMember.objects.create - - u = new_user(name='user-01', username='user-01', email='user-01@jms.com') - new_org_member(org=org_01, user=u, role=ORG_ROLE.USER) - new_org_member(org=org_02, user=u, role=ORG_ROLE.USER) - self.user_01 = u - - u = new_user(name='org-admin-01', username='org-admin-01', email='org-admin-01@jms.com') - new_org_member(org=org_01, user=u, role=ORG_ROLE.ADMIN) - self.org_admin_01 = u - - u = new_user(name='org-admin-02', username='org-admin-02', email='org-admin-02@jms.com') - new_org_member(org=org_02, user=u, role=ORG_ROLE.ADMIN) - self.org_admin_02 = u - - def test_create_request_asset_perm(self): - url = reverse('api-tickets:ticket-request-asset-perm') - ticket_url = reverse('api-tickets:ticket') - - self.client.force_login(self.user_01) - - date_start = now() - date_expired = date_start + datetime.timedelta(days=7) - - data = { - "title": "request-01", - "ips": [ - "192.168.1.1" - ], - "date_start": date_start, - "date_expired": date_expired, - "hostname": "", - "system_user": "", - "org_id": self.org_01.id, - "assignees": [ - str(self.org_admin_01.id), - str(self.org_admin_02.id), - ] - } - - self.client.post(data) - - self.client.force_login(self.org_admin_01) - res = self.client.get(ticket_url, params={'assgin': 1}) diff --git a/apps/tickets/urls/api_urls.py b/apps/tickets/urls/api_urls.py index 36700db92..93286f645 100644 --- a/apps/tickets/urls/api_urls.py +++ b/apps/tickets/urls/api_urls.py @@ -7,13 +7,9 @@ from .. import api app_name = 'tickets' router = BulkRouter() -router.register('tickets/request-asset-perm/assignees', api.AssigneeViewSet, 'ticket-request-asset-perm-assignee') -router.register('tickets/request-asset-perm', api.RequestAssetPermTicketViewSet, 'ticket-request-asset-perm') router.register('tickets', api.TicketViewSet, 'ticket') -router.register('tickets/(?P[0-9a-zA-Z\-]{36})/comments', api.TicketCommentViewSet, 'ticket-comment') - - -urlpatterns = [ -] +router.register('assignees', api.AssigneeViewSet, 'assignee') +router.register('comments', api.CommentViewSet, 'comment') +urlpatterns = [] urlpatterns += router.urls diff --git a/apps/tickets/utils.py b/apps/tickets/utils.py index f3d6ee91e..ebd4c22b8 100644 --- a/apps/tickets/utils.py +++ b/apps/tickets/utils.py @@ -4,55 +4,67 @@ from urllib.parse import urljoin from django.conf import settings from django.utils.translation import ugettext as _ -from common.const.front_urls import TICKET_DETAIL from common.utils import get_logger from common.tasks import send_mail_async +from . import const -logger = get_logger(__name__) -from tickets.models import Ticket +logger = get_logger(__file__) -def send_new_ticket_mail_to_assignees(ticket: Ticket, assignees): - recipient_list = [user.email for user in assignees] - user = ticket.user - if not recipient_list: - logger.error("Ticket not has assignees: {}".format(ticket.id)) +def send_ticket_applied_mail_to_assignees(ticket, assignees): + if not assignees: + logger.debug("Not found assignees, ticket: {}({}), assignees: {}".format( + ticket, str(ticket.id), assignees) + ) return - subject = '{}: {}'.format(_("New ticket"), ticket.title) - # 这里要设置前端地址,因为要直接跳转到页面 - detail_url = urljoin(settings.SITE_URL, TICKET_DETAIL.format(id=ticket.id)) - message = _(""" -
+ subject = _('New Ticket: {} ({})'.format(ticket.title, ticket.get_type_display())) + ticket_detail_url = urljoin( + settings.SITE_URL, const.TICKET_DETAIL_URL.format(id=str(ticket.id)) + ) + message = _( + """

Your has a new ticket

- {body} + Ticket:
- click here to review + {body} +
+ click here to review
- """).format(body=ticket.body, user=user, url=detail_url) + """.format( + body=ticket.body.replace('\n', '
'), + ticket_detail_url=ticket_detail_url + ) + ) + if settings.DEBUG: + logger.debug(message) + recipient_list = [assignee.email for assignee in assignees] send_mail_async.delay(subject, message, recipient_list, html_message=message) -def send_ticket_action_mail_to_user(ticket): - if not ticket.user: - logger.error("Ticket not has user: {}".format(ticket.id)) +def send_ticket_processed_mail_to_applicant(ticket): + if not ticket.applicant: + logger.error("Not found applicant: {}({})".format(ticket.title, ticket.id)) return - user = ticket.user - recipient_list = [user.email] - subject = '{}: {}'.format(_("Ticket has been reply"), ticket.title) - message = _(""" + subject = _('Ticket has processed: {} ({})').format(ticket.title, ticket.get_type_display()) + message = _( + """
-

Your ticket has been replay

+

Your ticket has been processed

- Title: {ticket.title} + Ticket:
- Assignee: {ticket.assignee_display} -
- Status: {ticket.status_display} + {body}
- """).format(ticket=ticket) + """.format( + body=ticket.body.replace('\n', '
'), + ) + ) + if settings.DEBUG: + logger.debug(message) + 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 2dcbd452d..20701acac 100644 --- a/apps/users/models/user.py +++ b/apps/users/models/user.py @@ -329,6 +329,28 @@ class RoleMixin: return OrganizationMember.objects.remove_users(current_org, [self]) + @classmethod + def get_super_admins(cls): + return cls.objects.filter(role=cls.ROLE.ADMIN) + + @classmethod + def get_org_admins(cls, org=None): + from orgs.models import Organization + if not isinstance(org, Organization): + org = current_org + org_admins = org.admins + return org_admins + + @classmethod + def get_super_and_org_admins(cls, org=None): + super_admins = cls.get_super_admins() + super_admins_id = list(super_admins.values_list('id', flat=True)) + org_admins = cls.get_org_admins(org) + org_admins_id = list(org_admins.values_list('id', flat=True)) + admins_id = set(org_admins_id + super_admins_id) + admins = User.objects.filter(id__in=admins_id) + return admins + class TokenMixin: CACHE_KEY_USER_RESET_PASSWORD_PREFIX = "_KEY_USER_RESET_PASSWORD_{}"