Merge pull request #1090 from Benbb96/checklist

New Checklist feature
This commit is contained in:
Christopher Broderick 2023-05-03 10:58:14 +01:00 committed by GitHub
commit 1ce945467c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 1260 additions and 95 deletions

View File

@ -3,6 +3,9 @@ from django.contrib import admin
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from helpdesk import settings as helpdesk_settings from helpdesk import settings as helpdesk_settings
from helpdesk.models import ( from helpdesk.models import (
Checklist,
ChecklistTask,
ChecklistTemplate,
CustomField, CustomField,
EmailTemplate, EmailTemplate,
EscalationExclusion, EscalationExclusion,
@ -41,6 +44,7 @@ class TicketAdmin(admin.ModelAdmin):
'hidden_submitter_email', 'time_spent') 'hidden_submitter_email', 'time_spent')
date_hierarchy = 'created' date_hierarchy = 'created'
list_filter = ('queue', 'assigned_to', 'status') list_filter = ('queue', 'assigned_to', 'status')
search_fields = ('id', 'title')
def hidden_submitter_email(self, ticket): def hidden_submitter_email(self, ticket):
if ticket.submitter_email: if ticket.submitter_email:
@ -115,5 +119,24 @@ class IgnoreEmailAdmin(admin.ModelAdmin):
list_display = ('name', 'queue_list', 'email_address', 'keep_in_mailbox') 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(PreSetReply)
admin.site.register(EscalationExclusion) admin.site.register(EscalationExclusion)

View File

@ -7,7 +7,6 @@ forms.py - Definitions of newforms-based forms for creating and maintaining
tickets. tickets.
""" """
from datetime import datetime from datetime import datetime
from django import forms from django import forms
from django.conf import settings 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 import settings as helpdesk_settings
from helpdesk.lib import convert_value, process_attachments, safe_template_context from helpdesk.lib import convert_value, process_attachments, safe_template_context
from helpdesk.models import ( from helpdesk.models import (
Checklist,
ChecklistTemplate,
CustomField, CustomField,
FollowUp, FollowUp,
IgnoreEmail, IgnoreEmail,
@ -602,3 +603,46 @@ class MultipleTicketSelectForm(forms.Form):
raise ValidationError( raise ValidationError(
_('All selected tickets must share the same queue in order to be merged.')) _('All selected tickets must share the same queue in order to be merged.'))
return tickets 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'})

View File

@ -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',),
},
),
]

View File

@ -2002,3 +2002,92 @@ class TicketDependency(models.Model):
def __str__(self): def __str__(self):
return '%s / %s' % (self.ticket, self.depends_on) 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

View File

@ -102,3 +102,7 @@ table .tickettitle {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.handle {
cursor: grab;
}

View File

@ -0,0 +1,45 @@
{% extends "helpdesk/base.html" %}
{% load i18n %}
{% block helpdesk_title %}{% trans "Delete Checklist" %}{% endblock %}
{% block helpdesk_breadcrumb %}
<li class="breadcrumb-item">
<a href="{% url 'helpdesk:list' %}">{% trans "Tickets" %}</a>
</li>
<li class="breadcrumb-item">
<a href="{% url 'helpdesk:list' %}{{ ticket.id }}/">{{ ticket.queue.slug }}-{{ ticket.id }}</a>
</li>
<li class="breadcrumb-item active">{{ checklist.name }}</li>
{% endblock %}
{% block helpdesk_body %}
<div class="row">
<div class="col-sm-6 offset-sm-3 col-xs-12">
<div class="card">
<div class="card-header">
<h2>
{% trans "Delete Checklist" %}
</h2>
</div>
<div class="card-body">
<form method='post'>
{% csrf_token %}
<p>{% trans "Are you sure your want to delete checklist" %} {{ checklist.name }} ?</p>
<div class='buttons form-group text-center'>
<a class="btn btn-secondary" href='{{ ticket.get_absolute_url }}'>
<i class="fas fa-times"></i>
{% trans "Don't Delete" %}
</a>
<button type='submit' class="btn btn-danger">
<i class="fas fa-trash"></i>
{% trans "Yes, I Understand - Delete" %}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,103 @@
{% extends "helpdesk/base.html" %}
{% load i18n %}
{% block helpdesk_title %}{% trans "Edit Checklist" %}{% endblock %}
{% block helpdesk_breadcrumb %}
<li class="breadcrumb-item">
<a href="{% url 'helpdesk:list' %}">{% trans "Tickets" %}</a>
</li>
<li class="breadcrumb-item">
<a href="{% url 'helpdesk:list' %}{{ ticket.id }}/">{{ ticket.queue.slug }}-{{ ticket.id }}</a>
</li>
<li class="breadcrumb-item active">{{ checklist.name }}</li>
{% endblock %}
{% block helpdesk_body %}
<div class="card">
<div class="card-header">
<h2>
{% trans "Edit Checklist" %}
<a class="btn btn-danger float-right" href='{% url "helpdesk:delete_ticket_checklist" ticket.id checklist.id %}'>
<i class="fas fa-trash"></i>
{% trans "Delete checklist" %}
</a>
</h2>
</div>
<div class="card-body">
{% if form.non_field_errors %}
<p class="text-danger">
{{ form.non_field_errors }}
</p>
{% endif %}
<form method='post'>
{% csrf_token %}
<div class="row">
<div class="col-sm-6 col-xs-12 form-group">
{{ form.name.label_tag }}
{{ form.name }}
{{ form.name.errors }}
</div>
</div>
<h3>Tasks</h3>
{{ formset.management_form }}
<table class="table">
<thead>
<tr>
<th class="text-right">{% trans "Position" %}</th>
<th>{% trans "Description" %}</th>
<th class="text-center">{% trans "Delete?" %}</th>
</tr>
</thead>
<tbody id="tasks">
{% for form in formset %}
{% include 'helpdesk/include/task_form_row.html' %}
{% endfor %}
</tbody>
</table>
<button type="button" class="btn btn-secondary" id="addRow">
<i class="fas fa-plus"></i>
{% trans "Add task" %}
</button>
<div class='buttons form-group text-center'>
<a class="btn btn-link" href='{{ ticket.get_absolute_url }}'>
<i class="fas fa-times"></i>
{% trans "Cancel Changes" %}
</a>
<button type='submit' class="btn btn-primary">
<i class="fas fa-save"></i>
{% trans "Save Changes" %}
</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block helpdesk_js %}
<script src="https://unpkg.com/sortablejs-make/Sortable.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/jquery-sortablejs@latest/jquery-sortable.js"></script>
<script>
const updatePosition = () => {
$('#tasks tr').each((index, taskRow) => {
$(taskRow).find('input[id$=position]').val(index + 1)
})
}
$(() => {
const idTasksTotalForms = $('#id_tasks-TOTAL_FORMS')
$('#addRow').on('click', function() {
const formCount = idTasksTotalForms.val()
$('#tasks').append(`{% include 'helpdesk/include/task_form_row.html' with form=formset.empty_form %}`.replace(/__prefix__/g, formCount))
idTasksTotalForms.val(parseInt(formCount) + 1);
updatePosition()
});
$('#tasks').sortable({
handle: '.handle',
onChange: updatePosition
})
})
</script>
{% endblock %}

