mirror of
https://gitea.mueller.network/extern/django-helpdesk.git
synced 2024-11-21 23:43:11 +01:00
Merge pull request #895 from Benbb96/merging-tickets-feature
New feature: Merging tickets
This commit is contained in:
commit
c9fa0c81c7
@ -337,9 +337,15 @@ def create_object_from_email_message(message, ticket_id, payload, files, logger)
|
||||
if previous_followup is None and ticket_id is not None:
|
||||
try:
|
||||
ticket = Ticket.objects.get(id=ticket_id)
|
||||
new = False
|
||||
except Ticket.DoesNotExist:
|
||||
ticket = None
|
||||
else:
|
||||
new = False
|
||||
# Check if the ticket has been merged to another ticket
|
||||
if ticket.merged_to:
|
||||
logger.info("Ticket has been merged to %s" % ticket.merged_to.ticket)
|
||||
# Use the ticket in which it was merged to for next operations
|
||||
ticket = ticket.merged_to
|
||||
|
||||
# New issue, create a new <Ticket> instance
|
||||
if ticket is None:
|
||||
|
@ -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,22 @@ class TicketDependencyForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = TicketDependency
|
||||
exclude = ('ticket',)
|
||||
|
||||
|
||||
class MultipleTicketSelectForm(forms.Form):
|
||||
tickets = forms.ModelMultipleChoiceField(
|
||||
label=_('Tickets to merge'),
|
||||
queryset=Ticket.objects.filter(merged_to=None),
|
||||
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...'))
|
||||
queues = tickets.order_by('queue').distinct().values_list('queue', flat=True)
|
||||
if len(queues) != 1:
|
||||
raise ValidationError(_('All selected tickets must share the same queue in order to be merged.'))
|
||||
return tickets
|
||||
|
@ -26,7 +26,7 @@ def ticket_template_context(ticket):
|
||||
for field in ('title', 'created', 'modified', 'submitter_email',
|
||||
'status', 'get_status_display', 'on_hold', 'description',
|
||||
'resolution', 'priority', 'get_priority_display',
|
||||
'last_escalation', 'ticket', 'ticket_for_url',
|
||||
'last_escalation', 'ticket', 'ticket_for_url', 'merged_to',
|
||||
'get_status', 'ticket_url', 'staff_url', '_get_assigned_to'
|
||||
):
|
||||
attr = getattr(ticket, field, None)
|
||||
|
Binary file not shown.
File diff suppressed because it is too large
Load Diff
19
helpdesk/migrations/0033_ticket_merged_to.py
Normal file
19
helpdesk/migrations/0033_ticket_merged_to.py
Normal file
@ -0,0 +1,19 @@
|
||||
# Generated by Django 2.2.13 on 2020-10-27 17:14
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('helpdesk', '0032_kbitem_enabled'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='ticket',
|
||||
name='merged_to',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='merged_tickets', to='helpdesk.Ticket', verbose_name='merged to'),
|
||||
),
|
||||
]
|
57
helpdesk/migrations/0034_create_email_template_for_merged.py
Normal file
57
helpdesk/migrations/0034_create_email_template_for_merged.py
Normal file
@ -0,0 +1,57 @@
|
||||
# Generated by Django 2.2.13 on 2020-10-29 22:34
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def forwards_func(apps, schema_editor):
|
||||
EmailTemplate = apps.get_model("helpdesk", "EmailTemplate")
|
||||
db_alias = schema_editor.connection.alias
|
||||
EmailTemplate.objects.using(db_alias).create(
|
||||
template_name='merged',
|
||||
subject='(Merged)',
|
||||
heading='Ticket merged',
|
||||
plain_text="""Hello,
|
||||
|
||||
This is a courtesy e-mail to let you know that ticket {{ ticket.ticket }} ("{{ ticket.title }}") by {{ ticket.submitter_email }} has been merged to ticket {{ ticket.merged_to.ticket }}.
|
||||
|
||||
From now on, please answer on this ticket, or you can include the tag {{ ticket.merged_to.ticket }} in your e-mail subject.""",
|
||||
html="""<p style="font-family: sans-serif; font-size: 1em;">Hello,</p>
|
||||
|
||||
<p style="font-family: sans-serif; font-size: 1em;">This is a courtesy e-mail to let you know that ticket <b>{{ ticket.ticket }}</b> (<em>{{ ticket.title }}</em>) by {{ ticket.submitter_email }} has been merged to ticket <a href="{{ ticket.merged_to.staff_url }}">{{ ticket.merged_to.ticket }}</a>.</p>
|
||||
|
||||
<p style="font-family: sans-serif; font-size: 1em;">From now on, please answer on this ticket, or you can include the tag <b>{{ ticket.merged_to.ticket }}</b> in your e-mail subject.</p>""",
|
||||
locale='en'
|
||||
)
|
||||
EmailTemplate.objects.using(db_alias).create(
|
||||
template_name='merged',
|
||||
subject='(Fusionné)',
|
||||
heading='Ticket Fusionné',
|
||||
plain_text="""Bonjour,
|
||||
|
||||
Ce courriel indicatif permet de vous prévenir que le ticket {{ ticket.ticket }} ("{{ ticket.title }}") par {{ ticket.submitter_email }} a été fusionné au ticket {{ ticket.merged_to.ticket }}.
|
||||
|
||||
Veillez à répondre sur ce ticket dorénavant, ou bien inclure la balise {{ ticket.merged_to.ticket }} dans le sujet de votre réponse par mail.""",
|
||||
html="""<p style="font-family: sans-serif; font-size: 1em;">Bonjour,</p>
|
||||
|
||||
<p style="font-family: sans-serif; font-size: 1em;">Ce courriel indicatif permet de vous prévenir que le ticket <b>{{ ticket.ticket }}</b> (<em>{{ ticket.title }}</em>) par {{ ticket.submitter_email }} a été fusionné au ticket <a href="{{ ticket.merged_to.staff_url }}">{{ ticket.merged_to.ticket }}</a>.</p>
|
||||
|
||||
<p style="font-family: sans-serif; font-size: 1em;">Veillez à répondre sur ce ticket dorénavant, ou bien inclure la balise <b>{{ ticket.merged_to.ticket }}</b> dans le sujet de votre réponse par mail.</p>""",
|
||||
locale='fr'
|
||||
)
|
||||
|
||||
|
||||
def reverse_func(apps, schema_editor):
|
||||
EmailTemplate = apps.get_model("helpdesk", "EmailTemplate")
|
||||
db_alias = schema_editor.connection.alias
|
||||
EmailTemplate.objects.using(db_alias).filter(template_name='merged').delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('helpdesk', '0033_ticket_merged_to'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forwards_func, reverse_func),
|
||||
]
|
@ -569,6 +569,15 @@ class Ticket(models.Model):
|
||||
verbose_name=_('Knowledge base item the user was viewing when they created this ticket.'),
|
||||
)
|
||||
|
||||
merged_to = models.ForeignKey(
|
||||
'self',
|
||||
verbose_name=_('merged to'),
|
||||
related_name='merged_tickets',
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
|
||||
@property
|
||||
def time_spent(self):
|
||||
"""Return back total time spent on the ticket. This is calculated value
|
||||
@ -795,6 +804,46 @@ 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:
|
||||
:rtype: TicketCC|None
|
||||
"""
|
||||
if ticketcc:
|
||||
email = ticketcc.display
|
||||
elif user:
|
||||
if user.email:
|
||||
email = user.email
|
||||
else:
|
||||
# Ignore if user has no email address
|
||||
return
|
||||
elif not email:
|
||||
raise ValueError('You must provide at least one parameter to get the email from')
|
||||
|
||||
# Prepare all emails already into the ticket
|
||||
ticket_emails = [x.display for x in self.ticketcc_set.all()]
|
||||
if self.submitter_email:
|
||||
ticket_emails.append(self.submitter_email)
|
||||
if self.assigned_to and self.assigned_to.email:
|
||||
ticket_emails.append(self.assigned_to.email)
|
||||
|
||||
# Check that email is not already part of the ticket
|
||||
if email not in ticket_emails:
|
||||
if ticketcc:
|
||||
ticketcc.ticket = self
|
||||
ticketcc.save(update_fields=['ticket'])
|
||||
elif user:
|
||||
ticketcc = self.ticketcc_set.create(user=user)
|
||||
else:
|
||||
ticketcc = self.ticketcc_set.create(email=email)
|
||||
return ticketcc
|
||||
|
||||
|
||||
class FollowUpManager(models.Manager):
|
||||
|
||||
|
@ -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')
|
||||
@ -72,7 +73,7 @@ def send_templated_mail(template_name,
|
||||
footer_file = os.path.join('helpdesk', locale, 'email_text_footer.txt')
|
||||
|
||||
text_part = from_string(
|
||||
"%s{%% include '%s' %%}" % (t.plain_text, footer_file)
|
||||
"%s\n\n{%% include '%s' %%}" % (t.plain_text, footer_file)
|
||||
).render(context)
|
||||
|
||||
email_html_base_file = os.path.join('helpdesk', locale, 'email_html_base.html')
|
||||
@ -81,9 +82,9 @@ def send_templated_mail(template_name,
|
||||
context['comment'] = mark_safe(context['comment'].replace('\r\n', '<br>'))
|
||||
|
||||
html_part = from_string(
|
||||
"{%% extends '%s' %%}{%% block title %%}"
|
||||
"%s"
|
||||
"{%% endblock %%}{%% block content %%}%s{%% endblock %%}" %
|
||||
"{%% extends '%s' %%}"
|
||||
"{%% block title %%}%s{%% endblock %%}"
|
||||
"{%% block content %%}%s{%% endblock %%}" %
|
||||
(email_html_base_file, t.heading, t.html)
|
||||
).render(context)
|
||||
|
||||
|
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"|linebreaksbr }}
|
||||
</label>
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
@ -11,7 +11,7 @@
|
||||
<thead class="thead-light">
|
||||
<tr>
|
||||
<th>{% trans "Ticket" %}</th>
|
||||
<th>{% trans "Prority" %}</th>
|
||||
<th>{% trans "Priority" %}</th>
|
||||
<th>{% trans "Queue" %}</th>
|
||||
<th>{% trans "Created" %}</th>
|
||||
<th>{% trans "Actions" %}</th>
|
||||
@ -51,7 +51,7 @@
|
||||
<thead class="thead-light">
|
||||
<tr>
|
||||
<th>{% trans "Ticket" %}</th>
|
||||
<th>{% trans "Prority" %}</th>
|
||||
<th>{% trans "Priority" %}</th>
|
||||
<th>{% trans "Queue" %}</th>
|
||||
<th>{% trans "Created" %}</th>
|
||||
<th>{% trans "Actions" %}</th>
|
||||
|
@ -21,196 +21,198 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block helpdesk_body %}
|
||||
{% if helpdesk_settings.HELPDESK_TRANSLATE_TICKET_COMMENTS %}
|
||||
{% comment %}
|
||||
<div id='translate_dropdown'>{% trans "Translate ticket comments into" %} </div>
|
||||
<div id='translate_block'>
|
||||
{% endcomment %}
|
||||
<div id="google_translate_element"></div>
|
||||
<script src="//translate.google.com/translate_a/element.js?cb=googleTranslateElementInit"></script>
|
||||
{% endif %}
|
||||
{% if helpdesk_settings.HELPDESK_TRANSLATE_TICKET_COMMENTS %}
|
||||
<div id="google_translate_element"></div>
|
||||
<script src="//translate.google.com/translate_a/element.js?cb=googleTranslateElementInit"></script>
|
||||
{% endif %}
|
||||
|
||||
{% include "helpdesk/ticket_desc_table.html" %}
|
||||
{% include "helpdesk/ticket_desc_table.html" %}
|
||||
|
||||
{% if ticket.followup_set.all %}
|
||||
{% load ticket_to_link %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><i class="fas fa-clock fa-fw fa-lg"></i> {% trans "Follow-Ups" %}</div>
|
||||
<div class="card-body">
|
||||
<div class="list-group">
|
||||
{% for followup in ticket.followup_set.all %}
|
||||
<div class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">{{ followup.title }}</h5>
|
||||
<small><i class="fas fa-clock"></i> <span class='byline text-info'>{% if followup.user %}by {{ followup.user }},{% endif %} <span title='{{ followup.date|date:"r" }}'>{{ followup.date|naturaltime }}</span>{% if followup.time_spent %}, <span>{% trans "time spent" %}: {{ followup.time_spent_formated }}</span>{% endif %} {% if not followup.public %} <span class='private'>({% trans "Private" %})</span>{% endif %}</span></small>
|
||||
</div>
|
||||
<p class="mb-1">
|
||||
{% if followup.comment %}
|
||||
<p>{{ followup.get_markdown|urlizetrunc:50|num_to_link|linebreaksbr }}</p>
|
||||
{% endif %}
|
||||
{% for change in followup.ticketchange_set.all %}
|
||||
{% if forloop.first %}<div class='changes'><ul>{% endif %}
|
||||
<li>{% blocktrans with change.field as field and change.old_value as old_value and change.new_value as new_value %}Changed {{ field }} from {{ old_value }} to {{ new_value }}.{% endblocktrans %}</li>
|
||||
{% if forloop.last %}</ul></div>{% endif %}
|
||||
{% endfor %}
|
||||
{% for attachment in followup.followupattachment_set.all %}{% if forloop.first %}{% trans "Attachments" %}:<div class='attachments'><ul>{% endif %}
|
||||
<li><a href='{{ attachment.file.url }}'>{{ attachment.filename }}</a> ({{ attachment.mime_type }}, {{ attachment.size|filesizeformat }})
|
||||
{% if followup.user and request.user == followup.user %}
|
||||
<a href='{% url 'helpdesk:attachment_del' ticket.id attachment.id %}'><button class="btn btn-danger btn-sm"><i class="fas fa-trash"></i></button></a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% if forloop.last %}</ul></div>{% endif %}
|
||||
{% endfor %}
|
||||
</p>
|
||||
<!--- ugly long test to suppress the following if it will be empty, to save vertical space -->
|
||||
{% with possible=helpdesk_settings.HELPDESK_SHOW_EDIT_BUTTON_FOLLOW_UP %}
|
||||
{% if possible and followup.user and request.user == followup.user and not followup.ticketchange_set.all or possible and user.is_superuser and helpdesk_settings.HELPDESK_SHOW_DELETE_BUTTON_SUPERUSER_FOLLOW_UP %}
|
||||
<small>
|
||||
{% if helpdesk_settings.HELPDESK_SHOW_EDIT_BUTTON_FOLLOW_UP %}
|
||||
{% if followup.user and request.user == followup.user and not followup.ticketchange_set.all %}
|
||||
<a href="{% url 'helpdesk:followup_edit' ticket.id followup.id %}" class='followup-edit'><button type="button" class="btn btn-warning btn-sm float-right"><i class="fas fa-edit"></i></button></a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if user.is_superuser and helpdesk_settings.HELPDESK_SHOW_DELETE_BUTTON_SUPERUSER_FOLLOW_UP %}
|
||||
<a href="{% url 'helpdesk:followup_delete' ticket.id followup.id %}" class='followup-edit'><button type="button" class="btn btn-warning btn-sm float-right"><i class="fas fa-trash"></i></button></a>
|
||||
{% endif %}
|
||||
</small>
|
||||
{% endif %}{% endwith %}
|
||||
</div>
|
||||
<!-- /.list-group-item -->
|
||||
{% endfor %}
|
||||
</div>
|
||||
<!-- /.list-group -->
|
||||
{% if ticket.merged_to %}
|
||||
<div class="card card-body bg-light">
|
||||
<h3 class="text-center">
|
||||
{% trans "This ticket has been merged into ticket" %}
|
||||
<a href="{{ ticket.merged_to.get_absolute_url }}">{{ ticket.merged_to }}</a>
|
||||
</h3>
|
||||
</div>
|
||||
<!-- /.card-body -->
|
||||
</div>
|
||||
<!-- /.card -->
|
||||
|
||||
{% endif %}
|
||||
{% if helpdesk_settings.HELPDESK_TRANSLATE_TICKET_COMMENTS %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">{% trans "Respond to this ticket" %}</div>
|
||||
<div class="card-body">
|
||||
|
||||
<form method='post' action='update/' enctype='multipart/form-data'>
|
||||
|
||||
<fieldset>
|
||||
<dl>
|
||||
{% if preset_replies %}
|
||||
<dt><label for='id_preset'>{% trans "Use a Pre-set Reply" %}</label> <span class='form_optional'>{% trans "(Optional)" %}</span></dt>
|
||||
<dd><select name='preset' id='id_preset'><option value=''>------</option>{% for preset in preset_replies %}<option value='{{ preset.id }}'>{{ preset.name }}</option>{% endfor %}</select></dd>
|
||||
<dd class='form_help_text'>{% trans "Selecting a pre-set reply will over-write your comment below. You can then modify the pre-set reply to your liking before saving this update." %}</dd>
|
||||
{% endif %}
|
||||
|
||||
<dt><label for='commentBox'>{% trans "Comment / Resolution" %}</label></dt>
|
||||
<dd><textarea rows='8' cols='70' name='comment' id='commentBox'></textarea></dd>
|
||||
<dd class='form_help_text'>{% trans "You can insert ticket and queue details in your message. For more information, see the <a href='../../help/context/'>context help page</a>." %}</dd>
|
||||
|
||||
<dt><label>{% trans "New Status" %}</label></dt>
|
||||
{% if not ticket.can_be_resolved %}<dd>{% trans "This ticket cannot be resolved or closed until the tickets it depends on are resolved." %}</dd>{% endif %}
|
||||
{% ifequal ticket.status 1 %}
|
||||
<dd><div class="form-group">
|
||||
<label for='st_open' class='active radio-inline'><input type='radio' name='new_status' value='1' id='st_open' checked='checked'>{% trans "Open" %} »</label>
|
||||
<label for='st_resolved' class="radio-inline"><input type='radio' name='new_status' value='3' id='st_resolved'{% if not ticket.can_be_resolved %} disabled='disabled'{% endif %}>{% trans "Resolved" %} »</label>
|
||||
<label for='st_closed' class="radio-inline"><input type='radio' name='new_status' value='4' id='st_closed'{% if not ticket.can_be_resolved %} disabled='disabled'{% endif %}>{% trans "Closed" %} »</label>
|
||||
<label class="radio-inline" for='st_duplicate'><input type='radio' name='new_status' value='5' id='st_duplicate'>{% trans "Duplicate" %}</label>
|
||||
</div></dd>
|
||||
{% endifequal %}
|
||||
{% ifequal ticket.status 2 %}
|
||||
<dd><div class="form-group">
|
||||
<label for='st_reopened' class='active radio-inline'><input type='radio' name='new_status' value='2' id='st_reopened' checked='checked'>{% trans "Reopened" %} »</label>
|
||||
<label class="radio-inline" for='st_resolved'><input type='radio' name='new_status' value='3' id='st_resolved'{% if not ticket.can_be_resolved %} disabled='disabled'{% endif %}>{% trans "Resolved" %} »</label>
|
||||
<label class="radio-inline" for='st_closed'><input type='radio' name='new_status' value='4' id='st_closed'{% if not ticket.can_be_resolved %} disabled='disabled'{% endif %}>{% trans "Closed" %} »</label>
|
||||
<label class="radio-inline" for='st_duplicate'><input type='radio' name='new_status' value='5' id='st_duplicate'>{% trans "Duplicate" %}</label>
|
||||
</div></dd>
|
||||
{% endifequal %}
|
||||
{% ifequal ticket.status 3 %}
|
||||
<dd><div class="form-group">
|
||||
<label for='st_reopened' class="radio-inline"><input type='radio' name='new_status' value='2' id='st_reopened'>{% trans "Reopened" %} «</label>
|
||||
<label for='st_resolved' class='active radio-inline'><input type='radio' name='new_status' value='3' id='st_resolved' checked='checked'>{% trans "Resolved" %} »</label>
|
||||
<label class="radio-inline" for='st_closed'><input type='radio' name='new_status' value='4' id='st_closed'>{% trans "Closed" %}</label>
|
||||
</div></dd>
|
||||
{% endifequal %}
|
||||
{% ifequal ticket.status 4 %}
|
||||
<dd><div class="form-group"><label for='st_reopened' class="radio-inline"><input type='radio' name='new_status' value='2' id='st_reopened'>{% trans "Reopened" %} «</label>
|
||||
<label class="radio-inline" for='st_closed'><input type='radio' name='new_status' value='4' id='st_closed' checked='checked'>{% trans "Closed" %}</label></div></dd>
|
||||
{% endifequal %}
|
||||
{% ifequal ticket.status 5 %}
|
||||
<dd><div class="form-group">
|
||||
<label class="radio-inline" for='st_reopened'><input type='radio' name='new_status' value='2' id='st_reopened'>{% trans "Reopened" %} «</label>
|
||||
<label class="radio-inline" for='st_duplicate'><input type='radio' name='new_status' value='5' id='st_duplicate' checked='checked'>{% trans "Duplicate" %}</label>
|
||||
</div></dd>
|
||||
{% endifequal %}
|
||||
|
||||
{% if helpdesk_settings.HELPDESK_UPDATE_PUBLIC_DEFAULT %}
|
||||
<input type='hidden' name='public' value='1'>
|
||||
{% else %}
|
||||
<dt>
|
||||
<label for='id_public'>{% trans "Is this update public?" %}</label> <span class='form_optional'>{% trans "(Optional)" %}</span>
|
||||
</dt>
|
||||
<dd><input type='checkbox' name='public' value='1' checked='checked' /> {% trans 'Yes, make this update public.' %}</dd>
|
||||
<dd class='form_help_text'>{% trans "If this is public, the submitter will be e-mailed your comment or resolution." %}</dd>
|
||||
{% endif %}
|
||||
|
||||
{% if user.is_staff %}
|
||||
<dt>
|
||||
<label for='id_time_spent'>{% trans "Time spent" %}</label> <span class='form_optional'>{% trans "(Optional)" %}</span>
|
||||
</dt>
|
||||
<dd><input name='time_spent' type="time" /></dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
|
||||
<p id='ShowFurtherOptPara'><button class="btn btn-warning btn-sm" id='ShowFurtherEditOptions'>{% trans "Change Further Details »" %}</button></p>
|
||||
|
||||
<div id='FurtherEditOptions' style='display: none;'>
|
||||
|
||||
<dl>
|
||||
|
||||
<dt><label for='id_title'>{% trans "Title" %}</label></dt>
|
||||
<dd><input type='text' name='title' value='{{ ticket.title|escape }}' /></dd>
|
||||
|
||||
<dt><label for='id_owner'>{% trans "Owner" %}</label></dt>
|
||||
<dd><select id='id_owner' name='owner'><option value='0'>{% trans "Unassign" %}</option>{% for u in active_users %}<option value='{{ u.id }}' {% ifequal u.id ticket.assigned_to.id %}selected{% endifequal %}>{{ u }}</option>{% endfor %}</select></dd>
|
||||
|
||||
<dt><label for='id_priority'>{% trans "Priority" %}</label></dt>
|
||||
<dd><select id='id_priority' name='priority'>{% for p in priorities %}<option value='{{ p.0 }}'{% ifequal p.0 ticket.priority %} selected='selected'{% endifequal %}>{{ p.1 }}</option>{% endfor %}</select></dd>
|
||||
|
||||
<dt><label for='id_due_date'>{% trans "Due on" %}</label></dt>
|
||||
<dd>{{ form.due_date }}</dd>
|
||||
|
||||
</dl>
|
||||
|
||||
</div>
|
||||
|
||||
<p id='ShowFileUploadPara'><button class="btn btn-warning btn-sm" id='ShowFileUpload'>{% trans "Attach File(s) »" %}</button></p>
|
||||
|
||||
<div id='FileUpload' style='display: none;'>
|
||||
|
||||
<dl>
|
||||
<dt><label for='id_file'>{% trans "Attach a File" %}</label></dt>
|
||||
<dd>
|
||||
<div class="add_file_fields_wrap">
|
||||
<button class="add_file_field_button btn btn-success btn-xs">{% trans "Add Another File" %}</button>
|
||||
<div><label class='btn btn-primary btn-sm btn-file'>
|
||||
Browse... <input type="file" name='attachment' id='file0' style='display: none;'/>
|
||||
</label><span> </span><span id='selectedfilename0'>{% trans 'No files selected.' %}</span></div>
|
||||
{% else %}
|
||||
{% if ticket.followup_set.all %}
|
||||
{% load ticket_to_link %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><i class="fas fa-clock fa-fw fa-lg"></i> {% trans "Follow-Ups" %}</div>
|
||||
<div class="card-body">
|
||||
<div class="list-group">
|
||||
{% for followup in ticket.followup_set.all %}
|
||||
<div class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">{{ followup.title|num_to_link }}</h5>
|
||||
<small><i class="fas fa-clock"></i> <span class='byline text-info'>{% if followup.user %}by {{ followup.user }},{% endif %} <span title='{{ followup.date|date:"r" }}'>{{ followup.date|naturaltime }}</span>{% if followup.time_spent %}, <span>{% trans "time spent" %}: {{ followup.time_spent_formated }}</span>{% endif %} {% if not followup.public %} <span class='private'>({% trans "Private" %})</span>{% endif %}</span></small>
|
||||
</div>
|
||||
<p class="mb-1">
|
||||
{% if followup.comment %}
|
||||
<p>{{ followup.get_markdown|urlizetrunc:50|num_to_link|linebreaksbr }}</p>
|
||||
{% endif %}
|
||||
{% for change in followup.ticketchange_set.all %}
|
||||
{% if forloop.first %}<div class='changes'><ul>{% endif %}
|
||||
<li>{% blocktrans with change.field as field and change.old_value as old_value and change.new_value as new_value %}Changed {{ field }} from {{ old_value }} to {{ new_value }}.{% endblocktrans %}</li>
|
||||
{% if forloop.last %}</ul></div>{% endif %}
|
||||
{% endfor %}
|
||||
{% for attachment in followup.followupattachment_set.all %}{% if forloop.first %}{% trans "Attachments" %}:<div class='attachments'><ul>{% endif %}
|
||||
<li><a href='{{ attachment.file.url }}'>{{ attachment.filename }}</a> ({{ attachment.mime_type }}, {{ attachment.size|filesizeformat }})
|
||||
{% if followup.user and request.user == followup.user %}
|
||||
<a href='{% url 'helpdesk:attachment_del' ticket.id attachment.id %}'><button class="btn btn-danger btn-sm"><i class="fas fa-trash"></i></button></a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% if forloop.last %}</ul></div>{% endif %}
|
||||
{% endfor %}
|
||||
</p>
|
||||
<!--- ugly long test to suppress the following if it will be empty, to save vertical space -->
|
||||
{% with possible=helpdesk_settings.HELPDESK_SHOW_EDIT_BUTTON_FOLLOW_UP %}
|
||||
{% if possible and followup.user and request.user == followup.user and not followup.ticketchange_set.all or possible and user.is_superuser and helpdesk_settings.HELPDESK_SHOW_DELETE_BUTTON_SUPERUSER_FOLLOW_UP %}
|
||||
<small>
|
||||
{% if helpdesk_settings.HELPDESK_SHOW_EDIT_BUTTON_FOLLOW_UP %}
|
||||
{% if followup.user and request.user == followup.user and not followup.ticketchange_set.all %}
|
||||
<a href="{% url 'helpdesk:followup_edit' ticket.id followup.id %}" class='followup-edit'><button type="button" class="btn btn-warning btn-sm float-right"><i class="fas fa-edit"></i></button></a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if user.is_superuser and helpdesk_settings.HELPDESK_SHOW_DELETE_BUTTON_SUPERUSER_FOLLOW_UP %}
|
||||
<a href="{% url 'helpdesk:followup_delete' ticket.id followup.id %}" class='followup-edit'><button type="button" class="btn btn-warning btn-sm float-right"><i class="fas fa-trash"></i></button></a>
|
||||
{% endif %}
|
||||
</small>
|
||||
{% endif %}{% endwith %}
|
||||
</div>
|
||||
<!-- /.list-group-item -->
|
||||
{% endfor %}
|
||||
</div>
|
||||
<!-- /.list-group -->
|
||||
</div>
|
||||
<!-- /.card-body -->
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
<!-- /.card -->
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</fieldset>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">{% trans "Respond to this ticket" %}</div>
|
||||
<div class="card-body">
|
||||
|
||||
<button class="btn btn-primary float-right" type='submit'>{% trans "Update This Ticket" %}</button>
|
||||
<form method='post' action='update/' enctype='multipart/form-data'>
|
||||
|
||||
{% csrf_token %}</form>
|
||||
<fieldset>
|
||||
<dl>
|
||||
{% if preset_replies %}
|
||||
<dt><label for='id_preset'>{% trans "Use a Pre-set Reply" %}</label> <span class='form_optional'>{% trans "(Optional)" %}</span></dt>
|
||||
<dd><select name='preset' id='id_preset'><option value=''>------</option>{% for preset in preset_replies %}<option value='{{ preset.id }}'>{{ preset.name }}</option>{% endfor %}</select></dd>
|
||||
<dd class='form_help_text'>{% trans "Selecting a pre-set reply will over-write your comment below. You can then modify the pre-set reply to your liking before saving this update." %}</dd>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<dt><label for='commentBox'>{% trans "Comment / Resolution" %}</label></dt>
|
||||
<dd><textarea rows='8' cols='70' name='comment' id='commentBox'></textarea></dd>
|
||||
<dd class='form_help_text'>{% trans "You can insert ticket and queue details in your message. For more information, see the <a href='../../help/context/'>context help page</a>." %}</dd>
|
||||
|
||||
<dt><label>{% trans "New Status" %}</label></dt>
|
||||
{% if not ticket.can_be_resolved %}<dd>{% trans "This ticket cannot be resolved or closed until the tickets it depends on are resolved." %}</dd>{% endif %}
|
||||
{% ifequal ticket.status 1 %}
|
||||
<dd><div class="form-group">
|
||||
<label for='st_open' class='active radio-inline'><input type='radio' name='new_status' value='1' id='st_open' checked='checked'>{% trans "Open" %} »</label>
|
||||
<label for='st_resolved' class="radio-inline"><input type='radio' name='new_status' value='3' id='st_resolved'{% if not ticket.can_be_resolved %} disabled='disabled'{% endif %}>{% trans "Resolved" %} »</label>
|
||||
<label for='st_closed' class="radio-inline"><input type='radio' name='new_status' value='4' id='st_closed'{% if not ticket.can_be_resolved %} disabled='disabled'{% endif %}>{% trans "Closed" %} »</label>
|
||||
<label class="radio-inline" for='st_duplicate'><input type='radio' name='new_status' value='5' id='st_duplicate'>{% trans "Duplicate" %}</label>
|
||||
</div></dd>
|
||||
{% endifequal %}
|
||||
{% ifequal ticket.status 2 %}
|
||||
<dd><div class="form-group">
|
||||
<label for='st_reopened' class='active radio-inline'><input type='radio' name='new_status' value='2' id='st_reopened' checked='checked'>{% trans "Reopened" %} »</label>
|
||||
<label class="radio-inline" for='st_resolved'><input type='radio' name='new_status' value='3' id='st_resolved'{% if not ticket.can_be_resolved %} disabled='disabled'{% endif %}>{% trans "Resolved" %} »</label>
|
||||
<label class="radio-inline" for='st_closed'><input type='radio' name='new_status' value='4' id='st_closed'{% if not ticket.can_be_resolved %} disabled='disabled'{% endif %}>{% trans "Closed" %} »</label>
|
||||
<label class="radio-inline" for='st_duplicate'><input type='radio' name='new_status' value='5' id='st_duplicate'>{% trans "Duplicate" %}</label>
|
||||
</div></dd>
|
||||
{% endifequal %}
|
||||
{% ifequal ticket.status 3 %}
|
||||
<dd><div class="form-group">
|
||||
<label for='st_reopened' class="radio-inline"><input type='radio' name='new_status' value='2' id='st_reopened'>{% trans "Reopened" %} «</label>
|
||||
<label for='st_resolved' class='active radio-inline'><input type='radio' name='new_status' value='3' id='st_resolved' checked='checked'>{% trans "Resolved" %} »</label>
|
||||
<label class="radio-inline" for='st_closed'><input type='radio' name='new_status' value='4' id='st_closed'>{% trans "Closed" %}</label>
|
||||
</div></dd>
|
||||
{% endifequal %}
|
||||
{% ifequal ticket.status 4 %}
|
||||
<dd><div class="form-group"><label for='st_reopened' class="radio-inline"><input type='radio' name='new_status' value='2' id='st_reopened'>{% trans "Reopened" %} «</label>
|
||||
<label class="radio-inline" for='st_closed'><input type='radio' name='new_status' value='4' id='st_closed' checked='checked'>{% trans "Closed" %}</label></div></dd>
|
||||
{% endifequal %}
|
||||
{% ifequal ticket.status 5 %}
|
||||
<dd><div class="form-group">
|
||||
<label class="radio-inline" for='st_reopened'><input type='radio' name='new_status' value='2' id='st_reopened'>{% trans "Reopened" %} «</label>
|
||||
<label class="radio-inline" for='st_duplicate'><input type='radio' name='new_status' value='5' id='st_duplicate' checked='checked'>{% trans "Duplicate" %}</label>
|
||||
</div></dd>
|
||||
{% endifequal %}
|
||||
|
||||
{% if helpdesk_settings.HELPDESK_UPDATE_PUBLIC_DEFAULT %}
|
||||
<input type='hidden' name='public' value='1'>
|
||||
{% else %}
|
||||
<dt>
|
||||
<label for='id_public'>{% trans "Is this update public?" %}</label> <span class='form_optional'>{% trans "(Optional)" %}</span>
|
||||
</dt>
|
||||
<dd><input type='checkbox' name='public' value='1' checked='checked' /> {% trans 'Yes, make this update public.' %}</dd>
|
||||
<dd class='form_help_text'>{% trans "If this is public, the submitter will be e-mailed your comment or resolution." %}</dd>
|
||||
{% endif %}
|
||||
|
||||
{% if user.is_staff %}
|
||||
<dt>
|
||||
<label for='id_time_spent'>{% trans "Time spent" %}</label> <span class='form_optional'>{% trans "(Optional)" %}</span>
|
||||
</dt>
|
||||
<dd><input name='time_spent' type="time" /></dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
|
||||
<p id='ShowFurtherOptPara'><button class="btn btn-warning btn-sm" id='ShowFurtherEditOptions'>{% trans "Change Further Details »" %}</button></p>
|
||||
|
||||
<div id='FurtherEditOptions' style='display: none;'>
|
||||
|
||||
<dl>
|
||||
|
||||
<dt><label for='id_title'>{% trans "Title" %}</label></dt>
|
||||
<dd><input type='text' name='title' value='{{ ticket.title|escape }}' /></dd>
|
||||
|
||||
<dt><label for='id_owner'>{% trans "Owner" %}</label></dt>
|
||||
<dd><select id='id_owner' name='owner'><option value='0'>{% trans "Unassign" %}</option>{% for u in active_users %}<option value='{{ u.id }}' {% ifequal u.id ticket.assigned_to.id %}selected{% endifequal %}>{{ u }}</option>{% endfor %}</select></dd>
|
||||
|
||||
<dt><label for='id_priority'>{% trans "Priority" %}</label></dt>
|
||||
<dd><select id='id_priority' name='priority'>{% for p in priorities %}<option value='{{ p.0 }}'{% ifequal p.0 ticket.priority %} selected='selected'{% endifequal %}>{{ p.1 }}</option>{% endfor %}</select></dd>
|
||||
|
||||
<dt><label for='id_due_date'>{% trans "Due on" %}</label></dt>
|
||||
<dd>{{ form.due_date }}</dd>
|
||||
|
||||
</dl>
|
||||
|
||||
</div>
|
||||
|
||||
<p id='ShowFileUploadPara'><button class="btn btn-warning btn-sm" id='ShowFileUpload'>{% trans "Attach File(s) »" %}</button></p>
|
||||
|
||||
<div id='FileUpload' style='display: none;'>
|
||||
|
||||
<dl>
|
||||
<dt><label for='id_file'>{% trans "Attach a File" %}</label></dt>
|
||||
<dd>
|
||||
<div class="add_file_fields_wrap">
|
||||
<button class="add_file_field_button btn btn-success btn-xs">{% trans "Add Another File" %}</button>
|
||||
<div><label class='btn btn-primary btn-sm btn-file'>
|
||||
Browse... <input type="file" name='attachment' id='file0' style='display: none;'/>
|
||||
</label><span> </span><span id='selectedfilename0'>{% trans 'No files selected.' %}</span></div>
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
</div>
|
||||
|
||||
</fieldset>
|
||||
|
||||
<button class="btn btn-primary float-right" type='submit'>{% trans "Update This Ticket" %}</button>
|
||||
|
||||
{% csrf_token %}</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
@ -21,7 +21,7 @@
|
||||
{% for customfield in ticket.ticketcustomfieldvalue_set.all %}
|
||||
<tr>
|
||||
<th class="table-secondary">{{ customfield.field.label }}</th>
|
||||
<td>{% ifequal customfield.field.data_type "url" %}<a href='{{ customfield.value }}'>{{ customfield.value }}</a>{% else %}{{ customfield.value }}{% endifequal %}</td>
|
||||
<td>{% ifequal customfield.field.data_type "url" %}<a href='{{ customfield.value }}'>{{ customfield.value }}</a>{% else %}{{ customfield.value|default:"" }}{% endifequal %}</td>
|
||||
</tr>{% endfor %}
|
||||
<tr>
|
||||
<th class="table-active">{% trans "Due Date" %}</th>
|
||||
|
@ -59,7 +59,7 @@
|
||||
<tr>
|
||||
<th> </th>
|
||||
<th>{% trans "Ticket" %}</th>
|
||||
<th>{% trans "Prority" %}</th>
|
||||
<th>{% trans "Priority" %}</th>
|
||||
<th>{% trans "Queue" %}</th>
|
||||
<th>{% trans "Status" %}</th>
|
||||
<th>{% trans "Created" %}</th>
|
||||
@ -88,6 +88,7 @@
|
||||
<select name='action' id='id_mass_action'>
|
||||
<option value='take'>{% trans "Take (Assign to me)" %}</option>
|
||||
<option value='delete'>{% trans "Delete" %}</option>
|
||||
<option value='merge'>{% trans "Merge" %}</option>
|
||||
<optgroup label='{% trans "Close" %}'>
|
||||
<option value='close'>{% trans "Close (Don't Send E-Mail)" %}</option>
|
||||
<option value='close_public'>{% trans "Close (Send E-Mail)" %}</option>
|
||||
|
105
helpdesk/templates/helpdesk/ticket_merge.html
Normal file
105
helpdesk/templates/helpdesk/ticket_merge.html
Normal file
@ -0,0 +1,105 @@
|
||||
{% 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>
|
||||
<input name="chosen_ticket" type="radio" value="{{ ticket.id }}"{% if forloop.first %} checked{% endif %}>
|
||||
{{ ticket }}
|
||||
<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-alt"></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)
|
@ -4,6 +4,8 @@ from django.core import mail
|
||||
from django.urls import reverse
|
||||
from django.test import TestCase
|
||||
from django.test.client import Client
|
||||
from django.utils import timezone
|
||||
|
||||
from helpdesk.models import CustomField, Queue, Ticket
|
||||
from helpdesk import settings as helpdesk_settings
|
||||
|
||||
@ -206,3 +208,100 @@ class TicketActionsTestCase(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# TODO this needs to be checked further
|
||||
|
||||
def test_merge_tickets(self):
|
||||
self.loginUser()
|
||||
|
||||
# Create two tickets
|
||||
ticket_1 = Ticket.objects.create(
|
||||
queue=self.queue_public,
|
||||
title='Ticket 1',
|
||||
description='Description from ticket 1',
|
||||
submitter_email='user1@mail.com',
|
||||
status=Ticket.RESOLVED_STATUS,
|
||||
resolution='Awesome resolution for ticket 1'
|
||||
)
|
||||
ticket_1_follow_up = ticket_1.followup_set.create(title='Ticket 1 creation')
|
||||
ticket_1_cc = ticket_1.ticketcc_set.create(user=self.user)
|
||||
ticket_1_created = ticket_1.created
|
||||
due_date = timezone.now()
|
||||
ticket_2 = Ticket.objects.create(
|
||||
queue=self.queue_public,
|
||||
title='Ticket 2',
|
||||
description='Description from ticket 2',
|
||||
submitter_email='user2@mail.com',
|
||||
due_date=due_date,
|
||||
assigned_to=self.user
|
||||
)
|
||||
ticket_2_follow_up = ticket_1.followup_set.create(title='Ticket 2 creation')
|
||||
ticket_2_cc = ticket_2.ticketcc_set.create(email='random@mail.com')
|
||||
|
||||
# Create custom fields and set values for tickets
|
||||
custom_field_1 = CustomField.objects.create(
|
||||
name='test',
|
||||
label='Test',
|
||||
data_type='varchar',
|
||||
)
|
||||
ticket_1_field_1 = 'This is for the test field'
|
||||
ticket_1.ticketcustomfieldvalue_set.create(field=custom_field_1, value=ticket_1_field_1)
|
||||
ticket_2_field_1 = 'Another test text'
|
||||
ticket_2.ticketcustomfieldvalue_set.create(field=custom_field_1, value=ticket_2_field_1)
|
||||
custom_field_2 = CustomField.objects.create(
|
||||
name='number',
|
||||
label='Number',
|
||||
data_type='integer',
|
||||
)
|
||||
ticket_2_field_2 = '444'
|
||||
ticket_2.ticketcustomfieldvalue_set.create(field=custom_field_2, value=ticket_2_field_2)
|
||||
|
||||
# Check that it correctly redirects to the intermediate page
|
||||
response = self.client.post(
|
||||
reverse('helpdesk:mass_update'),
|
||||
data={
|
||||
'ticket_id': [str(ticket_1.id), str(ticket_2.id)],
|
||||
'action': 'merge'
|
||||
},
|
||||
follow=True
|
||||
)
|
||||
redirect_url = '%s?tickets=%s&tickets=%s' % (reverse('helpdesk:merge_tickets'), ticket_1.id, ticket_2.id)
|
||||
self.assertRedirects(response, redirect_url)
|
||||
self.assertContains(response, ticket_1.description)
|
||||
self.assertContains(response, ticket_1.resolution)
|
||||
self.assertContains(response, ticket_1.submitter_email)
|
||||
self.assertContains(response, ticket_1_field_1)
|
||||
self.assertContains(response, ticket_2.description)
|
||||
self.assertContains(response, ticket_2.submitter_email)
|
||||
self.assertContains(response, ticket_2_field_1)
|
||||
self.assertContains(response, ticket_2_field_2)
|
||||
|
||||
# Check that the merge is correctly done
|
||||
response = self.client.post(
|
||||
redirect_url,
|
||||
data={
|
||||
'chosen_ticket': str(ticket_1.id),
|
||||
'due_date': str(ticket_2.id),
|
||||
'status': str(ticket_1.id),
|
||||
'submitter_email': str(ticket_2.id),
|
||||
'description': str(ticket_2.id),
|
||||
'assigned_to': str(ticket_2.id),
|
||||
custom_field_1.name: str(ticket_1.id),
|
||||
custom_field_2.name: str(ticket_2.id),
|
||||
},
|
||||
follow=True
|
||||
)
|
||||
self.assertRedirects(response, ticket_1.get_absolute_url())
|
||||
ticket_2.refresh_from_db()
|
||||
self.assertEqual(ticket_2.merged_to, ticket_1)
|
||||
self.assertEqual(ticket_2.followup_set.count(), 0)
|
||||
self.assertEqual(ticket_2.ticketcc_set.count(), 0)
|
||||
ticket_1.refresh_from_db()
|
||||
self.assertEqual(ticket_1.created, ticket_1_created)
|
||||
self.assertEqual(ticket_1.due_date, due_date)
|
||||
self.assertEqual(ticket_1.status, Ticket.RESOLVED_STATUS)
|
||||
self.assertEqual(ticket_1.submitter_email, ticket_2.submitter_email)
|
||||
self.assertEqual(ticket_1.description, ticket_2.description)
|
||||
self.assertEqual(ticket_1.assigned_to, ticket_2.assigned_to)
|
||||
self.assertEqual(ticket_1.ticketcustomfieldvalue_set.get(field=custom_field_1).value, ticket_1_field_1)
|
||||
self.assertEqual(ticket_1.ticketcustomfieldvalue_set.get(field=custom_field_2).value, ticket_2_field_2)
|
||||
self.assertEqual(list(ticket_1.followup_set.all()), [ticket_1_follow_up, ticket_2_follow_up])
|
||||
self.assertEqual(list(ticket_1.ticketcc_set.all()), [ticket_1_cc, ticket_2_cc])
|
||||
|
@ -1,10 +1,14 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.urls import reverse
|
||||
from django.test import TestCase
|
||||
from helpdesk.models import Ticket, Queue
|
||||
from django.test.utils import override_settings
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@override_settings(
|
||||
HELPDESK_VIEW_A_TICKET_PUBLIC=True
|
||||
)
|
||||
@ -50,3 +54,42 @@ class TestTicketLookupPublicEnabled(TestCase):
|
||||
# confirm that we can still get to a url which was emailed earlier
|
||||
response = self.client.get(url, params)
|
||||
self.assertNotContains(response, "Invalid ticket ID")
|
||||
|
||||
def test_add_email_to_ticketcc_if_not_in(self):
|
||||
staff_email = 'staff@mail.com'
|
||||
staff_user = User.objects.create(username='staff', email=staff_email, is_staff=True)
|
||||
self.ticket.assigned_to = staff_user
|
||||
self.ticket.save()
|
||||
email_1 = 'user1@mail.com'
|
||||
ticketcc_1 = self.ticket.ticketcc_set.create(email=email_1)
|
||||
|
||||
# Add new email to CC
|
||||
email_2 = 'user2@mail.com'
|
||||
ticketcc_2 = self.ticket.add_email_to_ticketcc_if_not_in(email=email_2)
|
||||
self.assertEqual(list(self.ticket.ticketcc_set.all()), [ticketcc_1, ticketcc_2])
|
||||
|
||||
# Add existing email, doesn't change anything
|
||||
self.ticket.add_email_to_ticketcc_if_not_in(email=email_1)
|
||||
self.assertEqual(list(self.ticket.ticketcc_set.all()), [ticketcc_1, ticketcc_2])
|
||||
|
||||
# Add mail from assigned user, doesn't change anything
|
||||
self.ticket.add_email_to_ticketcc_if_not_in(email=staff_email)
|
||||
self.assertEqual(list(self.ticket.ticketcc_set.all()), [ticketcc_1, ticketcc_2])
|
||||
self.ticket.add_email_to_ticketcc_if_not_in(user=staff_user)
|
||||
self.assertEqual(list(self.ticket.ticketcc_set.all()), [ticketcc_1, ticketcc_2])
|
||||
|
||||
# Move a ticketCC from ticket 1 to ticket 2
|
||||
ticket_2 = Ticket.objects.create(queue=self.ticket.queue, title='Ticket 2', submitter_email=email_2)
|
||||
self.assertEqual(ticket_2.ticketcc_set.count(), 0)
|
||||
ticket_2.add_email_to_ticketcc_if_not_in(ticketcc=ticketcc_1)
|
||||
self.assertEqual(ticketcc_1.ticket, ticket_2)
|
||||
self.assertEqual(ticket_2.ticketcc_set.count(), 1)
|
||||
|
||||
# Adding email_2 doesn't change since it is already submitter email
|
||||
ticket_2.add_email_to_ticketcc_if_not_in(email=email_2)
|
||||
self.assertEqual(ticket_2.ticketcc_set.get(), ticketcc_1)
|
||||
ticket_2.add_email_to_ticketcc_if_not_in(ticketcc=ticketcc_2)
|
||||
self.assertEqual(ticket_2.ticketcc_set.get(), ticketcc_1)
|
||||
|
||||
# Finally test function raises a Value error when no parameter is given
|
||||
self.assertRaises(ValueError, ticket_2.add_email_to_ticketcc_if_not_in)
|
||||
|
@ -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<ticket_id>[0-9]+)/$',
|
||||
staff.view_ticket,
|
||||
name='view'),
|
||||
|
@ -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,151 @@ 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')),
|
||||
('assigned_to', _('Owner')),
|
||||
('description', _('Description')),
|
||||
('resolution', _('Resolution')),
|
||||
)
|
||||
|
||||
|
||||
@staff_member_required
|
||||
def merge_tickets(request):
|
||||
"""
|
||||
An intermediate view to merge up to 3 tickets in one main ticket.
|
||||
The user has to first select which ticket will receive the other tickets information and can also choose which
|
||||
data to keep per attributes as well as custom fields.
|
||||
Follow-ups and ticketCC will be moved to the main ticket and other tickets won't be able to receive new answers.
|
||||
"""
|
||||
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 attribute, display_name in ticket_attributes:
|
||||
value = getattr(ticket, attribute, default)
|
||||
# Check if attr is a get_FIELD_display
|
||||
if attribute.startswith('get_') and attribute.endswith('_display'):
|
||||
# Hack to call methods like get_FIELD_display()
|
||||
value = getattr(ticket, attribute, default)()
|
||||
ticket.values[attribute] = {
|
||||
'value': value,
|
||||
'checked': str(ticket.id) == request.POST.get(attribute)
|
||||
}
|
||||
# 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 attribute, display_name in ticket_attributes:
|
||||
id_for_attribute = request.POST.get(attribute)
|
||||
if id_for_attribute != chosen_ticket.id:
|
||||
try:
|
||||
selected_ticket = tickets.get(id=id_for_attribute)
|
||||
except (Ticket.DoesNotExist, ValueError):
|
||||
continue
|
||||
|
||||
# Check if attr is a get_FIELD_display
|
||||
if attribute.startswith('get_') and attribute.endswith('_display'):
|
||||
# Keep only the FIELD part
|
||||
attribute = attribute[4:-8]
|
||||
# Get value from selected ticket and then save it on the chosen ticket
|
||||
value = getattr(selected_ticket, attribute)
|
||||
setattr(chosen_ticket, attribute, value)
|
||||
# Save custom fields values
|
||||
for custom_field in custom_fields:
|
||||
id_for_custom_field = request.POST.get(custom_field.name)
|
||||
if id_for_custom_field != chosen_ticket.id:
|
||||
try:
|
||||
selected_ticket = tickets.get(id=id_for_custom_field)
|
||||
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 the ticket in which they have been merged to
|
||||
# and set status to 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 #%(id)d] %(title)s') % {'id': ticket.id, 'title': 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 = {}
|
||||
|
Loading…
Reference in New Issue
Block a user