reactor&feat: 重构工单模块 & 支持申请应用工单 (#5352)

* reactor: 修改工单Model,添加工单迁移文件

* reactor: 修改工单Model,添加工单迁移文件

* reactor: 重构工单模块

* reactor: 重构工单模块2

* reactor: 重构工单模块3

* reactor: 重构工单模块4

* reactor: 重构工单模块5

* reactor: 重构工单模块6

* reactor: 重构工单模块7

* reactor: 重构工单模块8

* reactor: 重构工单模块9

* reactor: 重构工单模块10

* reactor: 重构工单模块11

* reactor: 重构工单模块12

* reactor: 重构工单模块13

* reactor: 重构工单模块14

* reactor: 重构工单模块15

* reactor: 重构工单模块16

* reactor: 重构工单模块17

* reactor: 重构工单模块18

* reactor: 重构工单模块19

* reactor: 重构工单模块20

* reactor: 重构工单模块21

* reactor: 重构工单模块22

* reactor: 重构工单模块23

* reactor: 重构工单模块24

* reactor: 重构工单模块25

* reactor: 重构工单模块26

* reactor: 重构工单模块27

* reactor: 重构工单模块28

* reactor: 重构工单模块29

* reactor: 重构工单模块30

* reactor: 重构工单模块31

* reactor: 重构工单模块32

* reactor: 重构工单模块33

* reactor: 重构工单模块34

* reactor: 重构工单模块35

* reactor: 重构工单模块36

* reactor: 重构工单模块37

* reactor: 重构工单模块38

* reactor: 重构工单模块39
pull/5364/head
Jiangjie.Bai 2020-12-30 00:19:59 +08:00 committed by GitHub
parent 9d4f1a01fd
commit 3b056ff953
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
54 changed files with 1585 additions and 948 deletions

View File

@ -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')]

View File

@ -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)

View File

@ -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()
)

View File

@ -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}<br>"
"IP: {ip}<br>"
"{city_key}: {city}<br>"
"{date_key}: {date}<br>").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):

View File

@ -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 <b>{}</b> confirm, You also can copy link to her/him <br/>
Don't close this page""").format(ticket.assignees_display)
else:

View File

@ -1,2 +0,0 @@
TICKET_DETAIL = '/ui/#/tickets/tickets/{id}'

View File

@ -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:

View File

@ -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):

View File

@ -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:

View File

@ -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):

View File

@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
#
from .ticket import *
from .request_asset_perm import *
from .assignee import *
from .comment import *

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
from .ticket import *

View File

@ -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

View File

@ -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)

27
apps/tickets/const.py Normal file
View File

@ -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")

View File

@ -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'

View File

@ -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'),
),
]

View File

@ -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

View File

@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
#
from .ticket import *
from .comment import *

View File

@ -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', )

View File

@ -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', '<br/>')
@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', )

View File

@ -0,0 +1 @@
from .ticket import *

View File

@ -0,0 +1 @@
from .ticket import TicketModelMixin

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

View File

@ -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)

View File

@ -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

View File

@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
#
from .ticket import *
from .request_asset_perm import *
from .assignee import *
from .comment import *

View File

@ -0,0 +1,9 @@
from rest_framework import serializers
__all__ = ['AssigneeSerializer']
class AssigneeSerializer(serializers.Serializer):
id = serializers.UUIDField()
name = serializers.CharField()
username = serializers.CharField()

View File

@ -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'
]

View File

@ -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}<br>
User: {username}<br>
Ip group: {ips}<br>
Hostname: {hostname}<br>
System user: {system_user}<br>
Date start: {date_start}<br>
Date expired: {date_expired}<br>
''').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()

View File

@ -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'
]

View File

@ -0,0 +1,2 @@
from .ticket import *
from .meta import *

View File

@ -0,0 +1,3 @@
from .apply_asset import *
from .apply_application import *
from .login_confirm import *

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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})

View File

@ -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<ticket_id>[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

View File

@ -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 = _("""
<div>
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 = _(
"""<div>
<p>Your has a new ticket</p>
<div>
{body}
<b>Ticket:</b>
<br/>
<a href={url}>click here to review</a>
{body}
<br/>
<a href={ticket_detail_url}>click here to review</a>
</div>
</div>
""").format(body=ticket.body, user=user, url=detail_url)
""".format(
body=ticket.body.replace('\n', '<br/>'),
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 = _(
"""
<div>
<p>Your ticket has been replay</p>
<p>Your ticket has been processed</p>
<div>
<b>Title:</b> {ticket.title}
<b>Ticket:</b>
<br/>
<b>Assignee:</b> {ticket.assignee_display}
<br/>
<b>Status:</b> {ticket.status_display}
{body}
<br/>
</div>
</div>
""").format(ticket=ticket)
""".format(
body=ticket.body.replace('\n', '<br/>'),
)
)
if settings.DEBUG:
logger.debug(message)
recipient_list = [ticket.applicant.email]
send_mail_async.delay(subject, message, recipient_list, html_message=message)

View File

@ -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_{}"