View File

@ -0,0 +1,47 @@
{% extends "helpdesk/base.html" %}
{% load i18n %}
{% block helpdesk_title %}{% trans "Delete Checklist Template" %}{% endblock %}
{% block helpdesk_breadcrumb %}
<li class="breadcrumb-item">
<a href="{% url 'helpdesk:system_settings' %}">{% trans "System Settings" %}</a>
</li>
<li class="breadcrumb-item">
<a href="{% url 'helpdesk:checklist_templates' %}">{% trans "Checklist Templates" %}</a>
</li>
<li class="breadcrumb-item active">
{{ checklist_template }}
</li>
{% endblock %}
{% block helpdesk_body %}
<div class="row">
<div class="col-sm-6 offset-sm-3 col-xs-12">
<div class="card">
<div class="card-header">
<h2>
{% trans "Delete Checklist Template" %}
</h2>
</div>
<div class="card-body">
<form method='post'>
{% csrf_token %}
<p>{% trans "Are you sure your want to delete checklist template" %} {{ checklist_template.name }} ?</p>
<div class='buttons form-group text-center'>
<a class="btn btn-secondary" href='{% url 'helpdesk:checklist_templates' %}'>
<i class="fas fa-times"></i>
{% trans "Don't Delete" %}
</a>
<button type='submit' class="btn btn-danger">
<i class="fas fa-trash"></i>
{% trans "Yes, I Understand - Delete" %}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,119 @@
{% extends "helpdesk/base.html" %}
{% load i18n %}
{% block helpdesk_title %}{% trans "Checklist Templates" %}{% endblock %}
{% block helpdesk_breadcrumb %}
<li class="breadcrumb-item">
<a href="{% url 'helpdesk:system_settings' %}">{% trans "System Settings" %}</a>
</li>
<li class="breadcrumb-item active">{% trans "Checklist Templates" %}</li>
{% endblock %}
{% block helpdesk_body %}
<h2>{% trans "Maintain checklist templates" %}</h2>
<div class="row mt-4">
<div class="col-sm-6 col-xs-12">
<div class="card">
<div class="card-header">
{% if checklist_template %}
{% trans "Edit checklist template" %}
{% else %}
{% trans "Create new checklist template" %}
{% endif %}
</div>
<form method="post">
<div class="card-body">
{% csrf_token %}
{{ form.as_p }}
<table class="table">
<thead>
<tr>
<th></th>
<th>Task</th>
<th class="text-center">Actions</th>
</tr>
</thead>
<tbody id="tasks">
{% if checklist_template %}
{% for value in checklist_template.task_list %}
{% include 'helpdesk/include/template_task_form_row.html' %}
{% endfor %}
{% else %}
{% include 'helpdesk/include/template_task_form_row.html' %}
{% endif %}
</tbody>
</table>
<button type="button" id="addTask" class="btn btn-sm btn-secondary">
<i class="fas fa-plus"></i>
{% trans "Add another task" %}
</button>
</div>
<div class="card-footer text-center">
{% if checklist_template %}
<a href="{% url 'helpdesk:checklist_templates' %}" class="btn btn-secondary">
<i class="fas fa-times"></i>
{% trans "Cancel" %}
</a>
{% endif %}
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i>
{% trans "Save checklist template" %}
</button>
</div>
</form>
</div>
</div>
<div class="col-sm-6 col-xs-12">
<div class="list-group">
{% for checklist in checklists %}
<div class="list-group-item d-flex justify-content-between align-items-center{% if checklist_template.id == checklist.id %} active{% endif %}">
<span>
{{ checklist.name }}
{% if checklist_template.id != checklist.id %}
<a class="btn btn-secondary btn-sm" href="{% url 'helpdesk:edit_checklist_template' checklist.id %}">
<i class="fas fa-edit"></i>
</a>
{% endif %}
<a class="btn btn-sm btn-danger" href="{% url 'helpdesk:delete_checklist_template' checklist.id %}">
<i class="fas fa-trash"></i>
</a>
</span>
<span class="badge badge-secondary badge-pill">{{ checklist.task_list|length }} {% trans "tasks" %}</span>
</div>
{% endfor %}
</div>
</div>
</div>
{% endblock %}
{% block helpdesk_js %}
<script src="https://unpkg.com/sortablejs-make/Sortable.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/jquery-sortablejs@latest/jquery-sortable.js"></script>
<script>
const updateTemplateTaskList = () => {
let tasks = []
$('#tasks .taskInput').each((index, taskInput) => {
tasks.push($(taskInput).val())
})
$('#id_task_list').val(JSON.stringify(tasks))
}
const removeTask = (btn) => {
$(btn).parents('tr').remove()
updateTemplateTaskList()
}
$(() => {
$('#addTask').on('click', () => {
$('#tasks').append(`{% include 'helpdesk/include/template_task_form_row.html' %}`)
})
$('#tasks').sortable({
handle: '.handle',
onChange: updateTemplateTaskList
})
})
</script>
{% endblock %}

