diff --git a/helpdesk/admin.py b/helpdesk/admin.py
index d51cf209..f5dc76af 100644
--- a/helpdesk/admin.py
+++ b/helpdesk/admin.py
@@ -3,6 +3,9 @@ from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from helpdesk import settings as helpdesk_settings
from helpdesk.models import (
+ Checklist,
+ ChecklistTask,
+ ChecklistTemplate,
CustomField,
EmailTemplate,
EscalationExclusion,
@@ -41,6 +44,7 @@ class TicketAdmin(admin.ModelAdmin):
'hidden_submitter_email', 'time_spent')
date_hierarchy = 'created'
list_filter = ('queue', 'assigned_to', 'status')
+ search_fields = ('id', 'title')
def hidden_submitter_email(self, ticket):
if ticket.submitter_email:
@@ -115,5 +119,24 @@ class IgnoreEmailAdmin(admin.ModelAdmin):
list_display = ('name', 'queue_list', 'email_address', 'keep_in_mailbox')
+@admin.register(ChecklistTemplate)
+class ChecklistTemplateAdmin(admin.ModelAdmin):
+ list_display = ('name', 'task_list')
+ search_fields = ('name', 'task_list')
+
+
+class ChecklistTaskInline(admin.TabularInline):
+ model = ChecklistTask
+
+
+@admin.register(Checklist)
+class ChecklistAdmin(admin.ModelAdmin):
+ list_display = ('name', 'ticket')
+ search_fields = ('name', 'ticket__id', 'ticket__title')
+ autocomplete_fields = ('ticket',)
+ list_select_related = ('ticket',)
+ inlines = (ChecklistTaskInline,)
+
+
admin.site.register(PreSetReply)
admin.site.register(EscalationExclusion)
diff --git a/helpdesk/forms.py b/helpdesk/forms.py
index 8a6781d2..f9abe18c 100644
--- a/helpdesk/forms.py
+++ b/helpdesk/forms.py
@@ -7,7 +7,6 @@ forms.py - Definitions of newforms-based forms for creating and maintaining
tickets.
"""
-
from datetime import datetime
from django import forms
from django.conf import settings
@@ -18,6 +17,8 @@ from django.utils.translation import gettext_lazy as _
from helpdesk import settings as helpdesk_settings
from helpdesk.lib import convert_value, process_attachments, safe_template_context
from helpdesk.models import (
+ Checklist,
+ ChecklistTemplate,
CustomField,
FollowUp,
IgnoreEmail,
@@ -602,3 +603,46 @@ class MultipleTicketSelectForm(forms.Form):
raise ValidationError(
_('All selected tickets must share the same queue in order to be merged.'))
return tickets
+
+
+class ChecklistTemplateForm(forms.ModelForm):
+ name = forms.CharField(
+ widget=forms.TextInput(attrs={'class': 'form-control'}),
+ required=True,
+ )
+ task_list = forms.JSONField(widget=forms.HiddenInput())
+
+ class Meta:
+ model = ChecklistTemplate
+ fields = ('name', 'task_list')
+
+ def clean_task_list(self):
+ task_list = self.cleaned_data['task_list']
+ return list(map(lambda task: task.strip(), task_list))
+
+
+class ChecklistForm(forms.ModelForm):
+ name = forms.CharField(
+ widget=forms.TextInput(attrs={'class': 'form-control'}),
+ required=True,
+ )
+
+ class Meta:
+ model = Checklist
+ fields = ('name',)
+
+
+class CreateChecklistForm(ChecklistForm):
+ checklist_template = forms.ModelChoiceField(
+ label=_("Template"),
+ queryset=ChecklistTemplate.objects.all(),
+ widget=forms.Select(attrs={'class': 'form-control'}),
+ required=False,
+ )
+
+ class Meta(ChecklistForm.Meta):
+ fields = ('checklist_template', 'name')
+
+
+class FormControlDeleteFormSet(forms.BaseInlineFormSet):
+ deletion_widget = forms.CheckboxInput(attrs={'class': 'form-control'})
diff --git a/helpdesk/migrations/0038_checklist_checklisttemplate_checklisttask.py b/helpdesk/migrations/0038_checklist_checklisttemplate_checklisttask.py
new file mode 100644
index 00000000..a3c80616
--- /dev/null
+++ b/helpdesk/migrations/0038_checklist_checklisttemplate_checklisttask.py
@@ -0,0 +1,54 @@
+# Generated by Django 4.2 on 2023-04-28 21:23
+
+from django.db import migrations, models
+import django.db.models.deletion
+import helpdesk.models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('helpdesk', '0037_alter_queue_email_box_type'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Checklist',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=100, verbose_name='Name')),
+ ('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='checklists', to='helpdesk.ticket', verbose_name='Ticket')),
+ ],
+ options={
+ 'verbose_name': 'Checklist',
+ 'verbose_name_plural': 'Checklists',
+ },
+ ),
+ migrations.CreateModel(
+ name='ChecklistTemplate',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=100, verbose_name='Name')),
+ ('task_list', models.JSONField(validators=[helpdesk.models.is_a_list_without_empty_element], verbose_name='Task List')),
+ ],
+ options={
+ 'verbose_name': 'Checklist Template',
+ 'verbose_name_plural': 'Checklist Templates',
+ },
+ ),
+ migrations.CreateModel(
+ name='ChecklistTask',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('description', models.CharField(max_length=250, verbose_name='Description')),
+ ('completion_date', models.DateTimeField(blank=True, null=True, verbose_name='Completion Date')),
+ ('position', models.PositiveSmallIntegerField(db_index=True, verbose_name='Position')),
+ ('checklist', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tasks', to='helpdesk.checklist', verbose_name='Checklist')),
+ ],
+ options={
+ 'verbose_name': 'Checklist Task',
+ 'verbose_name_plural': 'Checklist Tasks',
+ 'ordering': ('position',),
+ },
+ ),
+ ]
diff --git a/helpdesk/models.py b/helpdesk/models.py
index 27151171..eeca54e2 100644
--- a/helpdesk/models.py
+++ b/helpdesk/models.py
@@ -2002,3 +2002,92 @@ class TicketDependency(models.Model):
def __str__(self):
return '%s / %s' % (self.ticket, self.depends_on)
+
+
+def is_a_list_without_empty_element(task_list):
+ if not isinstance(task_list, list):
+ raise ValidationError(f'{task_list} is not a list')
+ for task in task_list:
+ if not isinstance(task, str):
+ raise ValidationError(f'{task} is not a string')
+ if task.strip() == '':
+ raise ValidationError('A task cannot be an empty string')
+
+
+class ChecklistTemplate(models.Model):
+ name = models.CharField(
+ verbose_name=_('Name'),
+ max_length=100
+ )
+ task_list = models.JSONField(verbose_name=_('Task List'), validators=[is_a_list_without_empty_element])
+
+ class Meta:
+ verbose_name = _('Checklist Template')
+ verbose_name_plural = _('Checklist Templates')
+
+ def __str__(self):
+ return self.name
+
+
+class Checklist(models.Model):
+ ticket = models.ForeignKey(
+ Ticket,
+ on_delete=models.CASCADE,
+ verbose_name=_('Ticket'),
+ related_name='checklists',
+ )
+ name = models.CharField(
+ verbose_name=_('Name'),
+ max_length=100
+ )
+
+ class Meta:
+ verbose_name = _('Checklist')
+ verbose_name_plural = _('Checklists')
+
+ def __str__(self):
+ return self.name
+
+ def create_tasks_from_template(self, template):
+ for position, task in enumerate(template.task_list):
+ self.tasks.create(description=task, position=position)
+
+
+class ChecklistTaskQuerySet(models.QuerySet):
+ def todo(self):
+ return self.filter(completion_date__isnull=True)
+
+ def completed(self):
+ return self.filter(completion_date__isnull=False)
+
+
+class ChecklistTask(models.Model):
+ checklist = models.ForeignKey(
+ Checklist,
+ on_delete=models.CASCADE,
+ verbose_name=_('Checklist'),
+ related_name='tasks',
+ )
+ description = models.CharField(
+ verbose_name=_('Description'),
+ max_length=250
+ )
+ completion_date = models.DateTimeField(
+ verbose_name=_('Completion Date'),
+ null=True,
+ blank=True
+ )
+ position = models.PositiveSmallIntegerField(
+ verbose_name=_('Position'),
+ db_index=True
+ )
+
+ objects = ChecklistTaskQuerySet.as_manager()
+
+ class Meta:
+ verbose_name = _('Checklist Task')
+ verbose_name_plural = _('Checklist Tasks')
+ ordering = ('position',)
+
+ def __str__(self):
+ return self.description
diff --git a/helpdesk/static/helpdesk/helpdesk-extend.css b/helpdesk/static/helpdesk/helpdesk-extend.css
index cdd6f2f0..f36f08d9 100644
--- a/helpdesk/static/helpdesk/helpdesk-extend.css
+++ b/helpdesk/static/helpdesk/helpdesk-extend.css
@@ -102,3 +102,7 @@ table .tickettitle {
overflow: hidden;
text-overflow: ellipsis;
}
+
+.handle {
+ cursor: grab;
+}
diff --git a/helpdesk/templates/helpdesk/checklist_confirm_delete.html b/helpdesk/templates/helpdesk/checklist_confirm_delete.html
new file mode 100644
index 00000000..1e5a697f
--- /dev/null
+++ b/helpdesk/templates/helpdesk/checklist_confirm_delete.html
@@ -0,0 +1,45 @@
+{% extends "helpdesk/base.html" %}
+
+{% load i18n %}
+
+{% block helpdesk_title %}{% trans "Delete Checklist" %}{% endblock %}
+
+{% block helpdesk_breadcrumb %}
+
+ {% trans "Tickets" %}
+
+
+ {{ ticket.queue.slug }}-{{ ticket.id }}
+
+ {{ checklist.name }}
+{% endblock %}
+
+{% block helpdesk_body %}
+
+{% endblock %}
diff --git a/helpdesk/templates/helpdesk/checklist_form.html b/helpdesk/templates/helpdesk/checklist_form.html
new file mode 100644
index 00000000..1b1026b8
--- /dev/null
+++ b/helpdesk/templates/helpdesk/checklist_form.html
@@ -0,0 +1,103 @@
+{% extends "helpdesk/base.html" %}
+
+{% load i18n %}
+
+{% block helpdesk_title %}{% trans "Edit Checklist" %}{% endblock %}
+
+{% block helpdesk_breadcrumb %}
+
+ {% trans "Tickets" %}
+
+
+ {{ ticket.queue.slug }}-{{ ticket.id }}
+
+ {{ checklist.name }}
+{% endblock %}
+
+{% block helpdesk_body %}
+
+
+
+ {% if form.non_field_errors %}
+
+ {{ form.non_field_errors }}
+
+ {% endif %}
+
+
+
+{% endblock %}
+
+{% block helpdesk_js %}
+
+
+
+{% endblock %}
diff --git a/helpdesk/templates/helpdesk/checklist_template_confirm_delete.html b/helpdesk/templates/helpdesk/checklist_template_confirm_delete.html
new file mode 100644
index 00000000..d1da22bb
--- /dev/null
+++ b/helpdesk/templates/helpdesk/checklist_template_confirm_delete.html
@@ -0,0 +1,47 @@
+{% extends "helpdesk/base.html" %}
+
+{% load i18n %}
+
+{% block helpdesk_title %}{% trans "Delete Checklist Template" %}{% endblock %}
+
+{% block helpdesk_breadcrumb %}
+
+ {% trans "System Settings" %}
+
+
+ {% trans "Checklist Templates" %}
+
+
+ {{ checklist_template }}
+
+{% endblock %}
+
+{% block helpdesk_body %}
+
+{% endblock %}
diff --git a/helpdesk/templates/helpdesk/checklist_templates.html b/helpdesk/templates/helpdesk/checklist_templates.html
new file mode 100644
index 00000000..b8d2c6e1
--- /dev/null
+++ b/helpdesk/templates/helpdesk/checklist_templates.html
@@ -0,0 +1,119 @@
+{% extends "helpdesk/base.html" %}
+
+{% load i18n %}
+
+{% block helpdesk_title %}{% trans "Checklist Templates" %}{% endblock %}
+
+{% block helpdesk_breadcrumb %}
+
+ {% trans "System Settings" %}
+
+ {% trans "Checklist Templates" %}
+{% endblock %}
+
+{% block helpdesk_body %}
+ {% trans "Maintain checklist templates" %}
+
+
+
+
+ {% for checklist in checklists %}
+
+
+ {{ checklist.name }}
+ {% if checklist_template.id != checklist.id %}
+
+
+
+ {% endif %}
+
+
+
+
+
{{ checklist.task_list|length }} {% trans "tasks" %}
+
+ {% endfor %}
+
+
+
+{% endblock %}
+
+{% block helpdesk_js %}
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/helpdesk/templates/helpdesk/email_ignore_list.html b/helpdesk/templates/helpdesk/email_ignore_list.html
index 3dda68b7..ad03e8b1 100644
--- a/helpdesk/templates/helpdesk/email_ignore_list.html
+++ b/helpdesk/templates/helpdesk/email_ignore_list.html
@@ -2,6 +2,13 @@
{% block helpdesk_title %}{% trans "Ignored E-Mail Addresses" %}{% endblock %}
+{% block helpdesk_breadcrumb %}
+
+ {% trans "System Settings" %}
+
+ Ignored E-Mail Addresses
+{% endblock %}
+
{% block helpdesk_body %}{% blocktrans %}
Ignored E-Mail Addresses
diff --git a/helpdesk/templates/helpdesk/include/task_form_row.html b/helpdesk/templates/helpdesk/include/task_form_row.html
new file mode 100644
index 00000000..bfe9f3da
--- /dev/null
+++ b/helpdesk/templates/helpdesk/include/task_form_row.html
@@ -0,0 +1,16 @@
+
+ {{ form.id }}
+
+
+ {{ form.position }}
+ {{ form.position.errors }}
+
+
+ {{ form.description }}
+ {{ form.description.errors }}
+
+
+ {{ form.DELETE }}
+ {{ form.DELETE.errors }}
+
+
\ No newline at end of file
diff --git a/helpdesk/templates/helpdesk/include/template_task_form_row.html b/helpdesk/templates/helpdesk/include/template_task_form_row.html
new file mode 100644
index 00000000..77a3af75
--- /dev/null
+++ b/helpdesk/templates/helpdesk/include/template_task_form_row.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/helpdesk/templates/helpdesk/system_settings.html b/helpdesk/templates/helpdesk/system_settings.html
index ad41f2b6..b09f86bd 100644
--- a/helpdesk/templates/helpdesk/system_settings.html
+++ b/helpdesk/templates/helpdesk/system_settings.html
@@ -18,6 +18,7 @@
{% trans "E-Mail Ignore list" %}
+ {% trans "Checklist Templates" %}
{% trans "Maintain Queues" %}
{% trans "Maintain Pre-Set Replies" %}
{% if helpdesk_settings.HELPDESK_KB_ENABLED %}
diff --git a/helpdesk/templates/helpdesk/ticket.html b/helpdesk/templates/helpdesk/ticket.html
index 265a449d..bf817d52 100644
--- a/helpdesk/templates/helpdesk/ticket.html
+++ b/helpdesk/templates/helpdesk/ticket.html
@@ -6,9 +6,6 @@
{% block helpdesk_title %}{{ ticket.queue.slug }}-{{ ticket.id }} : {% trans "View Ticket Details" %}{% endblock %}
-{% block helpdesk_head %}
-{% endblock %}
-
{% block h1_title %}{{ ticket.ticket_for_url }}{% endblock %}
{% block helpdesk_breadcrumb %}
@@ -166,7 +163,7 @@
{% endif %}
- {% trans "Change Further Details »" %}
+ {% trans "Change Further Details »" %}
@@ -188,7 +185,41 @@
- {% trans "Attach File(s) »" %}
+ {% if ticket.checklists.exists %}
+
+
+ {% trans "Update checklists" %} »
+
+
+
+
+
+ {% for checklist in ticket.checklists.all %}
+
+
+
+
+
+ {% for task in checklist.tasks.all %}
+
+
+
+ {{ task }}
+
+
+ {% endfor %}
+
+
+
+
+ {% endfor %}
+
+
+ {% endif %}
+
+ {% trans "Attach File(s) »" %}
@@ -214,6 +245,33 @@
+
+
{% endif %}
{% endblock %}
@@ -229,13 +287,15 @@ $( function() {
$(document).ready(function() {
$("#ShowFurtherEditOptions").click(function() {
$("#FurtherEditOptions").toggle();
- return false;
+ });
+
+ $("#ShowChecklistEditOptions").click(function() {
+ $("#checklistEdit").toggle();
});
$("#ShowFileUpload").click(function() {
$("#FileUpload").fadeIn();
$("#ShowFileUploadPara").hide();
- return false;
});
$('#id_preset').change(function() {
@@ -247,6 +307,19 @@ $(document).ready(function() {
}
});
+ // Preset name of checklist when a template is selected
+ $('#id_checklist_template').on('change', function() {
+ const nameField = $('#id_name')
+ const selectedTemplate = $(this).children(':selected')
+ if (nameField.val() === '' && selectedTemplate.val()) {
+ nameField.val(selectedTemplate.text())
+ }
+ })
+
+ $('.disabledTask').on('click', () => {
+ alert('{% trans 'If you want to update state of checklist tasks, please do a Follow-Up response and click on "Update checklists"' %}')
+ })
+
$("[data-toggle=tooltip]").tooltip();
// lists for file input change events, then updates the associated text label
diff --git a/helpdesk/templates/helpdesk/ticket_desc_table.html b/helpdesk/templates/helpdesk/ticket_desc_table.html
index 4a09a58b..8cf00829 100644
--- a/helpdesk/templates/helpdesk/ticket_desc_table.html
+++ b/helpdesk/templates/helpdesk/ticket_desc_table.html
@@ -10,130 +10,254 @@
- {{ ticket.queue.slug }}-{{ ticket.id }}. {{ ticket.title }} [{{ ticket.get_status }}]
+
+
+ {{ ticket.queue.slug }}-{{ ticket.id }}. {{ ticket.title }} [{{ ticket.get_status }}]
{% blocktrans with ticket.queue as queue %}Queue: {{ queue }}{% endblocktrans %}
- {% trans "Edit" %}
- | {% trans "Delete" %}
- |
- {% if ticket.on_hold %}
-
- {% else %}
-
- {% endif %}
-
+
+ {% trans "Edit" %}
+
+ |
+ {% trans "Delete" %}
+
+ | {% if ticket.on_hold %}
+
+ {% else %}
+
+ {% endif %}
+
+
+
{% for customfield in ticket.ticketcustomfieldvalue_set.all %}
-
- {{ customfield.field.label }}
- {% spaceless %}{% if "url" == customfield.field.data_type %}{{ customfield.value }}
- {% elif "datetime" == customfield.field.data_type %}{{ customfield.value|datetime_string_format }}
- {% elif "date" == customfield.field.data_type %}{{ customfield.value|datetime_string_format }}
- {% elif "time" == customfield.field.data_type %}{{ customfield.value|datetime_string_format }}
- {% else %}{{ customfield.value|default:"" }}
- {% endif %}{% endspaceless %}
- {% endfor %}
+
+ {{ customfield.field.label }}
+
+ {% spaceless %}
+ {% if "url" == customfield.field.data_type %}
+ {{ customfield.value }}
+ {% elif "datetime" == customfield.field.data_type %}
+ {{ customfield.value|datetime_string_format }}
+ {% elif "date" == customfield.field.data_type %}
+ {{ customfield.value|datetime_string_format }}
+ {% elif "time" == customfield.field.data_type %}
+ {{ customfield.value|datetime_string_format }}
+ {% else %}
+ {{ customfield.value|default:"" }}
+ {% endif %}
+ {% endspaceless %}
+
+
+ {% endfor %}
{% trans "Due Date" %}
- {{ ticket.due_date|date:"DATETIME_FORMAT" }} {% if ticket.due_date %}({{ ticket.due_date|naturaltime }}){% endif %}
+
+ {{ ticket.due_date|date:"DATETIME_FORMAT" }}
+ {% if ticket.due_date %}({{ ticket.due_date|naturaltime }}){% endif %}
{% trans "Submitted On" %}
{{ ticket.created|date:"DATETIME_FORMAT" }} ({{ ticket.created|naturaltime }})
{% trans "Assigned To" %}
- {{ ticket.get_assigned_to }}{% if _('Unassigned') == ticket.get_assigned_to %}
-
- {% endif %}
+
+ {{ ticket.get_assigned_to }}
+ {% if _('Unassigned') == ticket.get_assigned_to %}
+
+
+
+ {% endif %}
{% trans "Submitter E-Mail" %}
- {{ ticket.submitter_email }}
- {% if user.is_superuser %} {% if submitter_userprofile_url %} {% endif %}
-
-
-
- {% endif %}
+
+ {{ ticket.submitter_email }}
+ {% if user.is_superuser %}
+ {% if submitter_userprofile_url %}
+
+
+
+ {% endif %}
+
+
+
+
+
+
+ {% endif %}
{% trans "Priority" %}
- {{ ticket.get_priority_display }}
+
+ {{ ticket.get_priority_display }}
{% trans "Copies To" %}
- {{ ticketcc_string }} {% if SHOW_SUBSCRIBE %} {% endif %}
+
+ {{ ticketcc_string }}
+
+
+
+ {% if SHOW_SUBSCRIBE %}
+
+
+
+ {% endif %}
+
{% if helpdesk_settings.HELPDESK_ENABLE_DEPENDENCIES_ON_TICKET != False and helpdesk_settings.HELPDESK_ENABLE_TIME_SPENT_ON_TICKET != False %}
-
- {% if helpdesk_settings.HELPDESK_ENABLE_DEPENDENCIES_ON_TICKET %}
- {% trans "Dependencies" %}
-
-
- {% for dep in ticket.ticketdependency.all %}
- {% if forloop.first %}{% trans "This ticket cannot be resolved until the following ticket(s) are resolved" %}
{% endif %}
- {% empty %}
- {% trans "This ticket has no dependencies." %}
- {% endfor %}
-
- {% else %}
-
-
- {% endif %}
- {% if helpdesk_settings.HELPDESK_ENABLE_TIME_SPENT_ON_TICKET %}
- {% trans "Total time spent" %}
- {{ ticket.time_spent_formated }}
- {% else %}
-
-
- {% endif %}
-
+
+ {% if helpdesk_settings.HELPDESK_ENABLE_DEPENDENCIES_ON_TICKET %}
+ {% trans "Dependencies" %}
+
+
+ {% for dep in ticket.ticketdependency.all %}
+ {% if forloop.first %}{% trans "This ticket cannot be resolved until the following ticket(s) are resolved" %}
{% endif %}
+ {% empty %}
+ {% trans "This ticket has no dependencies." %}
+ {% endfor %}
+
+ {% else %}
+
+
+ {% endif %}
+ {% if helpdesk_settings.HELPDESK_ENABLE_TIME_SPENT_ON_TICKET %}
+ {% trans "Total time spent" %}
+ {{ ticket.time_spent_formated }}
+ {% else %}
+
+
+ {% endif %}
+
{% endif %}
{% if ticket.kbitem %}
-
- {% trans "Knowlegebase item" %}
- {{ticket.kbitem}}
-
+
+ {% trans "Knowlegebase item" %}
+ {{ticket.kbitem}}
+
{% endif %}
{% trans "Attachments" %}
{% for followup in ticket.followup_set.all %}
- {% for attachment in followup.followupattachment_set.all %}
- {{ attachment.filename }} ({{ attachment.mime_type }}, {{ attachment.size|filesizeformat }})
- {% if followup.user and request.user == followup.user %}
-
- {% endif %}
-
- {% endfor %}
+ {% for attachment in followup.followupattachment_set.all %}
+
+
+ {{ attachment.filename }}
+ ({{ attachment.mime_type }}, {{ attachment.size|filesizeformat }})
+ {% if followup.user and request.user == followup.user %}
+
+
+
+ {% endif %}
+
+ {% endfor %}
{% endfor %}
-
- {% trans "Description" %}
- {{ ticket.get_markdown|urlizetrunc:50|num_to_link }}
-
-
- {% if ticket.resolution %}
- {% trans "Resolution" %}{% if "Resolved" == ticket.get_status_display %} {% trans "Accept and Close" %} {% endif %}
+ {% trans "Checklists" %}
+
+
+
+ {% for checklist in ticket.checklists.all %}
+
+
+
+
+
+ {% for task in checklist.tasks.all %}
+
+
+
+ {{ task }}
+
+
+ {% endfor %}
+
+
+ {% if checklist.tasks.completed.count %}
+
+ {% endif %}
+
+
+ {% endfor %}
+
+
+
+ {% trans "Create new checklist" %}
+
+
+
+
+
- {{ ticket.get_resolution_markdown|urlizetrunc:50|linebreaksbr }}
- {% endif %}
+
+ {% trans "Description" %}
+ {{ ticket.get_markdown|urlizetrunc:50|num_to_link }}
+
+
+
+ {% if ticket.resolution %}
+
+
+ {% trans "Resolution" %}
+ {% if "Resolved" == ticket.get_status_display %}
+
+
+ {% trans "Accept and Close" %}
+
+
+ {% endif %}
+
+
+
+ {{ ticket.get_resolution_markdown|urlizetrunc:50|linebreaksbr }}
+
+ {% endif %}
-
{% trans "Edit details" %}
+
+
+ {% trans "Edit details" %}
+
+
diff --git a/helpdesk/tests/test_checklist.py b/helpdesk/tests/test_checklist.py
new file mode 100644
index 00000000..190b2173
--- /dev/null
+++ b/helpdesk/tests/test_checklist.py
@@ -0,0 +1,252 @@
+from datetime import datetime
+from django.contrib.auth import get_user_model
+from django.test import TestCase
+from django.urls import reverse
+from helpdesk.models import Checklist, ChecklistTask, ChecklistTemplate, Queue, Ticket
+
+
+class TicketChecklistTestCase(TestCase):
+ @classmethod
+ def setUpTestData(cls):
+ user = get_user_model().objects.create_user('User', password='pass')
+ user.is_staff = True
+ user.save()
+ cls.user = user
+
+ def setUp(self) -> None:
+ self.client.login(username='User', password='pass')
+
+ self.ticket = Ticket.objects.create(queue=Queue.objects.create(title='Queue', slug='queue'))
+
+ def test_create_checklist(self):
+ self.assertEqual(self.ticket.checklists.count(), 0)
+ checklist_name = 'test empty checklist'
+
+ response = self.client.post(
+ reverse('helpdesk:view', kwargs={'ticket_id': self.ticket.id}),
+ data={'name': checklist_name},
+ follow=True
+ )
+
+ self.assertEqual(response.status_code, 200)
+ self.assertTemplateUsed(response, 'helpdesk/checklist_form.html')
+ self.assertContains(response, checklist_name)
+
+ self.assertEqual(self.ticket.checklists.count(), 1)
+
+ def test_create_checklist_from_template(self):
+ self.assertEqual(self.ticket.checklists.count(), 0)
+ checklist_name = 'test checklist from template'
+
+ checklist_template = ChecklistTemplate.objects.create(
+ name='Test template',
+ task_list=['first', 'second', 'last']
+ )
+
+ response = self.client.post(
+ reverse('helpdesk:view', kwargs={'ticket_id': self.ticket.id}),
+ data={'name': checklist_name, 'checklist_template': checklist_template.id},
+ follow=True
+ )
+
+ self.assertEqual(response.status_code, 200)
+ self.assertTemplateUsed(response, 'helpdesk/checklist_form.html')
+ self.assertContains(response, checklist_name)
+
+ self.assertEqual(self.ticket.checklists.count(), 1)
+ created_checklist = self.ticket.checklists.get()
+ self.assertEqual(created_checklist.tasks.count(), 3)
+ self.assertEqual(created_checklist.tasks.all()[0].description, 'first')
+ self.assertEqual(created_checklist.tasks.all()[1].description, 'second')
+ self.assertEqual(created_checklist.tasks.all()[2].description, 'last')
+
+ def test_edit_checklist(self):
+ checklist = self.ticket.checklists.create(name='Test checklist')
+ first_task = checklist.tasks.create(description='First task', position=1)
+ checklist.tasks.create(description='To delete task', position=2)
+
+ url = reverse('helpdesk:edit_ticket_checklist', kwargs={
+ 'ticket_id': self.ticket.id,
+ 'checklist_id': checklist.id,
+ })
+
+ response = self.client.get(url)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertTemplateUsed(response, 'helpdesk/checklist_form.html')
+ self.assertContains(response, 'Test checklist')
+ self.assertContains(response, 'First task')
+ self.assertContains(response, 'To delete task')
+
+ response = self.client.post(
+ url,
+ data={
+ 'name': 'New name',
+ 'tasks-TOTAL_FORMS': 3,
+ 'tasks-INITIAL_FORMS': 2,
+ 'tasks-0-id': '1',
+ 'tasks-0-description': 'First task edited',
+ 'tasks-0-position': '2',
+ 'tasks-1-id': '2',
+ 'tasks-1-description': 'To delete task',
+ 'tasks-1-DELETE': 'on',
+ 'tasks-1-position': '2',
+ 'tasks-2-description': 'New first task',
+ 'tasks-2-position': '1',
+ },
+ follow=True
+ )
+
+ self.assertEqual(response.status_code, 200)
+ self.assertTemplateUsed(response, 'helpdesk/ticket.html')
+
+ checklist.refresh_from_db()
+ self.assertEqual(checklist.name, 'New name')
+ self.assertEqual(checklist.tasks.count(), 2)
+ first_task.refresh_from_db()
+ self.assertEqual(first_task.description, 'First task edited')
+ self.assertEqual(checklist.tasks.all()[0].description, 'New first task')
+ self.assertEqual(checklist.tasks.all()[1].description, 'First task edited')
+
+ def test_delete_checklist(self):
+ checklist = self.ticket.checklists.create(name='Test checklist')
+ checklist.tasks.create(description='First task', position=1)
+ self.assertEqual(Checklist.objects.count(), 1)
+ self.assertEqual(ChecklistTask.objects.count(), 1)
+
+ response = self.client.post(
+ reverse(
+ 'helpdesk:delete_ticket_checklist',
+ kwargs={'ticket_id': self.ticket.id, 'checklist_id': checklist.id}
+ ),
+ follow=True
+ )
+
+ self.assertEqual(response.status_code, 200)
+ self.assertTemplateUsed(response, 'helpdesk/ticket.html')
+
+ self.assertEqual(Checklist.objects.count(), 0)
+ self.assertEqual(ChecklistTask.objects.count(), 0)
+
+ def test_mark_task_as_done(self):
+ checklist = self.ticket.checklists.create(name='Test checklist')
+ task = checklist.tasks.create(description='Task', position=1)
+ self.assertIsNone(task.completion_date)
+
+ self.assertEqual(self.ticket.followup_set.count(), 0)
+
+ response = self.client.post(
+ reverse('helpdesk:update', kwargs={'ticket_id': self.ticket.id}),
+ data={
+ f'checklist-{checklist.id}': task.id
+ },
+ follow=True
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertTemplateUsed(response, 'helpdesk/ticket.html')
+
+ self.assertEqual(self.ticket.followup_set.count(), 1)
+ followup = self.ticket.followup_set.get()
+ self.assertEqual(followup.ticketchange_set.count(), 1)
+ self.assertEqual(followup.ticketchange_set.get().old_value, 'To do')
+ self.assertEqual(followup.ticketchange_set.get().new_value, 'Completed')
+
+ task.refresh_from_db()
+ self.assertIsNotNone(task.completion_date)
+
+ def test_mark_task_as_undone(self):
+ checklist = self.ticket.checklists.create(name='Test checklist')
+ task = checklist.tasks.create(description='Task', position=1, completion_date=datetime(2023, 5, 1))
+ self.assertIsNotNone(task.completion_date)
+
+ self.assertEqual(self.ticket.followup_set.count(), 0)
+
+ response = self.client.post(
+ reverse('helpdesk:update', kwargs={'ticket_id': self.ticket.id}),
+ follow=True
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertTemplateUsed(response, 'helpdesk/ticket.html')
+
+ self.assertEqual(self.ticket.followup_set.count(), 1)
+ followup = self.ticket.followup_set.get()
+ self.assertEqual(followup.ticketchange_set.count(), 1)
+ self.assertEqual(followup.ticketchange_set.get().old_value, 'Completed')
+ self.assertEqual(followup.ticketchange_set.get().new_value, 'To do')
+
+ task.refresh_from_db()
+ self.assertIsNone(task.completion_date)
+
+ def test_display_checklist_templates(self):
+ ChecklistTemplate.objects.create(
+ name='Test checklist template',
+ task_list=['first', 'second', 'third']
+ )
+
+ response = self.client.get(reverse('helpdesk:checklist_templates'))
+
+ self.assertEqual(response.status_code, 200)
+ self.assertTemplateUsed(response, 'helpdesk/checklist_templates.html')
+ self.assertContains(response, 'Test checklist template')
+ self.assertContains(response, '3 tasks')
+
+ def test_create_checklist_template(self):
+ self.assertEqual(ChecklistTemplate.objects.count(), 0)
+
+ response = self.client.post(
+ reverse('helpdesk:checklist_templates'),
+ data={
+ 'name': 'Test checklist template',
+ 'task_list': '["first", "second", "third"]'
+ },
+ follow=True
+ )
+
+ self.assertEqual(response.status_code, 200)
+ self.assertTemplateUsed(response, 'helpdesk/checklist_templates.html')
+
+ self.assertEqual(ChecklistTemplate.objects.count(), 1)
+ checklist_template = ChecklistTemplate.objects.get()
+ self.assertEqual(checklist_template.name, 'Test checklist template')
+ self.assertEqual(checklist_template.task_list, ['first', 'second', 'third'])
+
+ def test_edit_checklist_template(self):
+ checklist_template = ChecklistTemplate.objects.create(
+ name='Test checklist template',
+ task_list=['first', 'second', 'third']
+ )
+ self.assertEqual(ChecklistTemplate.objects.count(), 1)
+
+ response = self.client.post(
+ reverse('helpdesk:edit_checklist_template', kwargs={'checklist_template_id': checklist_template.id}),
+ data={
+ 'name': 'New checklist template',
+ 'task_list': '["new first", "second", "third", "last"]'
+ },
+ follow=True
+ )
+
+ self.assertEqual(response.status_code, 200)
+ self.assertTemplateUsed(response, 'helpdesk/checklist_templates.html')
+
+ self.assertEqual(ChecklistTemplate.objects.count(), 1)
+ checklist_template.refresh_from_db()
+ self.assertEqual(checklist_template.name, 'New checklist template')
+ self.assertEqual(checklist_template.task_list, ['new first', 'second', 'third', 'last'])
+
+ def test_delete_checklist_template(self):
+ checklist_template = ChecklistTemplate.objects.create(
+ name='Test checklist template',
+ task_list=['first', 'second', 'third']
+ )
+ self.assertEqual(ChecklistTemplate.objects.count(), 1)
+
+ response = self.client.post(
+ reverse('helpdesk:delete_checklist_template', kwargs={'checklist_template_id': checklist_template.id}),
+ follow=True
+ )
+
+ self.assertEqual(response.status_code, 200)
+ self.assertTemplateUsed(response, 'helpdesk/checklist_templates.html')
+
+ self.assertEqual(ChecklistTemplate.objects.count(), 0)
diff --git a/helpdesk/tests/test_ticket_actions.py b/helpdesk/tests/test_ticket_actions.py
index d3809005..abf95c3f 100644
--- a/helpdesk/tests/test_ticket_actions.py
+++ b/helpdesk/tests/test_ticket_actions.py
@@ -1,6 +1,4 @@
from django.contrib.auth import get_user_model
-from django.contrib.sites.models import Site
-from django.core import mail
from django.test import TestCase
from django.test.client import Client
from django.urls import reverse
@@ -38,6 +36,7 @@ class TicketActionsTestCase(TestCase):
)
self.ticket_data = {
+ 'queue': self.queue_public,
'title': 'Test Ticket',
'description': 'Some Test Ticket',
}
@@ -73,8 +72,7 @@ class TicketActionsTestCase(TestCase):
self.loginUser()
"""Tests whether staff can delete tickets"""
- ticket_data = dict(queue=self.queue_public, **self.ticket_data)
- ticket = Ticket.objects.create(**ticket_data)
+ ticket = Ticket.objects.create(**self.ticket_data)
ticket_id = ticket.id
response = self.client.get(reverse('helpdesk:delete', kwargs={
diff --git a/helpdesk/urls.py b/helpdesk/urls.py
index 1d001e18..6f7587d3 100644
--- a/helpdesk/urls.py
+++ b/helpdesk/urls.py
@@ -93,6 +93,16 @@ urlpatterns = [
staff.attachment_del,
name="attachment_del",
),
+ path(
+ "tickets//checklists//",
+ staff.edit_ticket_checklist,
+ name="edit_ticket_checklist"
+ ),
+ path(
+ "tickets//checklists//delete/",
+ staff.delete_ticket_checklist,
+ name="delete_ticket_checklist"
+ ),
re_path(r"^raw/(?P\w+)/$", staff.raw_details, name="raw"),
path("rss/", staff.rss_list, name="rss_index"),
path("reports/", staff.report_index, name="report_index"),
@@ -105,6 +115,17 @@ urlpatterns = [
path("ignore/add/", staff.email_ignore_add, name="email_ignore_add"),
path("ignore/delete//",
staff.email_ignore_del, name="email_ignore_del"),
+ path("checklist-templates/", staff.checklist_templates, name="checklist_templates"),
+ path(
+ "checklist-templates//",
+ staff.checklist_templates,
+ name="edit_checklist_template"
+ ),
+ path(
+ "checklist-templates//delete/",
+ staff.delete_checklist_template,
+ name="delete_checklist_template"
+ ),
re_path(
r"^datatables_ticket_list/(?P{})$".format(base64_pattern),
staff.datatables_ticket_list,
diff --git a/helpdesk/views/staff.py b/helpdesk/views/staff.py
index 934a9af9..d25dac0c 100644
--- a/helpdesk/views/staff.py
+++ b/helpdesk/views/staff.py
@@ -6,6 +6,7 @@ django-helpdesk - A Django powered ticket tracker for small enterprise.
views/staff.py - The bulk of the application - provides most business logic and
renders all staff-facing views.
"""
+
from ..lib import format_time_spent
from ..templated_email import send_templated_mail
from collections import defaultdict
@@ -20,6 +21,7 @@ from django.core.exceptions import PermissionDenied, ValidationError
from django.core.handlers.wsgi import WSGIRequest
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
from django.db.models import Q
+from django.forms import HiddenInput, inlineformset_factory, TextInput
from django.http import Http404, HttpResponse, HttpResponseRedirect, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse, reverse_lazy
@@ -38,10 +40,14 @@ from helpdesk.decorators import (
superuser_required
)
from helpdesk.forms import (
+ ChecklistForm,
+ ChecklistTemplateForm,
+ CreateChecklistForm,
CUSTOMFIELD_DATE_FORMAT,
EditFollowUpForm,
EditTicketForm,
EmailIgnoreForm,
+ FormControlDeleteFormSet,
MultipleTicketSelectForm,
TicketCCEmailForm,
TicketCCForm,
@@ -52,6 +58,9 @@ from helpdesk.forms import (
)
from helpdesk.lib import process_attachments, queue_template_context, safe_template_context
from helpdesk.models import (
+ Checklist,
+ ChecklistTask,
+ ChecklistTemplate,
CustomField,
FollowUp,
FollowUpAttachment,
@@ -406,6 +415,19 @@ def view_ticket(request, ticket_id):
else:
submitter_userprofile_url = None
+ checklist_form = CreateChecklistForm(request.POST or None)
+ if checklist_form.is_valid():
+ checklist = checklist_form.save(commit=False)
+ checklist.ticket = ticket
+ checklist.save()
+
+ checklist_template = checklist_form.cleaned_data.get('checklist_template')
+ # Add predefined tasks if template has been selected
+ if checklist_template:
+ checklist.create_tasks_from_template(checklist_template)
+
+ return redirect('helpdesk:edit_ticket_checklist', ticket.id, checklist.id)
+
return render(request, 'helpdesk/ticket.html', {
'ticket': ticket,
'submitter_userprofile_url': submitter_userprofile_url,
@@ -416,6 +438,56 @@ def view_ticket(request, ticket_id):
Q(queues=ticket.queue) | Q(queues__isnull=True)),
'ticketcc_string': ticketcc_string,
'SHOW_SUBSCRIBE': show_subscribe,
+ 'checklist_form': checklist_form,
+ })
+
+
+@helpdesk_staff_member_required
+def edit_ticket_checklist(request, ticket_id, checklist_id):
+ ticket = get_object_or_404(Ticket, id=ticket_id)
+ ticket_perm_check(request, ticket)
+ checklist = get_object_or_404(ticket.checklists.all(), id=checklist_id)
+
+ form = ChecklistForm(request.POST or None, instance=checklist)
+ TaskFormSet = inlineformset_factory(
+ Checklist,
+ ChecklistTask,
+ formset=FormControlDeleteFormSet,
+ fields=['description', 'position'],
+ widgets={
+ 'position': HiddenInput(),
+ 'description': TextInput(attrs={'class': 'form-control'}),
+ },
+ can_delete=True,
+ extra=0
+ )
+ formset = TaskFormSet(request.POST or None, instance=checklist)
+ if form.is_valid() and formset.is_valid():
+ form.save()
+ formset.save()
+ return redirect(ticket)
+
+ return render(request, 'helpdesk/checklist_form.html', {
+ 'ticket': ticket,
+ 'checklist': checklist,
+ 'form': form,
+ 'formset': formset,
+ })
+
+
+@helpdesk_staff_member_required
+def delete_ticket_checklist(request, ticket_id, checklist_id):
+ ticket = get_object_or_404(Ticket, id=ticket_id)
+ ticket_perm_check(request, ticket)
+ checklist = get_object_or_404(ticket.checklists.all(), id=checklist_id)
+
+ if request.method == 'POST':
+ checklist.delete()
+ return redirect(ticket)
+
+ return render(request, 'helpdesk/checklist_confirm_delete.html', {
+ 'ticket': ticket,
+ 'checklist': checklist,
})
@@ -669,6 +741,14 @@ def update_ticket(request, ticket_id, public=False):
owner = int(request.POST.get('owner', -1))
priority = int(request.POST.get('priority', ticket.priority))
+ # Check if a change happened on checklists
+ changes_in_checklists = False
+ for checklist in ticket.checklists.all():
+ old_completed_id = sorted(list(checklist.tasks.completed().values_list('id', flat=True)))
+ new_completed_id = sorted(list(map(int, request.POST.getlist(f'checklist-{checklist.id}', []))))
+ if old_completed_id != new_completed_id:
+ changes_in_checklists = True
+
time_spent = get_time_spent_from_request(request)
# NOTE: jQuery's default for dates is mm/dd/yy
# very US-centric but for now that's the only format supported
@@ -677,6 +757,7 @@ def update_ticket(request, ticket_id, public=False):
no_changes = all([
not request.FILES,
not comment,
+ not changes_in_checklists,
new_status == ticket.status,
title == ticket.title,
priority == int(ticket.priority),
@@ -785,6 +866,30 @@ def update_ticket(request, ticket_id, public=False):
c.save()
ticket.due_date = due_date
+ if changes_in_checklists:
+ for checklist in ticket.checklists.all():
+ new_completed_tasks = list(map(int, request.POST.getlist(f'checklist-{checklist.id}', [])))
+ for task in checklist.tasks.all():
+ changed = None
+
+ # Add completion if it was not done yet
+ if not task.completion_date and task.id in new_completed_tasks:
+ task.completion_date = timezone.now()
+ changed = 'completed'
+ # Remove it if it was done before
+ elif task.completion_date and task.id not in new_completed_tasks:
+ task.completion_date = None
+ changed = 'uncompleted'
+
+ # Save and add ticket change if task state has changed
+ if changed:
+ task.save(update_fields=['completion_date'])
+ f.ticketchange_set.create(
+ field=f'[{checklist.name}] {task.description}',
+ old_value=_('To do') if changed == 'completed' else _('Completed'),
+ new_value=_('Completed') if changed == 'completed' else _('To do'),
+ )
+
if new_status in (
Ticket.RESOLVED_STATUS, Ticket.CLOSED_STATUS
) and (
@@ -2030,3 +2135,30 @@ def date_rel_to_today(today, offset):
def sort_string(begin, end):
return 'sort=created&date_from=%s&date_to=%s&status=%s&status=%s&status=%s' % (
begin, end, Ticket.OPEN_STATUS, Ticket.REOPENED_STATUS, Ticket.RESOLVED_STATUS)
+
+
+@helpdesk_staff_member_required
+def checklist_templates(request, checklist_template_id=None):
+ checklist_template = None
+ if checklist_template_id:
+ checklist_template = get_object_or_404(ChecklistTemplate, id=checklist_template_id)
+ form = ChecklistTemplateForm(request.POST or None, instance=checklist_template)
+ if form.is_valid():
+ form.save()
+ return redirect('helpdesk:checklist_templates')
+ return render(request, 'helpdesk/checklist_templates.html', {
+ 'checklists': ChecklistTemplate.objects.all(),
+ 'checklist_template': checklist_template,
+ 'form': form
+ })
+
+
+@helpdesk_staff_member_required
+def delete_checklist_template(request, checklist_template_id):
+ checklist_template = get_object_or_404(ChecklistTemplate, id=checklist_template_id)
+ if request.method == 'POST':
+ checklist_template.delete()
+ return redirect('helpdesk:checklist_templates')
+ return render(request, 'helpdesk/checklist_template_confirm_delete.html', {
+ 'checklist_template': checklist_template,
+ })