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.
This commit is contained in:
bbe 2020-10-29 23:32:02 +01:00
parent 9795167d9b
commit e1cd9d0f2e
9 changed files with 332 additions and 4 deletions

View File

@ -8,7 +8,7 @@ forms.py - Definitions of newforms-based forms for creating and maintaining
""" """
import logging import logging
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
@ -510,3 +510,19 @@ class TicketDependencyForm(forms.ModelForm):
class Meta: class Meta:
model = TicketDependency model = TicketDependency
exclude = ('ticket',) 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

View File

@ -804,6 +804,40 @@ class Ticket(models.Model):
def get_resolution_markdown(self): def get_resolution_markdown(self):
return get_markdown(self.resolution) 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): class FollowUpManager(models.Manager):

View File

@ -3,6 +3,7 @@ import mimetypes
import logging import logging
from smtplib import SMTPException from smtplib import SMTPException
from django.conf import settings
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
logger = logging.getLogger('helpdesk') logger = logging.getLogger('helpdesk')

View File

@ -0,0 +1,13 @@
{% load helpdesk_util %}
<tr>
<td class="text-right">{{ display_attr }}</td>
{% for ticket in tickets %}
<td>
<label>
<input name="{{ attr }}" type="radio" value="{{ ticket.id }}"
class="flat"{% if ticket.values|get:attr|get:"checked" %} checked{% endif %}>
{{ ticket.values|get:attr|get:"value"|default:"Not defined" }}
</label>
</td>
{% endfor %}
</tr>

View File

@ -88,6 +88,7 @@
<select name='action' id='id_mass_action'> <select name='action' id='id_mass_action'>
<option value='take'>{% trans "Take (Assign to me)" %}</option> <option value='take'>{% trans "Take (Assign to me)" %}</option>
<option value='delete'>{% trans "Delete" %}</option> <option value='delete'>{% trans "Delete" %}</option>
<option value='merge'>{% trans "Merge" %}</option>
<optgroup label='{% trans "Close" %}'> <optgroup label='{% trans "Close" %}'>
<option value='close'>{% trans "Close (Don't Send E-Mail)" %}</option> <option value='close'>{% trans "Close (Don't Send E-Mail)" %}</option>
<option value='close_public'>{% trans "Close (Send E-Mail)" %}</option> <option value='close_public'>{% trans "Close (Send E-Mail)" %}</option>

View File

@ -0,0 +1,106 @@
{% extends "helpdesk/base.html" %}
{% load i18n helpdesk_util %}
{% block helpdesk_title %}{% trans "Merge Tickets" %}{% endblock %}
{% block h1_title %}{% endblock %}
{% block helpdesk_head %}
{{ ticket_select_form.media.css }}
{% endblock %}
{% block helpdesk_breadcrumb %}
<li class="breadcrumb-item">
<a href="{% url 'helpdesk:list' %}">{% trans "Tickets" %}</a>
</li>
<li class="breadcrumb-item active">{% trans "Merge Tickets" %}</li>
{% endblock %}
{% block helpdesk_body %}
<div class="row">
<div class="col-sm-6 col-xs-12">
<div class="card">
<div class="card-header">
<h3>{% trans "Merge Tickets" %}</h3>
</div>
<div class="card-body">
<form method="get">
<div class="form-group">
{{ ticket_select_form.tickets.label_tag }}
{{ ticket_select_form.tickets }}
{{ ticket_select_form.tickets.errors }}
</div>
<div class="text-center">
<button type="submit" class="btn btn-primary">{% trans "OK" %}</button>
</div>
</form>
</div>
</div>
</div>
<div class="col-sm-6 col-xs-12">
<div class="card card-body bg-light">
<p>
{% blocktrans %}
Choose the ticket which will be conserved and then, for each information, you can decide to use
a data from another ticket to merge. I you don't select a data on a row, <b>the information
from the main ticket will stay unchanged</b>.
{% endblocktrans %}
</p>
<p>
{% blocktrans %}
The <b>follow-ups</b> and <b>attachments</b> from the merged tickets will be moved to
the main ticket.<br>
<b>Involved users</b> (the ticket submitter and emails in CC) will also be added in the
main ticket CC list.<br>
However, ticket dependencies from the merged ticket won't be applied to the main ticket.
{% endblocktrans %}
</p>
</div>
</div>
</div>
{% if tickets %}
<hr>
<form method="post">
{% csrf_token %}
<div class="table-responsive">
<table class="table table-striped table-hover table-bordered">
<thead class="thead-dark">
<tr>
<th class="text-center">{% trans "Fields" %}</th>
{% for ticket in tickets %}
<th class="text-center">
<label>
{{ ticket }}
<input name="chosen_ticket" type="radio" value="{{ ticket.id }}"
class="flat"{% if forloop.first %} checked{% endif %}>
<a href="{{ ticket.get_absolute_url }}" target="_blank" class="btn btn-outline-primary btn-sm" role="button">
{% trans "Open ticket" %}
<i class="fa fa-external-link"></i>
</a>
</label>
</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% 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 %}
</tbody>
</table>
</div>
<div class="text-center">
<button type="submit" class="btn btn-lg btn-primary">{% trans "Validate" %}</button>
</div>
</form>
{% endif %}
{% endblock %}
{% block helpdesk_js %}
{{ ticket_select_form.media.js }}
{% endblock %}