View File

@ -2,6 +2,13 @@
{% block helpdesk_title %}{% trans "Ignored E-Mail Addresses" %}{% endblock %} {% block helpdesk_title %}{% trans "Ignored E-Mail Addresses" %}{% endblock %}
{% block helpdesk_breadcrumb %}
<li class="breadcrumb-item">
<a href="{% url 'helpdesk:system_settings' %}">{% trans "System Settings" %}</a>
</li>
<li class="breadcrumb-item active">Ignored E-Mail Addresses</li>
{% endblock %}
{% block helpdesk_body %}{% blocktrans %} {% block helpdesk_body %}{% blocktrans %}
<h2>Ignored E-Mail Addresses</h2> <h2>Ignored E-Mail Addresses</h2>

View File

@ -0,0 +1,16 @@
<tr>
{{ form.id }}
<td title="Drag & Drop" class="text-right handle">
<i class="fas fa-grip-vertical"></i>
{{ form.position }}
{{ form.position.errors }}
</td>
<td>
{{ form.description }}
{{ form.description.errors }}
</td>
<td>
{{ form.DELETE }}
{{ form.DELETE.errors }}
</td>
</tr>

View File

@ -0,0 +1,13 @@
<tr>
<td title="Drag & Drop" class="text-center handle">
<i class="fas fa-grip-vertical"></i>
</td>
<td>
<input class="form-control taskInput" oninput="updateTemplateTaskList()" required value="{{ value }}">
</td>
<td class="text-center">
<button type="button" class="btn btn-sm btn-danger" onclick="removeTask(this)">
<i class="fas fa-times"></i>
</button>
</td>
</tr>

View File

@ -18,6 +18,7 @@
<ul> <ul>
<li><a href='{% url 'helpdesk:email_ignore' %}'>{% trans "E-Mail Ignore list" %}</a></li> <li><a href='{% url 'helpdesk:email_ignore' %}'>{% trans "E-Mail Ignore list" %}</a></li>
<li><a href='{% url 'helpdesk:checklist_templates' %}'>{% trans "Checklist Templates" %}</a></li>
<li><a href='{% url 'admin:helpdesk_queue_changelist' %}'>{% trans "Maintain Queues" %}</a></li> <li><a href='{% url 'admin:helpdesk_queue_changelist' %}'>{% trans "Maintain Queues" %}</a></li>
<li><a href='{% url 'admin:helpdesk_presetreply_changelist' %}'>{% trans "Maintain Pre-Set Replies" %}</a></li> <li><a href='{% url 'admin:helpdesk_presetreply_changelist' %}'>{% trans "Maintain Pre-Set Replies" %}</a></li>
{% if helpdesk_settings.HELPDESK_KB_ENABLED %} {% if helpdesk_settings.HELPDESK_KB_ENABLED %}

View File

