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

View File

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

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):
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;
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_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 %}
<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>
<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_presetreply_changelist' %}'>{% trans "Maintain Pre-Set Replies" %}</a></li>
{% 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_head %}
{% endblock %}
{% block h1_title %}{{ ticket.ticket_for_url }}{% endblock %}
{% block helpdesk_breadcrumb %}
@ -166,7 +163,7 @@
{% endif %}
</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;'>
@ -188,7 +185,41 @@
</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;'>
@ -214,6 +245,33 @@
</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 %}
{% 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

View File

@ -10,130 +10,254 @@
<div class="table-responsive">
<table class="table table-sm table-border">
<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 %}
<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:delete' ticket.id %}" class="ticket-delete"><button class="btn btn-danger btn-sm"><i class="fas fa-trash-alt"></i> {% trans "Delete" %}</button></a>
|
{% if ticket.on_hold %}
<form class="form-inline ticket-hold" method='post' action='unhold/'>
{% csrf_token %}
<button class="btn btn-warning btn-sm" type='submit'><i class="fas fa-play"></i> {% trans "Unhold" %}</button>
</form>
{% else %}
<form class="form-inline ticket-hold" method='post' action='hold/'>
{% csrf_token %}
<button class="btn btn-warning btn-sm" type='submit'><i class="fas fa-pause"></i> {% trans "Hold" %}</button>
</form>
{% endif %}
</span></th></tr>
<a href="{% url 'helpdesk:edit' ticket.id %}" class="btn btn-warning btn-sm ticket-edit">
<i class="fas fa-pencil-alt"></i> {% trans "Edit" %}
</a>
| <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/'>
{% csrf_token %}
<button class="btn btn-warning btn-sm" type='submit'>
<i class="fas fa-play"></i> {% trans "Unhold" %}
</button>
</form>
{% else %}
<form class="form-inline ticket-hold" method='post' action='hold/'>
{% csrf_token %}
<button class="btn btn-warning btn-sm" type='submit'>
<i class="fas fa-pause"></i> {% trans "Hold" %}
</button>
</form>
{% endif %}
</span>
</th>
</tr>
</thead>
<tbody>
{% for customfield in ticket.ticketcustomfieldvalue_set.all %}
<tr>
<th class="table-secondary">{{ customfield.field.label }}</th>
<td>{% spaceless %}{% if "url" == customfield.field.data_type %}<a href='{{ customfield.value }}'>{{ customfield.value }}</a>
{% 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 %}</td>
</tr>{% endfor %}
<tr>
<th class="table-secondary">{{ customfield.field.label }}</th>
<td>
{% spaceless %}
{% if "url" == customfield.field.data_type %}
<a href='{{ customfield.value }}'>{{ customfield.value }}</a>
{% 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 %}
</td>
</tr>
{% endfor %}
<tr>
<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>
<th class="table-active">{% trans "Submitted On" %}</th>
<td>{{ ticket.created|date:"DATETIME_FORMAT" }} ({{ ticket.created|naturaltime }})</td>
</tr>
<tr>
<th class="table-active">{% trans "Assigned To" %}</th>
<td>{{ ticket.get_assigned_to }}{% if _('Unassigned') == ticket.get_assigned_to %} <strong>
<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>
</strong>{% endif %}
<td>
{{ ticket.get_assigned_to }}
{% 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>
<th class="table-active">{% trans "Submitter E-Mail" %}</th>
<td> {{ ticket.submitter_email }}
{% 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 %}
<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" %}'>
<button type="button" class="btn btn-primary btn-sm"><i class="fas fa-search"></i></button></a></strong>
<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." %}'>
<button type="button" class="btn btn-warning btn-sm float-right"><i class="fas fa-eye-slash"></i></button></a></strong>{% endif %}
<td>
{{ ticket.submitter_email }}
{% if user.is_superuser %}
{% if submitter_userprofile_url %}
<a class="btn btn-primary btn-sm" data-toggle="tooltip" href='{{submitter_userprofile_url}}' title='{% trans "Edit " %}{{ ticket.submitter_email }}{% trans " user profile" %}'>
<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>
</tr>
<tr>
<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>
<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>
{% if helpdesk_settings.HELPDESK_ENABLE_DEPENDENCIES_ON_TICKET != False and helpdesk_settings.HELPDESK_ENABLE_TIME_SPENT_ON_TICKET != False %}
<tr>
{% if helpdesk_settings.HELPDESK_ENABLE_DEPENDENCIES_ON_TICKET %}
<th class="table-active">{% trans "Dependencies" %}</th>
<td>
<a data-toggle='tooltip' href='{% url 'helpdesk:ticket_dependency_add' ticket.id %}' title="{% trans "Click on 'Add Dependency', if you want to make this ticket dependent on another ticket. A ticket may not be closed until all tickets it depends on are closed." %}"><button type="button" class="btn btn-primary btn-sm float-right"><i class="fas fa-link"></i></button></a>
{% for dep in ticket.ticketdependency.all %}
{% if forloop.first %}<p>{% trans "This ticket cannot be resolved until the following ticket(s) are resolved" %}</p><ul>{% endif %}
<li><a href='{{ dep.depends_on.get_absolute_url }}'>{{ dep.depends_on.ticket }} {{ dep.depends_on.title }}</a> ({{ dep.depends_on.get_status_display }}) <a href='{% url 'helpdesk:ticket_dependency_del' ticket.id dep.id %}'><button type="button" class="btn btn-warning btn-sm"><i class="fas fa-trash"></i></button></a></li>
{% if forloop.last %}</ul>{% endif %}
{% empty %}
{% trans "This ticket has no dependencies." %}
{% endfor %}
</td>
{% else %}
<th class="table-active"></th>
<td></td>
{% endif %}
{% if helpdesk_settings.HELPDESK_ENABLE_TIME_SPENT_ON_TICKET %}
<th class="table-active">{% trans "Total time spent" %}</th>
<td>{{ ticket.time_spent_formated }}</td>
{% else %}
<th class="table-active"></th>
<td></td>
{% endif %}
</tr>
<tr>
{% if helpdesk_settings.HELPDESK_ENABLE_DEPENDENCIES_ON_TICKET %}
<th class="table-active">{% trans "Dependencies" %}</th>
<td>
<a data-toggle='tooltip' href='{% url 'helpdesk:ticket_dependency_add' ticket.id %}' title="{% trans "Click on 'Add Dependency', if you want to make this ticket dependent on another ticket. A ticket may not be closed until all tickets it depends on are closed." %}"><button type="button" class="btn btn-primary btn-sm float-right"><i class="fas fa-link"></i></button></a>
{% for dep in ticket.ticketdependency.all %}
{% if forloop.first %}<p>{% trans "This ticket cannot be resolved until the following ticket(s) are resolved" %}</p><ul>{% endif %}
<li><a href='{{ dep.depends_on.get_absolute_url }}'>{{ dep.depends_on.ticket }} {{ dep.depends_on.title }}</a> ({{ dep.depends_on.get_status_display }}) <a href='{% url 'helpdesk:ticket_dependency_del' ticket.id dep.id %}'><button type="button" class="btn btn-warning btn-sm"><i class="fas fa-trash"></i></button></a></li>
{% if forloop.last %}</ul>{% endif %}
{% empty %}
{% trans "This ticket has no dependencies." %}
{% endfor %}
</td>
{% else %}
<th class="table-active"></th>
<td></td>
{% endif %}
{% if helpdesk_settings.HELPDESK_ENABLE_TIME_SPENT_ON_TICKET %}
<th class="table-active">{% trans "Total time spent" %}</th>
<td>{{ ticket.time_spent_formated }}</td>
{% else %}
<th class="table-active"></th>
<td></td>
{% endif %}
</tr>
{% endif %}
{% if ticket.kbitem %}
<tr>
<th class="table-active">{% trans "Knowlegebase item" %}</th>
<td> <a href ="{{ticket.kbitem.query_url}}"> {{ticket.kbitem}} </a> </td>
</tr>
<tr>
<th class="table-active">{% trans "Knowlegebase item" %}</th>
<td> <a href ="{{ticket.kbitem.query_url}}"> {{ticket.kbitem}} </a> </td>
</tr>
{% endif %}
<tr>
<th class="table-active">{% trans "Attachments" %}</th>
<td colspan="3">
<ul>
{% for followup in ticket.followup_set.all %}
{% for attachment in followup.followupattachment_set.all %}
<li><a href='{{ attachment.file.url }}'>{{ attachment.filename }}</a> ({{ attachment.mime_type }}, {{ attachment.size|filesizeformat }})
{% if followup.user and request.user == followup.user %}
<a href='{% url 'helpdesk:attachment_del' ticket.id attachment.id %}'><button class="btn btn-danger btn-sm"><i class="fas fa-trash"></i></button></a>
{% endif %}
</li>
{% endfor %}
{% for attachment in followup.followupattachment_set.all %}
<li>
<a href='{{ attachment.file.url }}'>
{{ attachment.filename }}
</a> ({{ attachment.mime_type }}, {{ attachment.size|filesizeformat }})
{% if followup.user and request.user == followup.user %}
<a class="btn btn-danger btn-sm" href='{% url 'helpdesk:attachment_del' ticket.id attachment.id %}'>
<i class="fas fa-trash"></i>
</a>
{% endif %}
</li>
{% endfor %}
{% endfor %}
</ul>
</td>
</tr>
<tr>
<td id="ticket-description" colspan='4'>
<h4>{% trans "Description" %}</h4>
{{ ticket.get_markdown|urlizetrunc:50|num_to_link }}</td>
</tr>
{% if ticket.resolution %}<tr>
<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>
<th class="table-active">{% trans "Checklists" %}</th>
<td colspan="3">
<div class="container-fluid">
<div class="row align-items-baseline">
{% for checklist in ticket.checklists.all %}
<div class="col-sm-4 col-xs-12">
<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>
<td colspan='2'>{{ ticket.get_resolution_markdown|urlizetrunc:50|linebreaksbr }}</td>
</tr>{% endif %}
<td id="ticket-description" colspan='4'>
<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>
</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>
<!-- /.table-responsive -->
</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.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={

View File

@ -93,6 +93,16 @@ urlpatterns = [
staff.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"),
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/<int:id>/",
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(
r"^datatables_ticket_list/(?P<query>{})$".format(base64_pattern),
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
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,
})