mirror of
https://github.com/django-helpdesk/django-helpdesk.git
synced 2024-12-13 18:31:10 +01:00
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:
parent
9795167d9b
commit
e1cd9d0f2e
@ -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
|
||||||
|
@ -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):
|
||||||
|
|
||||||
|
@ -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')
|
||||||
|
13
helpdesk/templates/helpdesk/include/ticket_merge_row.html
Normal file
13
helpdesk/templates/helpdesk/include/ticket_merge_row.html
Normal 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>
|
@ -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>
|
||||||
|
106
helpdesk/templates/helpdesk/ticket_merge.html
Normal file
106
helpdesk/templates/helpdesk/ticket_merge.html
Normal 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 %}
|
9
helpdesk/templatetags/helpdesk_util.py
Normal file
9
helpdesk/templatetags/helpdesk_util.py
Normal 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)
|
@ -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'),
|
||||||
|
@ -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 = {}
|
||||||
|
Loading…
Reference in New Issue
Block a user