@ -6,9 +6,6 @@
{% block helpdesk_title %}{{ ticket.queue.slug }}-{{ ticket.id }} : {% trans "View Ticket Details" %}{% endblock %} {% 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 h1_title %}{{ ticket.ticket_for_url }}{% endblock %}
{% block helpdesk_breadcrumb %} {% block helpdesk_breadcrumb %}
@ -166,7 +163,7 @@
{% endif %} {% endif %}
</dl> </dl>
<p id='ShowFurtherOptPara'><button class="btn btn-warning btn-sm" id='ShowFurtherEditOptions'>{% trans "Change Further Details &raquo;" %}</button></p> <p id='ShowFurtherOptPara'><button type="button" class="btn btn-warning btn-sm" id='ShowFurtherEditOptions'>{% trans "Change Further Details &raquo;" %}</button></p>
<div id='FurtherEditOptions' style='display: none;'> <div id='FurtherEditOptions' style='display: none;'>
@ -188,7 +185,41 @@
</div> </div>
<p id='ShowFileUploadPara'><button class="btn btn-warning btn-sm" id='ShowFileUpload'>{% trans "Attach File(s) &raquo;" %}</button></p> {% if ticket.checklists.exists %}
<p>
<button type="button" class="btn btn-warning btn-sm" id='ShowChecklistEditOptions'>
{% trans "Update checklists" %} &raquo;
</button>
</p>
<div id="checklistEdit" style="display: none">
<div class="row">
{% for checklist in ticket.checklists.all %}
<div class="col-sm-4 col-xs-12">
<div class="card mb-4">
<div class="card-header">
<h5>{{ checklist }}</h5>
</div>
<div class="card-body">
<div class="list-group">
{% for task in checklist.tasks.all %}
<div class="list-group-item"{% if task.completion_date %} title="{% trans "Completed on" %} {{ task.completion_date }}" {% endif %}>
<label>
<input type="checkbox" name="checklist-{{ checklist.id }}" value="{{ task.id }}" {% if task.completion_date %} checked{% endif %}>
{{ task }}
</label>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<p id='ShowFileUploadPara'><button type="button" class="btn btn-warning btn-sm" id='ShowFileUpload'>{% trans "Attach File(s) &raquo;" %}</button></p>
<div id='FileUpload' style='display: none;'> <div id='FileUpload' style='display: none;'>
@ -214,6 +245,33 @@
</div> </div>
</div> </div>
<div class="modal fade" id="createChecklistModal" tabindex="-1" role="dialog" aria-labelledby="exampleModalCenterTitle" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<form method="post">
{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title">{% trans "Add a new checklist" %}</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="{% trans 'Close' %}">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p>{% trans "You can select a template to generate a checklist with a predefined set of tasks." %}</p>
<p>{% trans "Ignore it and only give a name to create an empty checklist." %}</p>
{{ checklist_form.as_p }}
</div>
<div class="modal-footer">
<a class="btn btn-secondary" href="{% url 'helpdesk:checklist_templates' %}">
Manage templates
</a>
<button type="submit" class="btn btn-primary">{% trans "Add" %}</button>
</div>
</form>
</div>
</div>
</div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
@ -229,13 +287,15 @@ $( function() {
$(document).ready(function() { $(document).ready(function() {
$("#ShowFurtherEditOptions").click(function() { $("#ShowFurtherEditOptions").click(function() {
$("#FurtherEditOptions").toggle(); $("#FurtherEditOptions").toggle();
return false; });
$("#ShowChecklistEditOptions").click(function() {
$("#checklistEdit").toggle();
}); });
$("#ShowFileUpload").click(function() { $("#ShowFileUpload").click(function() {
$("#FileUpload").fadeIn(); $("#FileUpload").fadeIn();
$("#ShowFileUploadPara").hide(); $("#ShowFileUploadPara").hide();
return false;
}); });
$('#id_preset').change(function() { $('#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(); $("[data-toggle=tooltip]").tooltip();
// lists for file input change events, then updates the associated text label // lists for file input change events, then updates the associated text label

View File

@ -10,64 +10,111 @@
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-sm table-border"> <table class="table table-sm table-border">
<thead class="thead-light"> <thead class="thead-light">
<tr class=''><th colspan='4'><h3>{{ ticket.queue.slug }}-{{ ticket.id }}. {{ ticket.title }} [{{ ticket.get_status }}]</h3> <tr class=''>
<th colspan='4'>
<h3>{{ ticket.queue.slug }}-{{ ticket.id }}. {{ ticket.title }} [{{ ticket.get_status }}]</h3>
{% blocktrans with ticket.queue as queue %}Queue: {{ queue }}{% endblocktrans %} {% blocktrans with ticket.queue as queue %}Queue: {{ queue }}{% endblocktrans %}
<span class='ticket_toolbar float-right'> <span class='ticket_toolbar float-right'>
<a href="{% url 'helpdesk:edit' ticket.id %}" class="ticket-edit"><button class="btn btn-warning btn-sm"><i class="fas fa-pencil-alt"></i> {% trans "Edit" %}</button></a> <a href="{% url 'helpdesk:edit' ticket.id %}" class="btn btn-warning btn-sm ticket-edit">
| <a href="{% url 'helpdesk:delete' ticket.id %}" class="ticket-delete"><button class="btn btn-danger btn-sm"><i class="fas fa-trash-alt"></i> {% trans "Delete" %}</button></a> <i class="fas fa-pencil-alt"></i> {% trans "Edit" %}
| </a>
{% if ticket.on_hold %} | <a href="{% url 'helpdesk:delete' ticket.id %}" class="btn btn-danger btn-sm ticket-delete">
<i class="fas fa-trash-alt"></i> {% trans "Delete" %}
</a>
| {% if ticket.on_hold %}
<form class="form-inline ticket-hold" method='post' action='unhold/'> <form class="form-inline ticket-hold" method='post' action='unhold/'>
{% csrf_token %} {% csrf_token %}
<button class="btn btn-warning btn-sm" type='submit'><i class="fas fa-play"></i> {% trans "Unhold" %}</button> <button class="btn btn-warning btn-sm" type='submit'>
<i class="fas fa-play"></i> {% trans "Unhold" %}
</button>
</form> </form>
{% else %} {% else %}
<form class="form-inline ticket-hold" method='post' action='hold/'> <form class="form-inline ticket-hold" method='post' action='hold/'>
{% csrf_token %} {% csrf_token %}
<button class="btn btn-warning btn-sm" type='submit'><i class="fas fa-pause"></i> {% trans "Hold" %}</button> <button class="btn btn-warning btn-sm" type='submit'>
<i class="fas fa-pause"></i> {% trans "Hold" %}
</button>
</form> </form>
{% endif %} {% endif %}
</span></th></tr> </span>
</th>
</tr>
</thead> </thead>
<tbody> <tbody>
{% for customfield in ticket.ticketcustomfieldvalue_set.all %} {% for customfield in ticket.ticketcustomfieldvalue_set.all %}
<tr> <tr>
<th class="table-secondary">{{ customfield.field.label }}</th> <th class="table-secondary">{{ customfield.field.label }}</th>
<td>{% spaceless %}{% if "url" == customfield.field.data_type %}<a href='{{ customfield.value }}'>{{ customfield.value }}</a> <td>
{% elif "datetime" == customfield.field.data_type %}{{ customfield.value|datetime_string_format }} {% spaceless %}
{% elif "date" == customfield.field.data_type %}{{ customfield.value|datetime_string_format }} {% if "url" == customfield.field.data_type %}
{% elif "time" == customfield.field.data_type %}{{ customfield.value|datetime_string_format }} <a href='{{ customfield.value }}'>{{ customfield.value }}</a>
{% else %}{{ customfield.value|default:"" }} {% elif "datetime" == customfield.field.data_type %}
{% endif %}{% endspaceless %}</td> {{ customfield.value|datetime_string_format }}
</tr>{% endfor %} {% 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 %}
</td>
</tr>
{% endfor %}
<tr> <tr>
<th class="table-active">{% trans "Due Date" %}</th> <th class="table-active">{% trans "Due Date" %}</th>
<td>{{ ticket.due_date|date:"DATETIME_FORMAT" }} {% if ticket.due_date %}({{ ticket.due_date|naturaltime }}){% endif %} <td>
{{ ticket.due_date|date:"DATETIME_FORMAT" }}
{% if ticket.due_date %}({{ ticket.due_date|naturaltime }}){% endif %}
</td> </td>
<th class="table-active">{% trans "Submitted On" %}</th> <th class="table-active">{% trans "Submitted On" %}</th>
<td>{{ ticket.created|date:"DATETIME_FORMAT" }} ({{ ticket.created|naturaltime }})</td> <td>{{ ticket.created|date:"DATETIME_FORMAT" }} ({{ ticket.created|naturaltime }})</td>
</tr> </tr>
<tr> <tr>
<th class="table-active">{% trans "Assigned To" %}</th> <th class="table-active">{% trans "Assigned To" %}</th>
<td>{{ ticket.get_assigned_to }}{% if _('Unassigned') == ticket.get_assigned_to %} <strong> <td>
<a data-toggle="tooltip" href='?take' title='{% trans "Assign this ticket to " %}{{ request.user.email }}'><button type="button" class="btn btn-primary btn-sm float-right"><i class="fas fa-hand-paper"></i></button></a> {{ ticket.get_assigned_to }}
</strong>{% endif %} {% if _('Unassigned') == ticket.get_assigned_to %}
<a class="btn btn-primary btn-sm float-right" data-toggle="tooltip" href='?take' title='{% trans "Assign this ticket to " %}{{ request.user.email }}'>
<i class="fas fa-hand-paper"></i>
</a>
{% endif %}
</td> </td>
<th class="table-active">{% trans "Submitter E-Mail" %}</th> <th class="table-active">{% trans "Submitter E-Mail" %}</th>
<td> {{ ticket.submitter_email }} <td>
{% if user.is_superuser %} {% if submitter_userprofile_url %}<strong><a data-toggle="tooltip" href='{{submitter_userprofile_url}}' title='{% trans "Edit " %}{{ ticket.submitter_email }}{% trans " user profile" %}'><button type="button" class="btn btn-primary btn-sm"><i class="fas fa-address-book"></i></button></a></strong>{% endif %} {{ ticket.submitter_email }}
<strong><a data-toggle="tooltip" href ="{% url 'helpdesk:list'%}?q={{ticket.submitter_email}}" title='{% trans "Display tickets filtered for " %}{{ ticket.submitter_email }}{% trans " as a keyword" %}'> {% if user.is_superuser %}
<button type="button" class="btn btn-primary btn-sm"><i class="fas fa-search"></i></button></a></strong> {% if submitter_userprofile_url %}
<strong><a data-toggle="tooltip" href='{% url 'helpdesk:email_ignore_add' %}?email={{ ticket.submitter_email }}' title='{% trans "Add email address for the ticket system to ignore." %}'> <a class="btn btn-primary btn-sm" data-toggle="tooltip" href='{{submitter_userprofile_url}}' title='{% trans "Edit " %}{{ ticket.submitter_email }}{% trans " user profile" %}'>
<button type="button" class="btn btn-warning btn-sm float-right"><i class="fas fa-eye-slash"></i></button></a></strong>{% endif %} <i class="fas fa-address-book"></i>
</a>
{% endif %}
<a class="btn btn-primary btn-sm" data-toggle="tooltip" href ="{% url 'helpdesk:list'%}?q={{ticket.submitter_email}}" title='{% trans "Display tickets filtered for " %}{{ ticket.submitter_email }}{% trans " as a keyword" %}'>
<i class="fas fa-search"></i>
</a>
<a class="btn btn-warning btn-sm float-right" data-toggle="tooltip" href='{% url 'helpdesk:email_ignore_add' %}?email={{ ticket.submitter_email }}' title='{% trans "Add email address for the ticket system to ignore." %}'>
<i class="fas fa-eye-slash"></i>
</a>
{% endif %}
</td> </td>
</tr> </tr>
<tr> <tr>
<th class="table-active">{% trans "Priority" %}</th> <th class="table-active">{% trans "Priority" %}</th>
<td class="{% if ticket.priority < 3 %}table-warning{% endif %}">{{ ticket.get_priority_display }} <td class="{% if ticket.priority < 3 %}table-warning{% endif %}">
{{ ticket.get_priority_display }}
</td> </td>
<th class="table-active">{% trans "Copies To" %}</th> <th class="table-active">{% trans "Copies To" %}</th>
<td>{{ ticketcc_string }} <a data-toggle='tooltip' href='{% url 'helpdesk:ticket_cc' ticket.id %}' title='{% trans "Click here to add / remove people who should receive an e-mail whenever this ticket is updated." %}'><strong><button type="button" class="btn btn-warning btn-sm float-right"><i class="fa fa-share"></i></button></strong></a>{% if SHOW_SUBSCRIBE %} <strong><a data-toggle='tooltip' href='?subscribe' title='{% trans "Click here to subscribe yourself to this ticket, if you want to receive an e-mail whenever this ticket is updated." %}'><button type="button" class="btn btn-warning btn-sm float-right"><i class="fas fa-rss-square"></i></button></a></strong>{% endif %}</td> <td>
{{ ticketcc_string }}
<a class="btn btn-warning btn-sm float-right" data-toggle='tooltip' href='{% url 'helpdesk:ticket_cc' ticket.id %}' title='{% trans "Click here to add / remove people who should receive an e-mail whenever this ticket is updated." %}'>
<i class="fa fa-share"></i>
</a>
{% if SHOW_SUBSCRIBE %}
<a class="btn btn-warning btn-sm float-right" data-toggle='tooltip' href='?subscribe' title='{% trans "Click here to subscribe yourself to this ticket, if you want to receive an e-mail whenever this ticket is updated." %}'>
<i class="fas fa-rss-square"></i>
</a>
{% endif %}
</td>
</tr> </tr>
{% 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 != False and helpdesk_settings.HELPDESK_ENABLE_TIME_SPENT_ON_TICKET != False %}
@ -109,9 +156,14 @@
<ul> <ul>
{% for followup in ticket.followup_set.all %} {% for followup in ticket.followup_set.all %}
{% for attachment in followup.followupattachment_set.all %} {% for attachment in followup.followupattachment_set.all %}
<li><a href='{{ attachment.file.url }}'>{{ attachment.filename }}</a> ({{ attachment.mime_type }}, {{ attachment.size|filesizeformat }}) <li>
<a href='{{ attachment.file.url }}'>
{{ attachment.filename }}
</a> ({{ attachment.mime_type }}, {{ attachment.size|filesizeformat }})
{% if followup.user and request.user == followup.user %} {% 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> <a class="btn btn-danger btn-sm" href='{% url 'helpdesk:attachment_del' ticket.id attachment.id %}'>
<i class="fas fa-trash"></i>
</a>
{% endif %} {% endif %}
</li> </li>
{% endfor %} {% endfor %}
@ -120,20 +172,92 @@
</td> </td>
</tr> </tr>
<tr> <tr>
<td id="ticket-description" colspan='4'> <th class="table-active">{% trans "Checklists" %}</th>
<h4>{% trans "Description" %}</h4> <td colspan="3">
{{ ticket.get_markdown|urlizetrunc:50|num_to_link }}</td> <div class="container-fluid">
</tr> <div class="row align-items-baseline">
{% for checklist in ticket.checklists.all %}
{% if ticket.resolution %}<tr> <div class="col-sm-4 col-xs-12">
<th colspan='2'>{% trans "Resolution" %}{% if "Resolved" == ticket.get_status_display %} <a href='?close'><button type="button" class="btn btn-warning btn-sm">{% trans "Accept and Close" %}</button></a>{% endif %}</th> <div class="card mb-4">
<div class="card-header">
<h5>
<span data-toggle="collapse" data-target="#checklist{{ checklist.id }}" onclick="$(this).siblings('button').children('i').toggleClass('fa-caret-down fa-caret-up')">
{{ checklist }}
</span>
<a class="btn btn-link btn-sm" href="{% url 'helpdesk:edit_ticket_checklist' ticket.id checklist.id %}">
<i class="fas fa-edit"></i>
</a>
<button class="btn btn-secondary btn-sm float-right" data-toggle="collapse" data-target="#checklist{{ checklist.id }}" onclick="$(this).children('i').toggleClass('fa-caret-down fa-caret-up')">
<i class="fas fa-caret-down"></i>
</button>
</h5>
</div>
<div class="card-body collapse" id="checklist{{ checklist.id }}">
<div class="list-group">
{% for task in checklist.tasks.all %}
<div class="list-group-item"{% if task.completion_date %} title="{% trans "Completed on" %} {{ task.completion_date }}" {% endif %}>
<label class="disabledTask">
<input type="checkbox" disabled{% if task.completion_date %} checked{% endif %}>
{{ task }}
</label>
</div>
{% endfor %}
</div>
</div>
{% if checklist.tasks.completed.count %}
<div class="card-footer">
<div class="progress">
{% widthratio checklist.tasks.completed.count checklist.tasks.count 100 as width %}
<div class="progress-bar" role="progressbar" style="width: {{ width }}%" aria-valuenow="{{ width }}" aria-valuemin="0" aria-valuemax="100">
{{ width }}%
</div>
</div>
</div>
{% endif %}
</div>
</div>
{% endfor %}
<div class="col-sm-4 col-xs-12">
<button type="button" class="btn btn-sm btn-secondary" data-toggle="modal" data-target="#createChecklistModal">
<i class="fas fa-plus"></i>
{% trans "Create new checklist" %}
</button>
</div>
</div>
</div>
</td>
</tr> </tr>
<tr> <tr>
<td colspan='2'>{{ ticket.get_resolution_markdown|urlizetrunc:50|linebreaksbr }}</td> <td id="ticket-description" colspan='4'>
</tr>{% endif %} <h4>{% trans "Description" %}</h4>
{{ ticket.get_markdown|urlizetrunc:50|num_to_link }}
</td>
</tr>
{% if ticket.resolution %}
<tr>
<th colspan='4'>
{% trans "Resolution" %}
{% if "Resolved" == ticket.get_status_display %}
<a href='?close'>
<button type="button" class="btn btn-warning btn-sm">
{% trans "Accept and Close" %}
</button>
</a>
{% endif %}
</th>
</tr>
<tr>
<td colspan='4'>{{ ticket.get_resolution_markdown|urlizetrunc:50|linebreaksbr }}</td>
</tr>
{% endif %}
</tbody> </tbody>
</table> </table>
<a href='#FurtherEditOptions'><button type="button" class="btn btn-sm btn-warning float-right" onclick="$('#FurtherEditOptions').fadeIn()"><i class="fas fa-pencil-alt"></i>&nbsp;{% trans "Edit details" %}</button></a> <a href='#FurtherEditOptions'>
<button type="button" class="btn btn-sm btn-warning float-right" onclick="$('#FurtherEditOptions').fadeIn()">
<i class="fas fa-pencil-alt"></i>&nbsp;{% trans "Edit details" %}
</button>
</a>
</div> </div>
<!-- /.table-responsive --> <!-- /.table-responsive -->
</div> </div>

View File

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

View File

@ -1,6 +1,4 @@
from django.contrib.auth import get_user_model 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 import TestCase
from django.test.client import Client from django.test.client import Client
from django.urls import reverse from django.urls import reverse
@ -38,6 +36,7 @@ class TicketActionsTestCase(TestCase):
) )
self.ticket_data = { self.ticket_data = {
'queue': self.queue_public,
'title': 'Test Ticket', 'title': 'Test Ticket',
'description': 'Some Test Ticket', 'description': 'Some Test Ticket',
} }
@ -73,8 +72,7 @@ class TicketActionsTestCase(TestCase):
self.loginUser() self.loginUser()
"""Tests whether staff can delete tickets""" """Tests whether staff can delete tickets"""
ticket_data = dict(queue=self.queue_public, **self.ticket_data) ticket = Ticket.objects.create(**self.ticket_data)
ticket = Ticket.objects.create(**ticket_data)
ticket_id = ticket.id ticket_id = ticket.id
response = self.client.get(reverse('helpdesk:delete', kwargs={ response = self.client.get(reverse('helpdesk:delete', kwargs={

View File

@ -93,6 +93,16 @@ urlpatterns = [
staff.attachment_del, staff.attachment_del,
name="attachment_del", name="attachment_del",
), ),
path(
"tickets/<int:ticket_id>/checklists/<int:checklist_id>/",
staff.edit_ticket_checklist,
name="edit_ticket_checklist"
),
path(
"tickets/<int:ticket_id>/checklists/<int:checklist_id>/delete/",
staff.delete_ticket_checklist,
name="delete_ticket_checklist"
),
re_path(r"^raw/(?P<type>\w+)/$", staff.raw_details, name="raw"), re_path(r"^raw/(?P<type>\w+)/$", staff.raw_details, name="raw"),
path("rss/", staff.rss_list, name="rss_index"), path("rss/", staff.rss_list, name="rss_index"),
path("reports/", staff.report_index, name="report_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/add/", staff.email_ignore_add, name="email_ignore_add"),
path("ignore/delete/<int:id>/", path("ignore/delete/<int:id>/",
staff.email_ignore_del, name="email_ignore_del"), staff.email_ignore_del, name="email_ignore_del"),
path("checklist-templates/", staff.checklist_templates, name="checklist_templates"),
path(
"checklist-templates/<int:checklist_template_id>/",
staff.checklist_templates,
name="edit_checklist_template"
),
path(
"checklist-templates/<int:checklist_template_id>/delete/",
staff.delete_checklist_template,
name="delete_checklist_template"
),
re_path( re_path(
r"^datatables_ticket_list/(?P<query>{})$".format(base64_pattern), r"^datatables_ticket_list/(?P<query>{})$".format(base64_pattern),
staff.datatables_ticket_list, staff.datatables_ticket_list,

View File

@ -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 views/staff.py - The bulk of the application - provides most business logic and
renders all staff-facing views. renders all staff-facing views.
""" """
from ..lib import format_time_spent from ..lib import format_time_spent
from ..templated_email import send_templated_mail from ..templated_email import send_templated_mail
from collections import defaultdict 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.handlers.wsgi import WSGIRequest
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
from django.db.models import Q from django.db.models import Q
from django.forms import HiddenInput, inlineformset_factory, TextInput
from django.http import Http404, HttpResponse, HttpResponseRedirect, JsonResponse from django.http import Http404, HttpResponse, HttpResponseRedirect, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
@ -38,10 +40,14 @@ from helpdesk.decorators import (
superuser_required superuser_required
) )
from helpdesk.forms import ( from helpdesk.forms import (
ChecklistForm,
ChecklistTemplateForm,
CreateChecklistForm,
CUSTOMFIELD_DATE_FORMAT, CUSTOMFIELD_DATE_FORMAT,
EditFollowUpForm, EditFollowUpForm,
EditTicketForm, EditTicketForm,
EmailIgnoreForm, EmailIgnoreForm,
FormControlDeleteFormSet,
MultipleTicketSelectForm, MultipleTicketSelectForm,
TicketCCEmailForm, TicketCCEmailForm,
TicketCCForm, TicketCCForm,
@ -52,6 +58,9 @@ from helpdesk.forms import (
) )
from helpdesk.lib import process_attachments, queue_template_context, safe_template_context from helpdesk.lib import process_attachments, queue_template_context, safe_template_context
from helpdesk.models import ( from helpdesk.models import (
Checklist,
ChecklistTask,
ChecklistTemplate,
CustomField, CustomField,
FollowUp, FollowUp,
FollowUpAttachment, FollowUpAttachment,
@ -406,6 +415,19 @@ def view_ticket(request, ticket_id):
else: else:
submitter_userprofile_url = None 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', { return render(request, 'helpdesk/ticket.html', {
'ticket': ticket, 'ticket': ticket,
'submitter_userprofile_url': submitter_userprofile_url, 'submitter_userprofile_url': submitter_userprofile_url,
@ -416,6 +438,56 @@ def view_ticket(request, ticket_id):
Q(queues=ticket.queue) | Q(queues__isnull=True)), Q(queues=ticket.queue) | Q(queues__isnull=True)),
'ticketcc_string': ticketcc_string, 'ticketcc_string': ticketcc_string,
'SHOW_SUBSCRIBE': show_subscribe, '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)) owner = int(request.POST.get('owner', -1))
priority = int(request.POST.get('priority', ticket.priority)) 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) time_spent = get_time_spent_from_request(request)
# NOTE: jQuery's default for dates is mm/dd/yy # NOTE: jQuery's default for dates is mm/dd/yy
# very US-centric but for now that's the only format supported # 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([ no_changes = all([
not request.FILES, not request.FILES,
not comment, not comment,
not changes_in_checklists,
new_status == ticket.status, new_status == ticket.status,
title == ticket.title, title == ticket.title,
priority == int(ticket.priority), priority == int(ticket.priority),
@ -785,6 +866,30 @@ def update_ticket(request, ticket_id, public=False):
c.save() c.save()
ticket.due_date = due_date 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 ( if new_status in (
Ticket.RESOLVED_STATUS, Ticket.CLOSED_STATUS Ticket.RESOLVED_STATUS, Ticket.CLOSED_STATUS
) and ( ) and (
@ -2030,3 +2135,30 @@ def date_rel_to_today(today, offset):
def sort_string(begin, end): def sort_string(begin, end):
return 'sort=created&date_from=%s&date_to=%s&status=%s&status=%s&status=%s' % ( 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) 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,
})