From e1cd9d0f2e50a14160592d455480c4498c855073 Mon Sep 17 00:00:00 2001 From: bbe Date: Thu, 29 Oct 2020 23:32:02 +0100 Subject: [PATCH] Implement ticket merge feature in ticket list. Create intermediate page to choose which data and custom field values to keep on the main ticket. Also add new template tag filter to use the dictionary get function in template. --- helpdesk/forms.py | 18 ++- helpdesk/models.py | 34 ++++ helpdesk/templated_email.py | 1 + .../helpdesk/include/ticket_merge_row.html | 13 ++ helpdesk/templates/helpdesk/ticket_list.html | 1 + helpdesk/templates/helpdesk/ticket_merge.html | 106 +++++++++++++ helpdesk/templatetags/helpdesk_util.py | 9 ++ helpdesk/urls.py | 4 + helpdesk/views/staff.py | 150 +++++++++++++++++- 9 files changed, 332 insertions(+), 4 deletions(-) create mode 100644 helpdesk/templates/helpdesk/include/ticket_merge_row.html create mode 100644 helpdesk/templates/helpdesk/ticket_merge.html create mode 100644 helpdesk/templatetags/helpdesk_util.py diff --git a/helpdesk/forms.py b/helpdesk/forms.py index 39cf8bcd..bb9072a0 100644 --- a/helpdesk/forms.py +++ b/helpdesk/forms.py @@ -8,7 +8,7 @@ forms.py - Definitions of newforms-based forms for creating and maintaining """ import logging -from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import ObjectDoesNotExist, ValidationError from django import forms from django.conf import settings from django.utils.translation import ugettext_lazy as _ @@ -510,3 +510,19 @@ class TicketDependencyForm(forms.ModelForm): class Meta: model = TicketDependency exclude = ('ticket',) + + +class MultipleTicketSelectForm(forms.Form): + tickets = forms.ModelMultipleChoiceField( + label=_('Tickets to merge'), + queryset=Ticket.objects.all(), + widget=forms.SelectMultiple(attrs={'class': 'form-control'}) + ) + + def clean_tickets(self): + tickets = self.cleaned_data.get('tickets') + if len(tickets) < 2: + raise ValidationError(_('Please choose at least 2 tickets')) + if len(tickets) > 4: + raise ValidationError(_('Impossible to merge more than 4 tickets...')) + return tickets diff --git a/helpdesk/models.py b/helpdesk/models.py index d05dbd18..c4822ced 100644 --- a/helpdesk/models.py +++ b/helpdesk/models.py @@ -804,6 +804,40 @@ class Ticket(models.Model): def get_resolution_markdown(self): return get_markdown(self.resolution) + def add_email_to_ticketcc_if_not_in(self, email=None, user=None, ticketcc=None): + """ + Check that given email/user_email/ticketcc_email is not already present on the ticket + (submitter email, assigned to, or in ticket CCs) and add it to a new ticket CC, + or move the given one + + :param str email: + :param User user: + :param TicketCC ticketcc: + """ + if ticketcc: + email = ticketcc.display + elif user: + if user.email: + email = user.email + else: + return + elif not email: + return + + # Check that email is not already part of the ticket + if ( + email != self.submitter_email and + (self.assigned_to and email != self.assigned_to.email) and + email not in [x.display for x in self.ticketcc_set.all()] + ): + if ticketcc: + ticketcc.ticket = self + ticketcc.save(update_fields=['ticket']) + elif user: + self.ticketcc_set.create(user=user) + else: + self.ticketcc_set.create(email=email) + class FollowUpManager(models.Manager): diff --git a/helpdesk/templated_email.py b/helpdesk/templated_email.py index 720e9445..e26df247 100644 --- a/helpdesk/templated_email.py +++ b/helpdesk/templated_email.py @@ -3,6 +3,7 @@ import mimetypes import logging from smtplib import SMTPException +from django.conf import settings from django.utils.safestring import mark_safe logger = logging.getLogger('helpdesk') diff --git a/helpdesk/templates/helpdesk/include/ticket_merge_row.html b/helpdesk/templates/helpdesk/include/ticket_merge_row.html new file mode 100644 index 00000000..a259e4d2 --- /dev/null +++ b/helpdesk/templates/helpdesk/include/ticket_merge_row.html @@ -0,0 +1,13 @@ +{% load helpdesk_util %} + + {{ display_attr }} + {% for ticket in tickets %} + + + + {% endfor %} + \ No newline at end of file diff --git a/helpdesk/templates/helpdesk/ticket_list.html b/helpdesk/templates/helpdesk/ticket_list.html index f2c83e06..4a24c079 100644 --- a/helpdesk/templates/helpdesk/ticket_list.html +++ b/helpdesk/templates/helpdesk/ticket_list.html @@ -88,6 +88,7 @@ + + {% trans "Open ticket" %} + + + + + {% endfor %} + + + + {% for attr, display_attr in ticket_attributes %} + {% include 'helpdesk/include/ticket_merge_row.html' %} + {% endfor %} + {% for custom_field in custom_fields %} + {% include 'helpdesk/include/ticket_merge_row.html' with display_attr=custom_field.label attr=custom_field.name %} + {% endfor %} + + + + +
+ +
+ + {% endif %} +{% endblock %} + +{% block helpdesk_js %} + {{ ticket_select_form.media.js }} +{% endblock %} \ No newline at end of file diff --git a/helpdesk/templatetags/helpdesk_util.py b/helpdesk/templatetags/helpdesk_util.py new file mode 100644 index 00000000..9522c17f --- /dev/null +++ b/helpdesk/templatetags/helpdesk_util.py @@ -0,0 +1,9 @@ +from django import template + +register = template.Library() + + +@register.filter +def get(value, arg, default=None): + """ Call the dictionary get function """ + return value.get(arg, default) diff --git a/helpdesk/urls.py b/helpdesk/urls.py index 3ad3216b..da22b09a 100644 --- a/helpdesk/urls.py +++ b/helpdesk/urls.py @@ -52,6 +52,10 @@ urlpatterns = [ staff.mass_update, name='mass_update'), + url(r'^tickets/merge$', + staff.merge_tickets, + name='merge_tickets'), + url(r'^tickets/(?P[0-9]+)/$', staff.view_ticket, name='view'), diff --git a/helpdesk/views/staff.py b/helpdesk/views/staff.py index 9fa1b869..7875d000 100644 --- a/helpdesk/views/staff.py +++ b/helpdesk/views/staff.py @@ -18,7 +18,7 @@ from django.core.exceptions import ValidationError, PermissionDenied from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger from django.db.models import Q from django.http import HttpResponseRedirect, Http404, HttpResponse, JsonResponse -from django.shortcuts import render, get_object_or_404 +from django.shortcuts import render, get_object_or_404, redirect from django.utils.translation import ugettext as _ from django.utils.html import escape from django.utils import timezone @@ -38,7 +38,7 @@ from helpdesk.decorators import ( ) from helpdesk.forms import ( TicketForm, UserSettingsForm, EmailIgnoreForm, EditTicketForm, TicketCCForm, - TicketCCEmailForm, TicketCCUserForm, EditFollowUpForm, TicketDependencyForm + TicketCCEmailForm, TicketCCUserForm, EditFollowUpForm, TicketDependencyForm, MultipleTicketSelectForm ) from helpdesk.decorators import superuser_required from helpdesk.lib import ( @@ -48,7 +48,7 @@ from helpdesk.lib import ( ) from helpdesk.models import ( Ticket, Queue, FollowUp, TicketChange, PreSetReply, FollowUpAttachment, SavedSearch, - IgnoreEmail, TicketCC, TicketDependency, UserSettings, KBItem, + IgnoreEmail, TicketCC, TicketDependency, UserSettings, KBItem, CustomField, TicketCustomFieldValue, ) from helpdesk import settings as helpdesk_settings import helpdesk.views.abstract_views as abstract_views @@ -61,6 +61,7 @@ from rest_framework.decorators import api_view from datetime import date, datetime, timedelta import re +from ..templated_email import send_templated_mail User = get_user_model() Query = get_query_class() @@ -766,6 +767,11 @@ def mass_update(request): elif action == 'take': user = request.user action = 'assign' + elif action == 'merge': + # Redirect to the Merge View with selected tickets id in the GET request + return redirect( + reverse('helpdesk:merge_tickets') + '?' + '&'.join(['tickets=%s' % ticket_id for ticket_id in tickets]) + ) huser = HelpdeskUser(request.user) for t in Ticket.objects.filter(id__in=tickets): @@ -854,6 +860,144 @@ def mass_update(request): mass_update = staff_member_required(mass_update) +# Prepare ticket attributes which will be displayed in the table to choose which value to keep when merging +ticket_attributes = ( + ('created', _('Created date')), + ('due_date', _('Due on')), + ('get_status_display', _('Status')), + ('submitter_email', _('Submitter email')), + ('description', _('Description')), + ('resolution', _('Resolution')), +) + + +@staff_member_required +def merge_tickets(request): + """ TODO """ + ticket_select_form = MultipleTicketSelectForm(request.GET or None) + tickets = custom_fields = None + if ticket_select_form.is_valid(): + tickets = ticket_select_form.cleaned_data.get('tickets') + + custom_fields = CustomField.objects.all() + default = _('Not defined') + for ticket in tickets: + ticket.values = {} + # Prepare the value for each attributes of this ticket + for attr, display_name in ticket_attributes: + value = getattr(ticket, attr, default) + # Check if attr is a get_FIELD_display + if attr.startswith('get_') and attr.endswith('_display'): + # Hack to call methods like get_FIELD_display() + value = getattr(ticket, attr, default)() + ticket.values[attr] = { + 'value': value, + 'checked': str(ticket.id) == request.POST.get(attr) + } + # Prepare the value for each custom fields of this ticket + for custom_field in custom_fields: + try: + value = ticket.ticketcustomfieldvalue_set.get(field=custom_field).value + except (TicketCustomFieldValue.DoesNotExist, ValueError): + value = default + ticket.values[custom_field.name] = { + 'value': value, + 'checked': str(ticket.id) == request.POST.get(custom_field.name) + } + + if request.method == 'POST': + # Find which ticket has been chosen to be the main one + try: + chosen_ticket = tickets.get(id=request.POST.get('chosen_ticket')) + except Ticket.DoesNotExist: + ticket_select_form.add_error( + field='tickets', + error=_('Please choose a ticket in which the others will be merged into.') + ) + else: + # Save ticket fields values + for attr, display_name in ticket_attributes: + id_for_attr = request.POST.get(attr) + if id_for_attr != chosen_ticket.id: + try: + selected_ticket = tickets.get(id=id_for_attr) + except Ticket.DoesNotExist: + pass + else: + # Check if attr is a get_FIELD_display + if attr.startswith('get_') and attr.endswith('_display'): + # Keep only the FIELD part + attr = attr[4:-8] + value = getattr(selected_ticket, attr) + setattr(chosen_ticket, attr, value) + # Save custom fields values + for custom_field in custom_fields: + id_for_attr = request.POST.get(custom_field.name) + if id_for_attr != chosen_ticket.id: + try: + selected_ticket = tickets.get(id=id_for_attr) + except (Ticket.DoesNotExist, ValueError): + continue + + # Check if the value for this ticket custom field exists + try: + value = selected_ticket.ticketcustomfieldvalue_set.get(field=custom_field).value + except TicketCustomFieldValue.DoesNotExist: + continue + + # Create the custom field value or update it with the value from the selected ticket + custom_field_value, created = chosen_ticket.ticketcustomfieldvalue_set.get_or_create( + field=custom_field, + defaults={'value': value} + ) + if not created: + custom_field_value.value = value + custom_field_value.save(update_fields=['value']) + # Save changes + chosen_ticket.save() + + # For other tickets, save the link to ticket in which they have been merged to + # and set status as DUPLICATE + for ticket in tickets.exclude(id=chosen_ticket.id): + ticket.merged_to = chosen_ticket + ticket.status = Ticket.DUPLICATE_STATUS + ticket.save() + + # Send mail to submitter email and ticket CC to let them know ticket has been merged + context = safe_template_context(ticket) + if ticket.submitter_email: + send_templated_mail( + template_name='merged', + context=context, + recipients=[ticket.submitter_email], + bcc=[cc.email_address for cc in ticket.ticketcc_set.select_related('user')], + sender=ticket.queue.from_address, + fail_silently=True + ) + + # Move all followups and update their title to know they come from another ticket + ticket.followup_set.update( + ticket=chosen_ticket, + # Next might exceed maximum 200 characters limit + title=_('[Merged from #%d] %s') % (ticket.id, ticket.title) + ) + + # Add submitter_email, assigned_to email and ticketcc to chosen ticket if necessary + chosen_ticket.add_email_to_ticketcc_if_not_in(email=ticket.submitter_email) + if ticket.assigned_to and ticket.assigned_to.email: + chosen_ticket.add_email_to_ticketcc_if_not_in(email=ticket.assigned_to.email) + for ticketcc in ticket.ticketcc_set.all(): + chosen_ticket.add_email_to_ticketcc_if_not_in(ticketcc=ticketcc) + return redirect(chosen_ticket) + + return render(request, 'helpdesk/ticket_merge.html', { + 'tickets': tickets, + 'ticket_attributes': ticket_attributes, + 'custom_fields': custom_fields, + 'ticket_select_form': ticket_select_form + }) + + @helpdesk_staff_member_required def ticket_list(request): context = {}