You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
jumpserver/apps/tickets/models/ticket/general.py

486 lines
17 KiB

# -*- coding: utf-8 -*-
#
import json
from typing import Callable
from django.db import models
from django.db.models import Prefetch, Q
from django.db.models.fields import related
from django.db.utils import IntegrityError
from django.forms import model_to_dict
from django.utils.translation import gettext_lazy as _
from accounts.const import AliasAccount
from common.db.encoder import ModelJSONFieldEncoder
from common.db.models import JMSBaseModel
from common.exceptions import JMSException
from common.utils import reverse, get_logger
from common.utils.lock import DistributedLock
from common.utils.timezone import as_current_tz
from orgs.models import Organization
from orgs.utils import tmp_to_org
from tickets.const import (
TicketType, TicketStatus, TicketState,
TicketLevel, StepState, StepStatus
)
from tickets.errors import AlreadyClosed
from tickets.handlers import get_ticket_handler
from ..flow import TicketFlow
logger = get_logger(__file__)
__all__ = [
'Ticket', 'TicketStep', 'TicketAssignee',
'SuperTicket', 'SubTicketManager'
]
class TicketStep(JMSBaseModel):
ticket = models.ForeignKey(
'Ticket', related_name='ticket_steps',
on_delete=models.CASCADE, verbose_name='Ticket'
)
level = models.SmallIntegerField(
default=TicketLevel.one, choices=TicketLevel.choices,
verbose_name=_('Approve level')
)
state = models.CharField(
max_length=64, choices=StepState.choices,
default=StepState.pending, verbose_name=_("State")
)
status = models.CharField(
max_length=16, choices=StepStatus.choices,
default=StepStatus.pending
)
def change_state(self, state, processor):
if state != StepState.closed:
assignees = self.ticket_assignees.filter(assignee=processor)
if not assignees:
raise PermissionError('Only assignees can do this')
assignees.update(state=state)
self.status = StepStatus.closed
self.state = state
self.save(update_fields=['state', 'status', 'date_updated'])
def set_active(self):
self.status = StepStatus.active
self.save(update_fields=['status'])
def next(self):
kwargs = dict(ticket=self.ticket, level=self.level + 1, status=StepStatus.pending)
return self.__class__.objects.filter(**kwargs).first()
@property
def processor(self):
processor = self.ticket_assignees.exclude(state=StepState.pending).first()
return processor.assignee if processor else None
class Meta:
verbose_name = _("Ticket step")
class TicketAssignee(JMSBaseModel):
assignee = models.ForeignKey(
'users.User', related_name='ticket_assignees',
on_delete=models.CASCADE, verbose_name='Assignee'
)
state = models.CharField(
choices=TicketState.choices, max_length=64,
default=TicketState.pending
)
step = models.ForeignKey(
'tickets.TicketStep', related_name='ticket_assignees',
on_delete=models.CASCADE
)
class Meta:
verbose_name = _('Ticket assignee')
def __str__(self):
return '{0.assignee.name}({0.assignee.username})_{0.step}'.format(self)
class StatusMixin:
State = TicketState
Status = TicketStatus
state: str
status: str
applicant_id: str
applicant: models.ForeignKey
current_step: TicketStep
save: Callable
create_process_steps_by_flow: Callable
create_process_steps_by_assignees: Callable
assignees: Callable
set_serial_num: Callable
set_rel_snapshot: Callable
approval_step: int
handler: None
flow: TicketFlow
ticket_steps: models.Manager
def is_state(self, state: TicketState):
return self.state == state
def is_status(self, status: TicketStatus):
return self.status == status
def _open(self):
self.set_serial_num()
self.set_rel_snapshot()
self._change_state_by_applicant(TicketState.pending)
def open(self):
self.create_process_steps_by_flow()
self._open()
def open_by_system(self, assignees):
self.create_process_steps_by_assignees(assignees)
self._open()
def approve(self, processor):
self.set_rel_snapshot()
self._change_state(StepState.approved, processor)
def reject(self, processor):
self._change_state(StepState.rejected, processor)
def close(self):
self._change_state(TicketState.closed, self.applicant)
def _change_state_by_applicant(self, state):
if state == TicketState.closed:
self.status = TicketStatus.closed
elif state == TicketState.pending:
self.status = TicketStatus.open
else:
raise ValueError("Not supported state: {}".format(state))
self.state = state
self.save(update_fields=['state', 'status'])
self.handler.on_change_state(state)
def _change_state(self, state, processor):
if self.is_status(self.Status.closed):
raise AlreadyClosed
current_step = self.current_step
current_step.change_state(state, processor)
self._finish_or_next(current_step, state)
def _finish_or_next(self, current_step, state):
next_step = current_step.next()
# 提前结束,或者最后一步
if state in [TicketState.rejected, TicketState.closed] or not next_step:
self.state = state
self.status = Ticket.Status.closed
self.save(update_fields=['state', 'status'])
self.handler.on_step_state_change(current_step, state)
else:
self.handler.on_step_state_change(current_step, state)
next_step.set_active()
self.approval_step += 1
self.save(update_fields=['approval_step'])
@property
def process_map(self):
process_map = []
for step in self.ticket_steps.all():
processor_id = ''
assignee_ids = []
processor_display = ''
assignees_display = []
state = step.state
for i in step.ticket_assignees.all().prefetch_related('assignee'):
assignee_id = i.assignee_id
assignee_display = str(i.assignee)
if state != StepState.pending and state == i.state:
processor_id = assignee_id
processor_display = assignee_display
if state == StepState.closed:
processor_id = self.applicant_id
processor_display = str(self.applicant)
assignee_ids.append(assignee_id)
assignees_display.append(assignee_display)
step_info = {
'state': state,
'assignees': assignee_ids,
'processor': processor_id,
'approval_level': step.level,
'assignees_display': assignees_display,
'approval_date': str(step.date_updated),
'processor_display': processor_display
}
process_map.append(step_info)
return process_map
def exclude_applicant(self, assignees, applicant=None):
applicant = applicant if applicant else self.applicant
if len(assignees) != 1:
assignees = set(assignees) - {applicant, }
return list(assignees)
def create_process_steps_by_flow(self):
org_id = self.flow.org_id
flow_rules = self.flow.rules.order_by('level')
for rule in flow_rules:
assignees = rule.get_assignees(org_id=org_id)
assignees = self.exclude_applicant(assignees, self.applicant)
step = TicketStep.objects.create(ticket=self, level=rule.level)
step_assignees = [TicketAssignee(step=step, assignee=user) for user in assignees]
TicketAssignee.objects.bulk_create(step_assignees)
def create_process_steps_by_assignees(self, assignees):
step = TicketStep.objects.create(ticket=self, level=1)
assignees = self.exclude_applicant(assignees, self.applicant)
ticket_assignees = [TicketAssignee(step=step, assignee=user) for user in assignees]
TicketAssignee.objects.bulk_create(ticket_assignees)
@property
def current_step(self):
return self.ticket_steps.filter(level=self.approval_step).first()
@property
def current_assignees(self):
ticket_assignees = self.current_step.ticket_assignees.all()
return [i.assignee for i in ticket_assignees]
@property
def processor(self):
""" 返回最后一步的处理人 """
return self.current_step.processor
def has_current_assignee(self, assignee):
return self.ticket_steps.filter(
level=self.approval_step,
ticket_assignees__assignee=assignee,
).exists()
def has_all_assignee(self, assignee):
return self.ticket_steps.filter(ticket_assignees__assignee=assignee).exists()
@property
def handler(self):
return get_ticket_handler(ticket=self)
class Ticket(StatusMixin, JMSBaseModel):
title = models.CharField(max_length=256, verbose_name=_('Title'))
type = models.CharField(
max_length=64, choices=TicketType.choices,
default=TicketType.general, verbose_name=_('Type')
)
state = models.CharField(
max_length=16, choices=TicketState.choices,
default=TicketState.pending, verbose_name=_('State')
)
status = models.CharField(
max_length=16, choices=TicketStatus.choices,
default=TicketStatus.open, verbose_name=_('Status')
)
# 申请人
applicant = models.ForeignKey(
'users.User', related_name='applied_tickets', null=True,
on_delete=models.SET_NULL, verbose_name=_("Applicant")
)
flow = models.ForeignKey(
'TicketFlow', related_name='tickets', null=True,
on_delete=models.SET_NULL, verbose_name=_('TicketFlow')
)
approval_step = models.SmallIntegerField(
default=TicketLevel.one, choices=TicketLevel.choices, verbose_name=_('Approval step')
)
comment = models.TextField(default='', blank=True, verbose_name=_('Comment'))
rel_snapshot = models.JSONField(verbose_name=_('Relation snapshot'), default=dict)
serial_num = models.CharField(_('Serial number'), max_length=128, null=True)
meta = models.JSONField(encoder=ModelJSONFieldEncoder, default=dict, verbose_name=_("Meta"))
org_id = models.CharField(
max_length=36, blank=True, default='', verbose_name=_('Organization'), db_index=True
)
class Meta:
ordering = ('-date_created',)
verbose_name = _('Ticket')
unique_together = (
('serial_num',),
)
def __str__(self):
return '{}({})'.format(self.title, self.applicant)
@property
def spec_ticket(self):
attr = self.type.replace('_', '') + 'ticket'
return getattr(self, attr)
# TODO 先单独处理一下
@property
def org_name(self):
org = Organization.get_instance(self.org_id)
return org.name
def is_type(self, tp: TicketType):
return self.type == tp
@classmethod
def get_user_related_tickets(cls, user):
queries = Q(applicant=user) | Q(ticket_steps__ticket_assignees__assignee=user)
# TODO: 与 StatusMixin.process_map 内连表查询有部分重叠 有优化空间 待验证排除是否不影响其它调用
prefetch_ticket_assignee = Prefetch('ticket_steps__ticket_assignees',
queryset=TicketAssignee.objects.select_related('assignee'), )
tickets = cls.objects.prefetch_related(prefetch_ticket_assignee) \
.select_related('applicant') \
.filter(queries) \
.distinct()
return tickets
def get_current_ticket_flow_approve(self):
return self.flow.rules.filter(level=self.approval_step).first()
@classmethod
def all(cls):
return cls.objects.all()
def set_rel_snapshot(self, save=True):
rel_fields = set()
m2m_fields = set()
excludes = ['ticket_ptr_id', 'ticket_ptr', 'flow_id', 'flow', 'applicant_id']
for name, field in self._meta._forward_fields_map.items():
if name in excludes:
continue
if isinstance(field, related.RelatedField):
rel_fields.add(name)
if isinstance(field, related.ManyToManyField):
m2m_fields.add(name)
snapshot = {}
with tmp_to_org(self.org_id):
for field in rel_fields:
value = getattr(self, field)
if field in m2m_fields:
value = [str(v) for v in value.all()]
else:
value = str(value) if value else ''
snapshot[field] = value
self.rel_snapshot.update(snapshot)
if save:
self.save(update_fields=('rel_snapshot',))
def get_next_serial_num(self):
date_created = as_current_tz(self.date_created)
date_prefix = date_created.strftime('%Y%m%d')
ticket = Ticket.objects.filter(
serial_num__startswith=date_prefix
).order_by('-serial_num').first()
last_num = 0
if ticket:
last_num = ticket.serial_num[8:]
last_num = int(last_num)
num = '%04d' % (last_num + 1)
return f'{date_prefix}{num}'
def set_serial_num(self):
if self.serial_num:
return
lock_key = 'TICKET_LOCK_SET_SERIAL_NUM'
with DistributedLock(lock_key):
try:
self.serial_num = self.get_next_serial_num()
self.save(update_fields=('serial_num',))
except IntegrityError as e:
logger.error(f'Set ticket serial number error: {e}')
if e.args[0] == 1062:
# 虽然做了 `select_for_update` 但是每天的第一条工单仍可能造成冲突
# 但概率小,这里只报错,用户重新提交即可
raise JMSException(detail=_('Please try again'), code='please_try_again')
raise e
def get_field_display(self, name, field, data: dict):
value = data.get(name)
if hasattr(self, f'get_{name}_display'):
value = getattr(self, f'get_{name}_display')()
elif isinstance(field, related.ForeignKey):
value = self.rel_snapshot[name]
elif isinstance(field, related.ManyToManyField):
if isinstance(self.rel_snapshot[name], str):
value = self.rel_snapshot[name]
elif isinstance(self.rel_snapshot[name], list):
value = ','.join(self.rel_snapshot[name])
elif name == 'apply_accounts':
new_values = []
for account in value:
alias = dict(AliasAccount.choices).get(account)
new_value = alias if alias else account
new_values.append(str(new_value))
value = ', '.join(new_values)
elif isinstance(value, list):
value = ', '.join(value)
return value
def get_local_snapshot(self):
snapshot = {}
excludes = ['ticket_ptr']
fields = self._meta._forward_fields_map
json_data = json.dumps(model_to_dict(self), cls=ModelJSONFieldEncoder)
data = json.loads(json_data)
local_fields = self._meta.local_fields + self._meta.local_many_to_many
item_names = [field.name for field in local_fields if field.name not in excludes]
for name in item_names:
field = fields[name]
value = self.get_field_display(name, field, data)
snapshot[field.verbose_name] = value
return snapshot
def get_extra_info_of_review(self, user=None):
if user and user.is_service_account:
url_ticket_status = reverse(
view_name='api-tickets:super-ticket-status', kwargs={'pk': str(self.id)}
)
check_ticket_api = {'method': 'GET', 'url': url_ticket_status}
close_ticket_api = {'method': 'DELETE', 'url': url_ticket_status}
else:
url_ticket_status = reverse(
view_name='api-tickets:ticket-detail', kwargs={'pk': str(self.id)}
)
url_ticket_close = reverse(
view_name='api-tickets:ticket-close', kwargs={'pk': str(self.id)}
)
check_ticket_api = {'method': 'GET', 'url': url_ticket_status}
close_ticket_api = {'method': 'PUT', 'url': url_ticket_close}
url_ticket_detail_external = reverse(
view_name='api-tickets:ticket-detail',
kwargs={'pk': str(self.id)},
external=True,
api_to_ui=True
)
ticket_assignees = self.current_step.ticket_assignees.all()
return {
'check_ticket_api': check_ticket_api,
'close_ticket_api': close_ticket_api,
'ticket_detail_page_url': '{url}?type={type}'.format(
url=url_ticket_detail_external, type=self.type
),
'assignees': [str(ticket_assignee.assignee) for ticket_assignee in ticket_assignees]
}
class SuperTicket(Ticket):
class Meta:
proxy = True
verbose_name = _("Super ticket")
class SubTicketManager(models.Manager):
pass