View File

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

View File

@ -52,6 +52,10 @@ urlpatterns = [
staff.mass_update, staff.mass_update,
name='mass_update'), name='mass_update'),
url(r'^tickets/merge$',
staff.merge_tickets,
name='merge_tickets'),
url(r'^tickets/(?P<ticket_id>[0-9]+)/$', url(r'^tickets/(?P<ticket_id>[0-9]+)/$',
staff.view_ticket, staff.view_ticket,
name='view'), name='view'),

View File

@ -18,7 +18,7 @@ from django.core.exceptions import ValidationError, PermissionDenied
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from django.db.models import Q from django.db.models import Q
from django.http import HttpResponseRedirect, Http404, HttpResponse, JsonResponse 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.translation import ugettext as _
from django.utils.html import escape from django.utils.html import escape
from django.utils import timezone from django.utils import timezone
@ -38,7 +38,7 @@ from helpdesk.decorators import (
) )
from helpdesk.forms import ( from helpdesk.forms import (
TicketForm, UserSettingsForm, EmailIgnoreForm, EditTicketForm, TicketCCForm, TicketForm, UserSettingsForm, EmailIgnoreForm, EditTicketForm, TicketCCForm,
TicketCCEmailForm, TicketCCUserForm, EditFollowUpForm, TicketDependencyForm TicketCCEmailForm, TicketCCUserForm, EditFollowUpForm, TicketDependencyForm, MultipleTicketSelectForm
) )
from helpdesk.decorators import superuser_required from helpdesk.decorators import superuser_required
from helpdesk.lib import ( from helpdesk.lib import (
@ -48,7 +48,7 @@ from helpdesk.lib import (
) )
from helpdesk.models import ( from helpdesk.models import (
Ticket, Queue, FollowUp, TicketChange, PreSetReply, FollowUpAttachment, SavedSearch, 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 from helpdesk import settings as helpdesk_settings
import helpdesk.views.abstract_views as abstract_views 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 from datetime import date, datetime, timedelta
import re import re
from ..templated_email import send_templated_mail
User = get_user_model() User = get_user_model()
Query = get_query_class() Query = get_query_class()
@ -766,6 +767,11 @@ def mass_update(request):
elif action == 'take': elif action == 'take':
user = request.user user = request.user
action = 'assign' 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) huser = HelpdeskUser(request.user)
for t in Ticket.objects.filter(id__in=tickets): for t in Ticket.objects.filter(id__in=tickets):
@ -854,6 +860,144 @@ def mass_update(request):
mass_update = staff_member_required(mass_update) 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 @helpdesk_staff_member_required
def ticket_list(request): def ticket_list(request):
context = {} context = {}