Merge in bugfixes and features from 0.3.4 release including new api

This commit is contained in:
Garret Wassermann 2022-04-23 02:10:58 -04:00
commit d1187b02fa
26 changed files with 639 additions and 89 deletions

View File

@ -55,6 +55,7 @@ INSTALLED_APPS = [
'pinax.teams', # team support
'reversion', # required by pinax-teams
'helpdesk', # This is us!
'rest_framework', # required for the API
]
MIDDLEWARE = [
@ -113,9 +114,14 @@ HELPDESK_SUBMIT_A_TICKET_PUBLIC = True
# Should the Knowledgebase be enabled?
HELPDESK_KB_ENABLED = True
HELPDESK_TICKETS_TIMELINE_ENABLED = True
# Allow users to change their passwords
HELPDESK_SHOW_CHANGE_PASSWORD = True
# Activate the API
HELPDESK_ACTIVATE_API_ENDPOINT = True
# Instead of showing the public web portal first,
# we can instead redirect users straight to the login page.
HELPDESK_REDIRECT_TO_LOGIN_BY_DEFAULT = False

View File

@ -28,4 +28,5 @@ from django.conf.urls.static import static
urlpatterns = [
url(r'^admin/', admin.site.urls),
url(r'^', include('helpdesk.urls', namespace='helpdesk')),
url(r'^api/auth/', include('rest_framework.urls', namespace='rest_framework'))
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

66
docs/api.rst Normal file
View File

@ -0,0 +1,66 @@
API
===
A REST API (built with ``djangorestframework``) is available in order to list, create, update and delete tickets from other tools thanks to HTTP requests.
If you wish to use it, you have to add this line in your settings::
HELPDESK_ACTIVATE_API_ENDPOINT = True
You must be authenticated to access the API, the URL endpoint is ``/api/tickets/``. You can configure how you wish to authenticate to the API by customizing the ``DEFAULT_AUTHENTICATION_CLASSES`` key in the ``REST_FRAMEWORK`` setting (more information on this page : https://www.django-rest-framework.org/api-guide/authentication/)
GET
---
Accessing the endpoint ``/api/tickets/`` with a **GET** request will return you the complete list of tickets.
Accessing the endpoint ``/api/tickets/<ticket-id>`` with a **GET** request will return you the data of the ticket you provided the ID.
POST
----
Accessing the endpoint ``/api/tickets/`` with a **POST** request will let you create a new tickets.
You need to provide a JSON body with the following data :
- **queue**: ID of the queue
- **title**: the title (subject) of the ticket
- **description**: the description of the ticket
- **resolution**: an optional text for the resoltuion of the ticket
- **submitter_email**: the email of the ticket submitter
- **assigned_to**: ID of the ticket's assigned user
- **status**: integer corresponding to the status (OPEN=1, REOPENED=2, RESOLVED=3, CLOSED=4, DUPLICATE=5). It is OPEN by default.
- **on_hold**: boolean to indicates if the ticket is on hold
- **priority**: integer corresponding to different degrees of priority 1 to 5 (1 is Critical and 5 is Very Low)
- **due_date**: date representation for when the ticket is due
- **merged_to**: ID of the ticket to which it is merged
Note that ``status`` will automatically be set to OPEN. Also, some fields are not configurable during creation: ``resolution``, ``on_hold`` and ``merged_to``.
Moreover, if you created custom fields, you can add them into the body with the key ``custom_<custom-field-slug>``.
Here is an example of a cURL request to create a ticket (using Basic authentication) ::
curl --location --request POST 'http://127.0.0.1:8000/api/tickets/' \
--header 'Authorization: Basic YWRtaW46YWRtaW4=' \
--header 'Content-Type: application/json' \
--data-raw '{"queue": 1, "title": "Test Ticket API", "description": "Test create ticket from API", "submitter_email": "test@mail.com", "priority": 4}'
PUT
---
Accessing the endpoint ``/api/tickets/<ticket-id>`` with a **PUT** request will let you update the data of the ticket you provided the ID.
You must include all fields in the JSON body.
PATCH
-----
Accessing the endpoint ``/api/tickets/<ticket-id>`` with a **PATCH** request will let you do a partial update of the data of the ticket you provided the ID.
You can include only the fields you need to update in the JSON body.
DELETE
------
Accessing the endpoint ``/api/tickets/<ticket-id>`` with a **DELETE** request will let you delete the ticket you provided the ID.

View File

@ -16,9 +16,9 @@ Contents
settings
spam
custom_fields
api
integration
teams
contributing
license
@ -40,6 +40,7 @@ django-helpdesk has been designed for small businesses who need to receive, mana
* Tickets can be opened via email
* Multiple queues / categories of tickets
* Integrated FAQ / knowledgebase
* API to manage tickets
Customer-facing Capabilities
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@ -62,6 +63,8 @@ If a user is a staff member, they get general helpdesk access, including:
5. Follow up or respond to tickets
6. Assign tickets to themselves or other staff members
7. Resolve tickets
8. Merge multiple tickets into one
9. Create, Read, Update and Delete tickets through a REST API
Optionally, their access to view tickets, both on the dashboard and through searches and reports, may be restricted by a list of queues to which they have been granted membership. Create and update permissions for individual tickets are not limited by this optional restriction.

View File

@ -27,10 +27,10 @@ Installing using PIP
Try using ``pip install django-helpdesk``. Go and have a beer to celebrate Python packaging.
Checkout ``master`` from git (Cutting Edge)
Checkout ``stable`` from git (Cutting Edge)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
If you're planning on editing the code or just want to get whatever is the latest and greatest, you can clone the official Git repository with ``git clone git://github.com/django-helpdesk/django-helpdesk.git``. We use the ``master`` branch as our development branch for the next major release of ``django-helpdesk``.
If you're planning on editing the code or just want to get whatever is the latest and greatest, you can clone the official Git repository with ``git clone git://github.com/django-helpdesk/django-helpdesk.git``. We use the ``stable`` branch as our development branch for the next major release of ``django-helpdesk``.
Copy the ``helpdesk`` folder into your ``PYTHONPATH``.
@ -59,13 +59,14 @@ errors with trying to create User settings.
'django.contrib.humanize', # Required for elapsed time formatting
'bootstrap4form', # Required for nicer formatting of forms with the default templates
'account', # Required by pinax-teams
'pinax.invitations', # required by pinax-teams
'pinax.teams', # team support
'reversion', # required by pinax-teams
'pinax.invitations', # Required by pinax-teams
'pinax.teams', # Team support
'reversion', # Required by pinax-teams
'rest_framework', # required for the API
'helpdesk', # This is us!
)
Note: you do not need to use pinax-teams. To dissable teams see the :doc:`teams` section.
Note: you do not need to use pinax-teams. To disable teams see the :doc:`teams` section.
Your ``settings.py`` file should also define a ``SITE_ID`` that allows multiple projects to share
a single database, and is required by ``django.contrib.sites`` in Django 1.9+.
@ -75,11 +76,11 @@ errors with trying to create User settings.
2. Make sure django-helpdesk is accessible via ``urls.py``. Add the following line to ``urls.py``::
url(r'helpdesk/', include('helpdesk.urls')),
path('helpdesk/', include('helpdesk.urls')),
Note that you can change 'helpdesk/' to anything you like, such as 'support/' or 'help/'. If you want django-helpdesk to be available at the root of your site (for example at http://support.mysite.tld/) then the line will be as follows::
url(r'', include('helpdesk.urls', namespace='helpdesk')),
path('', include('helpdesk.urls', namespace='helpdesk')),
This line will have to come *after* any other lines in your urls.py such as those used by the Django admin.
@ -90,7 +91,7 @@ errors with trying to create User settings.
Migrate using Django migrations::
./manage.py migrate helpdesk
python manage.py migrate helpdesk
4. Include your static files in your public web path::

View File

@ -94,6 +94,18 @@ These changes are visible throughout django-helpdesk
**Default:** ``HELPDESK_ANON_ACCESS_RAISES_404 = False``
- **HELPDESK_ENABLE_DEPENDENCIES_ON_TICKET** If False, disable the dependencies fields on ticket.
**Default:** ``HELPDESK_ENABLE_DEPENDENCIES_ON_TICKET = True``
- **HELPDESK_ENABLE_TIME_SPENT_ON_TICKET** If False, disable the time spent fields on ticket.
**Default:** ``HELPDESK_ENABLE_TIME_SPENT_ON_TICKET = True``
- **HELPDESK_TICKETS_TIMELINE_ENABLED** If False, remove from the dashboard the Timeline view for tickets.
**Default:** ``HELPDESK_TICKETS_TIMELINE_ENABLED = True``
Options shown on public pages
-----------------------------
@ -168,6 +180,10 @@ Staff Ticket Creation Settings
**Default:** ``HELPDESK_CREATE_TICKET_HIDE_ASSIGNED_TO = False``
- **HELPDESK_ACTIVATE_API_ENDPOINT** Activate the API endpoint to manage tickets thanks to Django REST Framework. See the API section in documentation for more information.
**Default:** ``HELPDESK_ACTIVATE_API_ENDPOINT = False``
Staff Ticket View Settings
------------------------------

View File

@ -16,11 +16,12 @@ from django.utils.translation import ugettext_lazy as _
from django.contrib.auth import get_user_model
from django.utils import timezone
from helpdesk.lib import safe_template_context, process_attachments
from helpdesk.lib import safe_template_context, process_attachments, convert_value
from helpdesk.models import (Ticket, Queue, FollowUp, IgnoreEmail, TicketCC,
CustomField, TicketCustomFieldValue, TicketDependency, UserSettings)
from helpdesk import settings as helpdesk_settings
from helpdesk.settings import CUSTOMFIELD_TO_FIELD_DICT, CUSTOMFIELD_DATETIME_FORMAT, \
CUSTOMFIELD_DATE_FORMAT, CUSTOMFIELD_TIME_FORMAT
if helpdesk_settings.HELPDESK_KB_ENABLED:
from helpdesk.models import (KBItem)
@ -28,22 +29,6 @@ if helpdesk_settings.HELPDESK_KB_ENABLED:
logger = logging.getLogger(__name__)
User = get_user_model()
CUSTOMFIELD_TO_FIELD_DICT = {
# Store the immediate equivalences here
'boolean': forms.BooleanField,
'date': forms.DateField,
'time': forms.TimeField,
'datetime': forms.DateTimeField,
'email': forms.EmailField,
'url': forms.URLField,
'ipaddress': forms.GenericIPAddressField,
'slug': forms.SlugField,
}
CUSTOMFIELD_DATE_FORMAT = "%Y-%m-%d"
CUSTOMFIELD_TIME_FORMAT = "%H:%M:%S"
CUSTOMFIELD_DATETIME_FORMAT = f"{CUSTOMFIELD_DATE_FORMAT} {CUSTOMFIELD_TIME_FORMAT}"
class CustomFieldMixin(object):
"""
@ -71,10 +56,7 @@ class CustomFieldMixin(object):
instanceargs['widget'] = forms.NumberInput(attrs={'class': 'form-control'})
elif field.data_type == 'list':
fieldclass = forms.ChoiceField
choices = field.choices_as_array
if field.empty_selection_list:
choices.insert(0, ('', '---------'))
instanceargs['choices'] = choices
instanceargs['choices'] = field.get_choices()
instanceargs['widget'] = forms.Select(attrs={'class': 'form-control'})
else:
# Try to use the immediate equivalences dictionary
@ -155,15 +137,7 @@ class EditTicketForm(CustomFieldMixin, forms.ModelForm):
except ObjectDoesNotExist:
cfv = TicketCustomFieldValue(ticket=self.instance, field=customfield)
# Convert date/time data type to known fixed format string.
if datetime is type(value):
cfv.value = value.strftime(CUSTOMFIELD_DATETIME_FORMAT)
elif date is type(value):
cfv.value = value.strftime(CUSTOMFIELD_DATE_FORMAT)
elif time is type(value):
cfv.value = value.strftime(CUSTOMFIELD_TIME_FORMAT)
else:
cfv.value = value
cfv.value = convert_value(value)
cfv.save()
return super(EditTicketForm, self).save(*args, **kwargs)
@ -290,14 +264,7 @@ class AbstractTicketForm(CustomFieldMixin, forms.Form):
return ticket, queue
def _create_custom_fields(self, ticket):
for field, value in self.cleaned_data.items():
if field.startswith('custom_'):
field_name = field.replace('custom_', '', 1)
custom_field = CustomField.objects.get(name=field_name)
cfv = TicketCustomFieldValue(ticket=ticket,
field=custom_field,
value=value)
cfv.save()
ticket.save_custom_field_values(self.cleaned_data)
def _create_follow_up(self, ticket, title, user=None):
followup = FollowUp(ticket=ticket,

View File

@ -8,12 +8,12 @@ lib.py - Common functions (eg multipart e-mail)
import logging
import mimetypes
from datetime import datetime, date, time
from django.conf import settings
from django.utils.encoding import smart_text
from helpdesk.models import FollowUpAttachment
from helpdesk.settings import CUSTOMFIELD_DATETIME_FORMAT, CUSTOMFIELD_DATE_FORMAT, CUSTOMFIELD_TIME_FORMAT
logger = logging.getLogger('helpdesk')
@ -136,8 +136,7 @@ def process_attachments(followup, attached_files):
if attached.size:
filename = smart_text(attached.name)
att = FollowUpAttachment(
followup=followup,
att = followup.followupattachment_set.create(
file=attached,
filename=filename,
mime_type=attached.content_type or
@ -169,3 +168,15 @@ def format_time_spent(time_spent):
else:
time_spent = ""
return time_spent
def convert_value(value):
""" Convert date/time data type to known fixed format string """
if type(value) == datetime:
return value.strftime(CUSTOMFIELD_DATETIME_FORMAT)
elif type(value) == date:
return value.strftime(CUSTOMFIELD_DATE_FORMAT)
elif type(value) == time:
return value.strftime(CUSTOMFIELD_TIME_FORMAT)
else:
return value

View File

@ -28,7 +28,10 @@ from markdown.extensions import Extension
import uuid
from rest_framework import serializers
from helpdesk import settings as helpdesk_settings
from .lib import convert_value
from .validators import validate_file_extension
@ -861,6 +864,27 @@ class Ticket(models.Model):
ticketcc = self.ticketcc_set.create(email=email)
return ticketcc
def set_custom_field_values(self):
for field in CustomField.objects.all():
try:
value = self.ticketcustomfieldvalue_set.get(field=field).value
except TicketCustomFieldValue.DoesNotExist:
value = None
setattr(self, 'custom_%s' % field.name, value)
def save_custom_field_values(self, data):
for field, value in data.items():
if field.startswith('custom_'):
field_name = field.replace('custom_', '', 1)
customfield = CustomField.objects.get(name=field_name)
cfv, created = self.ticketcustomfieldvalue_set.get_or_create(
field=customfield,
defaults={'value': convert_value(value)}
)
if not created:
cfv.value = convert_value(value)
cfv.save()
class FollowUpManager(models.Manager):
@ -1861,6 +1885,52 @@ class CustomField(models.Model):
verbose_name = _('Custom field')
verbose_name_plural = _('Custom fields')
def get_choices(self):
if not self.data_type == 'list':
return None
choices = self.choices_as_array
if self.empty_selection_list:
choices.insert(0, ('', '---------'))
return choices
def build_api_field(self):
customfield_to_api_field_dict = {
'varchar': serializers.CharField,
'text': serializers.CharField,
'integer': serializers.IntegerField,
'decimal': serializers.DecimalField,
'list': serializers.ChoiceField,
'boolean': serializers.BooleanField,
'date': serializers.DateField,
'time': serializers.TimeField,
'datetime': serializers.DateTimeField,
'email': serializers.EmailField,
'url': serializers.URLField,
'ipaddress': serializers.IPAddressField,
'slug': serializers.SlugField,
}
# Prepare attributes for each types
attributes = {
'label': self.label,
'help_text': self.help_text,
'required': self.required,
}
if self.data_type in ('varchar', 'text'):
attributes['max_length'] = self.max_length
if self.data_type == 'text':
attributes['style'] = {'base_template': 'textarea.html'}
elif self.data_type == 'decimal':
attributes['decimal_places'] = self.decimal_places
attributes['max_digits'] = self.max_length
elif self.data_type == 'list':
attributes['choices'] = self.get_choices()
try:
return customfield_to_api_field_dict[self.data_type](**attributes)
except KeyError:
raise NameError("Unrecognized data_type %s" % self.data_type)
class TicketCustomFieldValue(models.Model):
ticket = models.ForeignKey(

View File

@ -1,18 +1,18 @@
from rest_framework import serializers
from .models import Ticket
from .lib import format_time_spent
from django.contrib.humanize.templatetags import humanize
from rest_framework.exceptions import ValidationError
"""
A serializer for the Ticket model, returns data in the format as required by
datatables for ticket_list.html. Called from staff.datatables_ticket_list.
"""
from .forms import TicketForm
from .models import Ticket, CustomField
from .lib import format_time_spent
from .user import HelpdeskUser
class DatatablesTicketSerializer(serializers.ModelSerializer):
"""
A serializer for the Ticket model, returns data in the format as required by
datatables for ticket_list.html. Called from staff.datatables_ticket_list.
"""
ticket = serializers.SerializerMethodField()
assigned_to = serializers.SerializerMethodField()
submitter = serializers.SerializerMethodField()
@ -68,3 +68,45 @@ class DatatablesTicketSerializer(serializers.ModelSerializer):
def get_kbitem(self, obj):
return obj.kbitem.title if obj.kbitem else ""
class TicketSerializer(serializers.ModelSerializer):
class Meta:
model = Ticket
fields = (
'id', 'queue', 'title', 'description', 'resolution', 'submitter_email', 'assigned_to', 'status', 'on_hold',
'priority', 'due_date', 'merged_to'
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Add custom fields
for field in CustomField.objects.all():
self.fields['custom_%s' % field.name] = field.build_api_field()
def create(self, validated_data):
""" Use TicketForm to validate and create ticket """
queues = HelpdeskUser(self.context['request'].user).get_queues()
queue_choices = [(q.id, q.title) for q in queues]
data = validated_data.copy()
data['body'] = data['description']
# TicketForm needs id for ForeignKey (not the instance themselves)
data['queue'] = data['queue'].id
if data.get('assigned_to'):
data['assigned_to'] = data['assigned_to'].id
if data.get('merged_to'):
data['merged_to'] = data['merged_to'].id
ticket_form = TicketForm(data=data, queue_choices=queue_choices)
if ticket_form.is_valid():
ticket = ticket_form.save(user=self.context['request'].user)
ticket.set_custom_field_values()
return ticket
raise ValidationError(ticket_form.errors)
def update(self, instance, validated_data):
instance = super().update(instance, validated_data)
instance.save_custom_field_values(validated_data)
return instance

View File

@ -2,10 +2,12 @@
Default settings for django-helpdesk.
"""
import os
import warnings
from django import forms
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
import os
DEFAULT_USER_SETTINGS = {
'login_view_ticketlist': True,
@ -39,6 +41,16 @@ HELPDESK_REDIRECT_TO_LOGIN_BY_DEFAULT = getattr(settings,
'HELPDESK_REDIRECT_TO_LOGIN_BY_DEFAULT',
False)
# Enable the Dependencies field on ticket view
HELPDESK_ENABLE_DEPENDENCIES_ON_TICKET = getattr(settings,
'HELPDESK_ENABLE_DEPENDENCIES_ON_TICKET',
True)
# Enable the Time spent on field on ticket view
HELPDESK_ENABLE_TIME_SPENT_ON_TICKET = getattr(settings,
'HELPDESK_ENABLE_TIME_SPENT_ON_TICKET',
True)
# raises a 404 to anon users. It's like it was invisible
HELPDESK_ANON_ACCESS_RAISES_404 = getattr(settings,
'HELPDESK_ANON_ACCESS_RAISES_404',
@ -47,6 +59,9 @@ HELPDESK_ANON_ACCESS_RAISES_404 = getattr(settings,
# show knowledgebase links?
HELPDESK_KB_ENABLED = getattr(settings, 'HELPDESK_KB_ENABLED', True)
# Disable Timeline on ticket list
HELPDESK_TICKETS_TIMELINE_ENABLED = getattr(settings, 'HELPDESK_TICKETS_TIMELINE_ENABLED', True)
# show extended navigation by default, to all users, irrespective of staff status?
HELPDESK_NAVIGATION_ENABLED = getattr(settings, 'HELPDESK_NAVIGATION_ENABLED', False)
@ -97,6 +112,21 @@ HELPDESK_PUBLIC_TICKET_FORM_CLASS = getattr(
"helpdesk.forms.PublicTicketForm"
)
# Custom fields constants
CUSTOMFIELD_TO_FIELD_DICT = {
'boolean': forms.BooleanField,
'date': forms.DateField,
'time': forms.TimeField,
'datetime': forms.DateTimeField,
'email': forms.EmailField,
'url': forms.URLField,
'ipaddress': forms.GenericIPAddressField,
'slug': forms.SlugField,
}
CUSTOMFIELD_DATE_FORMAT = "%Y-%m-%d"
CUSTOMFIELD_TIME_FORMAT = "%H:%M:%S"
CUSTOMFIELD_DATETIME_FORMAT = f"{CUSTOMFIELD_DATE_FORMAT}T%H:%M"
###################################
# options for update_ticket views #
@ -155,6 +185,9 @@ HELPDESK_MAX_EMAIL_ATTACHMENT_SIZE = getattr(settings, 'HELPDESK_MAX_EMAIL_ATTAC
HELPDESK_CREATE_TICKET_HIDE_ASSIGNED_TO = getattr(
settings, 'HELPDESK_CREATE_TICKET_HIDE_ASSIGNED_TO', False)
# Activate the API endpoint to manage tickets thanks to Django REST Framework
HELPDESK_ACTIVATE_API_ENDPOINT = getattr(settings, 'HELPDESK_ACTIVATE_API_ENDPOINT', False)
#################
# email options #

View File

@ -46,8 +46,10 @@
<dt><label for="id_new_status">New Status:</label></dt>
<dd>{{ form.new_status }}</dd>
<p>If the status was changed, what was it changed to?</p>
{% if helpdesk_settings.HELPDESK_ENABLE_TIME_SPENT_ON_TICKET %}
<dt><label for="id_time_spent">Time spent:</label></dt>
<dd>{{ form.time_spent }}</dd>
{% endif %}
</dl>
</fieldset>
<p><input class="btn btn-primary btn-sm" type="submit" value="Submit"></p>{% csrf_token %}

View File

@ -45,7 +45,7 @@
<th>{% trans "Open" %}</th>
<th>{% trans "Resolved" %}</th>
<th>{% trans "Closed" %}</th>
<th>{% trans "Time spent" %}</th>
{% if helpdesk_settings.HELPDESK_ENABLE_TIME_SPENT_ON_TICKET %}<th>{% trans "Time spent" %}</th>{% endif %}
</tr>
</thead>
<tbody>
@ -55,7 +55,7 @@
<td>{% if queue.open %}<a href='{{ hdlist }}?queue={{ queue.queue }}&status=1&status=2'>{% endif %}{{ queue.open }}{% if queue.open %}</a>{% endif %}</td>
<td>{% if queue.resolved %}<a href='{{ hdlist }}?queue={{ queue.queue }}&status=3'>{% endif %}{{ queue.resolved }}{% if queue.resolved %}</a>{% endif %}</td>
<td>{% if queue.closed %}<a href='{{ hdlist }}?queue={{ queue.queue }}&status=4'>{% endif %}{{ queue.closed }}{% if queue.closed %}</a>{% endif %}</td>
<td>{{ queue.time_spent }}{% if queue.dedicated_time %} / {{ queue.dedicated_time }}{% endif %}</td>
{% if helpdesk_settings.HELPDESK_ENABLE_TIME_SPENT_ON_TICKET %}<td>{{ queue.time_spent }}{% if queue.dedicated_time %} / {{ queue.dedicated_time }}{% endif %}</td>{% endif %}
</tr>
{% empty %}
<tr><td colspan='6'>{% trans "There are no unassigned tickets." %}</td></tr>

View File

@ -46,7 +46,7 @@
<div class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">{{ followup.title|num_to_link }}</h5>
<small><i class="fas fa-clock"></i>&nbsp;<span class='byline text-info'>{% if followup.user %}by {{ followup.user }},{% endif %} <span title='{{ followup.date|date:"DATETIME_FORMAT" }}'>{{ followup.date|naturaltime }}</span>{% if followup.time_spent %}, <span>{% trans "time spent" %}: {{ followup.time_spent_formated }}</span>{% endif %} {% if not followup.public %} <span class='private'>({% trans "Private" %})</span>{% endif %}</span></small>
<small><i class="fas fa-clock"></i>&nbsp;<span class='byline text-info'>{% if followup.user %}by {{ followup.user }},{% endif %} <span title='{{ followup.date|date:"DATETIME_FORMAT" }}'>{{ followup.date|naturaltime }}</span>{% if helpdesk_settings.HELPDESK_ENABLE_TIME_SPENT_ON_TICKET %}{% if followup.time_spent %}{% endif %}, <span>{% trans "time spent" %}: {{ followup.time_spent_formated }}</span>{% endif %} {% if not followup.public %} <span class='private'>({% trans "Private" %})</span>{% endif %}</span></small>
</div>
<p class="mb-1">
{% if followup.comment %}
@ -156,12 +156,14 @@
<dd class='form_help_text'>{% trans "If this is public, the submitter will be e-mailed your comment or resolution." %}</dd>
{% endif %}
{% if helpdesk_settings.HELPDESK_ENABLE_TIME_SPENT_ON_TICKET %}
{% if user.is_staff %}
<dt>
<label for='id_time_spent'>{% trans "Time spent" %}</label> <span class='form_optional'>{% trans "(Optional)" %}</span>
</dt>
<dd><input name='time_spent' type="time" /></dd>
{% endif %}
{% endif %}
</dl>
<p id='ShowFurtherOptPara'><button class="btn btn-warning btn-sm" id='ShowFurtherEditOptions'>{% trans "Change Further Details &raquo;" %}</button></p>

View File

@ -70,7 +70,9 @@
<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>
</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>
@ -82,9 +84,19 @@
{% 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>

View File

@ -33,6 +33,7 @@
{% block helpdesk_body %}
<div class="card">
{% if helpdesk_settings.HELPDESK_TICKETS_TIMELINE_ENABLED %}
<div class="card-header">
<ul class="nav nav-tabs">
<li class="nav-item" style="width: 200px;">
@ -54,6 +55,7 @@
</li>
</ul>
</div>
{% endif %}
<div class="card-body">
{{ search_message|safe }}
<div class="tab-content" id="myTabContent">
@ -74,8 +76,8 @@
<th>{% trans "Due Date" %}</th>
<th>{% trans "Owner" %}</th>
<th>{% trans "Submitter" %}</th>
<th>{% trans "Time Spent" %}</th>
<th>{% trans "KB item" %}</th>
{% if helpdesk_settings.HELPDESK_ENABLE_TIME_SPENT_ON_TICKET %}<th>{% trans "Time Spent" %}</th>{% endif %}
{% if helpdesk_settings.HELPDESK_KB_ENABLED %}<th>{% trans "KB item" %}</th>{% endif %}
</tr>
</thead>
</table>
@ -125,9 +127,11 @@
</p>
</form>
</div>
{% if helpdesk_settings.HELPDESK_TICKETS_TIMELINE_ENABLED %}
<div class="tab-pane fade" id="timelinetabcontents" role="tabpanel" aria-labelledby="timelinetabcontents-tab">
<div id='timeline-embed' style="width: 100%; height: 80vh"></div>
</div>
{% endif %}
</div>
</div>
<!-- /.panel-body -->
@ -408,8 +412,12 @@
}
},
{data: "submitter"},
{% if helpdesk_settings.HELPDESK_ENABLE_TIME_SPENT_ON_TICKET %}
{data: "time_spent", "visible": false},
{% endif %}
{% if helpdesk_settings.HELPDESK_KB_ENABLED %}
{data: "kbitem"},
{% endif %}
]
});

View File

@ -1,5 +1,5 @@
# import all test_*.py files in directory.
# neccessary for automatic discovery in django <= 1.5
# necessary for automatic discovery in django <= 1.5
# http://stackoverflow.com/a/15780326/1382740
import unittest

262
helpdesk/tests/test_api.py Normal file
View File

@ -0,0 +1,262 @@
import base64
from datetime import datetime
from django.contrib.auth.models import User
from pytz import UTC
from rest_framework import HTTP_HEADER_ENCODING
from rest_framework.exceptions import ErrorDetail
from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_204_NO_CONTENT, HTTP_400_BAD_REQUEST, HTTP_403_FORBIDDEN
from rest_framework.test import APITestCase
from helpdesk.models import Queue, Ticket, CustomField
class TicketTest(APITestCase):
@classmethod
def setUpTestData(cls):
cls.queue = Queue.objects.create(
title='Test Queue',
slug='test-queue',
)
def test_create_api_ticket_not_authenticated_user(self):
response = self.client.post('/api/tickets/')
self.assertEqual(response.status_code, HTTP_403_FORBIDDEN)
def test_create_api_ticket_authenticated_non_staff_user(self):
non_staff_user = User.objects.create_user(username='test')
self.client.force_authenticate(non_staff_user)
response = self.client.post('/api/tickets/')
self.assertEqual(response.status_code, HTTP_403_FORBIDDEN)
def test_create_api_ticket_no_data(self):
staff_user = User.objects.create_user(username='test', is_staff=True)
self.client.force_authenticate(staff_user)
response = self.client.post('/api/tickets/')
self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST)
self.assertEqual(response.data, {
'queue': [ErrorDetail(string='This field is required.', code='required')],
'title': [ErrorDetail(string='This field is required.', code='required')]
})
self.assertFalse(Ticket.objects.exists())
def test_create_api_ticket_wrong_date_format(self):
staff_user = User.objects.create_user(username='test', is_staff=True)
self.client.force_authenticate(staff_user)
response = self.client.post('/api/tickets/', {
'queue': self.queue.id,
'title': 'Test title',
'due_date': 'monday, 1st of may 2022'
})
self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST)
self.assertEqual(response.data, {
'due_date': [ErrorDetail(string='Datetime has wrong format. Use one of these formats instead: YYYY-MM-DDThh:mm[:ss[.uuuuuu]][+HH:MM|-HH:MM|Z].', code='invalid')]
})
self.assertFalse(Ticket.objects.exists())
def test_create_api_ticket_authenticated_staff_user(self):
staff_user = User.objects.create_user(username='test', is_staff=True)
self.client.force_authenticate(staff_user)
response = self.client.post('/api/tickets/', {
'queue': self.queue.id,
'title': 'Test title',
'description': 'Test description\nMulti lines',
'submitter_email': 'test@mail.com',
'priority': 4
})
self.assertEqual(response.status_code, HTTP_201_CREATED)
created_ticket = Ticket.objects.get()
self.assertEqual(created_ticket.title, 'Test title')
self.assertEqual(created_ticket.description, 'Test description\nMulti lines')
self.assertEqual(created_ticket.submitter_email, 'test@mail.com')
self.assertEqual(created_ticket.priority, 4)
def test_create_api_ticket_with_basic_auth(self):
username = 'admin'
password = 'admin'
User.objects.create_user(username=username, password=password, is_staff=True)
test_user = User.objects.create_user(username='test')
merge_ticket = Ticket.objects.create(queue=self.queue, title='merge ticket')
# Generate base64 credentials string
credentials = f"{username}:{password}"
base64_credentials = base64.b64encode(credentials.encode(HTTP_HEADER_ENCODING)).decode(HTTP_HEADER_ENCODING)
self.client.credentials(HTTP_AUTHORIZATION=f"Basic {base64_credentials}")
response = self.client.post(
'/api/tickets/',
{
'queue': self.queue.id,
'title': 'Title',
'description': 'Description',
'resolution': 'Resolution',
'assigned_to': test_user.id,
'submitter_email': 'test@mail.com',
'status': Ticket.RESOLVED_STATUS,
'priority': 1,
'on_hold': True,
'due_date': datetime(2022, 4, 10, 15, 6),
'merged_to': merge_ticket.id
}
)
self.assertEqual(response.status_code, HTTP_201_CREATED)
created_ticket = Ticket.objects.last()
self.assertEqual(created_ticket.title, 'Title')
self.assertEqual(created_ticket.description, 'Description')
self.assertIsNone(created_ticket.resolution) # resolution can not be set on creation
self.assertEqual(created_ticket.assigned_to, test_user)
self.assertEqual(created_ticket.submitter_email, 'test@mail.com')
self.assertEqual(created_ticket.priority, 1)
self.assertFalse(created_ticket.on_hold) # on_hold is False on creation
self.assertEqual(created_ticket.status, Ticket.OPEN_STATUS) # status is always open on creation
self.assertEqual(created_ticket.due_date, datetime(2022, 4, 10, 15, 6, tzinfo=UTC))
self.assertIsNone(created_ticket.merged_to) # merged_to can not be set on creation
def test_edit_api_ticket(self):
staff_user = User.objects.create_user(username='admin', is_staff=True)
test_ticket = Ticket.objects.create(queue=self.queue, title='Test ticket')
test_user = User.objects.create_user(username='test')
merge_ticket = Ticket.objects.create(queue=self.queue, title='merge ticket')
self.client.force_authenticate(staff_user)
response = self.client.put(
'/api/tickets/%d/' % test_ticket.id,
{
'queue': self.queue.id,
'title': 'Title',
'description': 'Description',
'resolution': 'Resolution',
'assigned_to': test_user.id,
'submitter_email': 'test@mail.com',
'status': Ticket.RESOLVED_STATUS,
'priority': 1,
'on_hold': True,
'due_date': datetime(2022, 4, 10, 15, 6),
'merged_to': merge_ticket.id
}
)
self.assertEqual(response.status_code, HTTP_200_OK)
test_ticket.refresh_from_db()
self.assertEqual(test_ticket.title, 'Title')
self.assertEqual(test_ticket.description, 'Description')
self.assertEqual(test_ticket.resolution, 'Resolution')
self.assertEqual(test_ticket.assigned_to, test_user)
self.assertEqual(test_ticket.submitter_email, 'test@mail.com')
self.assertEqual(test_ticket.priority, 1)
self.assertTrue(test_ticket.on_hold)
self.assertEqual(test_ticket.status, Ticket.RESOLVED_STATUS)
self.assertEqual(test_ticket.due_date, datetime(2022, 4, 10, 15, 6, tzinfo=UTC))
self.assertEqual(test_ticket.merged_to, merge_ticket)
def test_partial_edit_api_ticket(self):
staff_user = User.objects.create_user(username='admin', is_staff=True)
test_ticket = Ticket.objects.create(queue=self.queue, title='Test ticket')
self.client.force_authenticate(staff_user)
response = self.client.patch(
'/api/tickets/%d/' % test_ticket.id,
{
'description': 'New description',
}
)
self.assertEqual(response.status_code, HTTP_200_OK)
test_ticket.refresh_from_db()
self.assertEqual(test_ticket.description, 'New description')
def test_delete_api_ticket(self):
staff_user = User.objects.create_user(username='admin', is_staff=True)
test_ticket = Ticket.objects.create(queue=self.queue, title='Test ticket')
self.client.force_authenticate(staff_user)
response = self.client.delete('/api/tickets/%d/' % test_ticket.id)
self.assertEqual(response.status_code, HTTP_204_NO_CONTENT)
self.assertFalse(Ticket.objects.exists())
def test_create_api_ticket_with_custom_fields(self):
# Create custom fields
for field_type, field_display in CustomField.DATA_TYPE_CHOICES:
extra_data = {}
if field_type in ('varchar', 'text'):
extra_data['max_length'] = 10
if field_type == 'integer':
# Set one field as required to test error if not provided
extra_data['required'] = True
if field_type == 'decimal':
extra_data['max_length'] = 7
extra_data['decimal_places'] = 3
if field_type == 'list':
extra_data['list_values'] = '''Green
Blue
Red
Yellow'''
CustomField.objects.create(name=field_type, label=field_display, data_type=field_type, **extra_data)
staff_user = User.objects.create_user(username='test', is_staff=True)
self.client.force_authenticate(staff_user)
# Test creation without providing required field
response = self.client.post('/api/tickets/', {
'queue': self.queue.id,
'title': 'Test title',
'description': 'Test description\nMulti lines',
'submitter_email': 'test@mail.com',
'priority': 4
})
self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST)
self.assertEqual(response.data, {'custom_integer': [ErrorDetail(string='This field is required.', code='required')]})
# Test creation with custom field values
response = self.client.post('/api/tickets/', {
'queue': self.queue.id,
'title': 'Test title',
'description': 'Test description\nMulti lines',
'submitter_email': 'test@mail.com',
'priority': 4,
'custom_varchar': 'test',
'custom_text': 'multi\nline',
'custom_integer': '1',
'custom_decimal': '42.987',
'custom_list': 'Red',
'custom_boolean': True,
'custom_date': '2022-4-11',
'custom_time': '23:59:59',
'custom_datetime': '2022-4-10 18:27',
'custom_email': 'email@test.com',
'custom_url': 'http://django-helpdesk.readthedocs.org/',
'custom_ipaddress': '127.0.0.1',
'custom_slug': 'test-slug',
})
self.assertEqual(response.status_code, HTTP_201_CREATED)
# Check all fields with data returned from the response
self.assertEqual(response.data, {
'id': 1,
'queue': 1,
'title': 'Test title',
'description': 'Test description\nMulti lines',
'resolution': None,
'submitter_email': 'test@mail.com',
'assigned_to': None,
'status': 1,
'on_hold': False,
'priority': 4,
'due_date': None,
'merged_to': None,
'custom_varchar': 'test',
'custom_text': 'multi\nline',
'custom_integer': 1,
'custom_decimal': '42.987',
'custom_list': 'Red',
'custom_boolean': True,
'custom_date': '2022-04-11',
'custom_time': '23:59:59',
'custom_datetime': '2022-04-10T18:27',
'custom_email': 'email@test.com',
'custom_url': 'http://django-helpdesk.readthedocs.org/',
'custom_ipaddress': '127.0.0.1',
'custom_slug': 'test-slug'
})

View File

@ -147,7 +147,7 @@ class AttachmentUnitTests(TestCase):
self.assertEqual(obj.size, len(self.file_attrs['content']))
self.assertEqual(obj.mime_type, "text/plain")
@mock.patch.object(lib.FollowUpAttachment, 'save', autospec=True)
@mock.patch.object('helpdesk.lib.FollowUpAttachment', 'save', autospec=True)
@override_settings(MEDIA_ROOT=MEDIA_DIR)
def test_unicode_filename_to_filesystem(self, mock_att_save, mock_queue_save, mock_ticket_save, mock_follow_up_save):
""" don't mock saving to filesystem to test file renames caused by storage layer """

View File

@ -52,7 +52,7 @@ class GetEmailCommonTests(TestCase):
"""
with open(os.path.join(THIS_DIR, "test_files/blank-body-with-attachment.eml")) as fd:
test_email = fd.read()
ticket = helpdesk.email.object_from_message(test_email, self.queue_public, self.logger)
ticket = helpdesk.email.object_from_message(test_email, self.queue_public, self.logger)
# title got truncated because of max_lengh of the model.title field
assert ticket.title == (
@ -68,7 +68,7 @@ class GetEmailCommonTests(TestCase):
"""
with open(os.path.join(THIS_DIR, "test_files/quoted_printable.eml")) as fd:
test_email = fd.read()
ticket = helpdesk.email.object_from_message(test_email, self.queue_public, self.logger)
ticket = helpdesk.email.object_from_message(test_email, self.queue_public, self.logger)
self.assertEqual(ticket.title, "Český test")
self.assertEqual(ticket.description, "Tohle je test českých písmen odeslaných z gmailu.")
followups = FollowUp.objects.filter(ticket=ticket)
@ -86,7 +86,7 @@ class GetEmailCommonTests(TestCase):
"""
with open(os.path.join(THIS_DIR, "test_files/all-special-chars.eml")) as fd:
test_email = fd.read()
ticket = helpdesk.email.object_from_message(test_email, self.queue_public, self.logger)
ticket = helpdesk.email.object_from_message(test_email, self.queue_public, self.logger)
self.assertEqual(ticket.title, "Testovácí email")
self.assertEqual(ticket.description, "íářčšáíéřášč")

View File

@ -603,13 +603,16 @@ class EmailInteractionsTestCase(TestCase):
# As we have created a Ticket from an email, we notify the sender
# and contacts on the cc_list (+1 as it's treated as a list),
# the new and update queues (+2)
# then each cc gets its own email? (+2)
# TODO: check this is correct!
# Ensure that the submitter is notified
self.assertIn(submitter_email, mail.outbox[0].to)
# As we have created an Ticket from an email, we notify the sender (+1)
# and the new and update queues (+2)
expected_email_count = 1 + 2
# then each cc gets its own email? (+2)
expected_email_count = 1 + 2 + 2
self.assertEqual(expected_email_count, len(mail.outbox))
# end of the Ticket and TicketCCs creation #
@ -637,7 +640,8 @@ class EmailInteractionsTestCase(TestCase):
# As an update was made, we increase the expected_email_count with:
# public_update_queue: +1
expected_email_count += 1
# since the submitter and the two ccs each get an email
expected_email_count += 1 + 3
self.assertEqual(expected_email_count, len(mail.outbox))
# As we have created a FollowUp from an email, we notify the sender

View File

@ -10,13 +10,14 @@ urls.py - Mapping of URL's to our various views. Note we always used NAMED
from django.conf.urls import url
from django.contrib.auth.decorators import login_required
from django.contrib.auth import views as auth_views
from django.urls import include
from django.views.generic import TemplateView
from rest_framework.routers import DefaultRouter
from helpdesk.decorators import helpdesk_staff_member_required, protect_view
from helpdesk import settings as helpdesk_settings
from helpdesk.views import feeds, staff, public, login
from helpdesk import settings as helpdesk_settings
from helpdesk.views.api import TicketViewSet
if helpdesk_settings.HELPDESK_KB_ENABLED:
from helpdesk.views import kb
@ -107,14 +108,6 @@ urlpatterns = [
staff.ticket_cc_del,
name='ticket_cc_del'),
url(r'^tickets/(?P<ticket_id>[0-9]+)/dependency/add/$',
staff.ticket_dependency_add,
name='ticket_dependency_add'),
url(r'^tickets/(?P<ticket_id>[0-9]+)/dependency/delete/(?P<dependency_id>[0-9]+)/$',
staff.ticket_dependency_del,
name='ticket_dependency_del'),
url(r'^tickets/(?P<ticket_id>[0-9]+)/attachment_delete/(?P<attachment_id>[0-9]+)/$',
staff.attachment_del,
name='attachment_del'),
@ -169,6 +162,17 @@ urlpatterns = [
]
if helpdesk_settings.HELPDESK_ENABLE_DEPENDENCIES_ON_TICKET:
urlpatterns += [
url(r'^tickets/(?P<ticket_id>[0-9]+)/dependency/add/$',
staff.ticket_dependency_add,
name='ticket_dependency_add'),
url(r'^tickets/(?P<ticket_id>[0-9]+)/dependency/delete/(?P<dependency_id>[0-9]+)/$',
staff.ticket_dependency_del,
name='ticket_dependency_del'),
]
urlpatterns += [
url(r'^$',
protect_view(public.Homepage.as_view()),
@ -218,6 +222,15 @@ urlpatterns += [
]
# API is added to url conf based on the setting (False by default)
if helpdesk_settings.HELPDESK_ACTIVATE_API_ENDPOINT:
router = DefaultRouter()
router.register(r'tickets', TicketViewSet, basename='ticket')
urlpatterns += [
url(r'^api/', include(router.urls))
]
urlpatterns += [
url(r'^login/$',
login.login,

View File

@ -16,10 +16,12 @@ def validate_file_extension(value):
# check if VALID_EXTENSIONS is defined in settings.py
# if not use defaults
if settings.VALID_EXTENSIONS:
if hasattr(settings, 'VALID_EXTENSIONS'):
valid_extensions = settings.VALID_EXTENSIONS
else:
valid_extensions = ['.txt', '.pdf', '.doc', '.docx', '.odt', '.jpg', '.png']
valid_extensions = ['.txt', '.asc', '.htm', '.html', '.pdf', '.doc', '.docx', '.odt', '.jpg', '.png', '.eml']
if not ext.lower() in valid_extensions:
raise ValidationError('Unsupported file extension.')
# TODO: one more check in case it is a file with no extension; we should always allow that?
if not (ext.lower() == '' or ext.lower() == '.'):
raise ValidationError('Unsupported file extension: %s.' % ext.lower())

25
helpdesk/views/api.py Normal file
View File

@ -0,0 +1,25 @@
from rest_framework import viewsets
from rest_framework.permissions import IsAdminUser
from helpdesk.models import Ticket
from helpdesk.serializers import TicketSerializer
class TicketViewSet(viewsets.ModelViewSet):
"""
A viewset that provides the standard actions to handle Ticket
"""
queryset = Ticket.objects.all()
serializer_class = TicketSerializer
permission_classes = [IsAdminUser]
def get_queryset(self):
tickets = Ticket.objects.all()
for ticket in tickets:
ticket.set_custom_field_values()
return tickets
def get_object(self):
ticket = super().get_object()
ticket.set_custom_field_values()
return ticket

View File

@ -1167,6 +1167,7 @@ def ticket_list(request):
from_saved_query=saved_query is not None,
saved_query=saved_query,
search_message=search_message,
helpdesk_settings=helpdesk_settings,
))

View File

@ -40,6 +40,7 @@ class QuickDjangoTest(object):
#'account',
#'pinax.invitations',
#'pinax.teams',
'rest_framework',
'helpdesk',
#'reversion',
)
@ -104,7 +105,9 @@ class QuickDjangoTest(object):
## The following settings disable teams
HELPDESK_TEAMS_MODEL = 'auth.User',
HELPDESK_TEAMS_MIGRATION_DEPENDENCIES = [],
HELPDESK_KBITEM_TEAM_GETTER = lambda _: None
HELPDESK_KBITEM_TEAM_GETTER = lambda _: None,
## test the API
HELPDESK_ACTIVATE_API_ENDPOINT=True
)
from django.test.runner import DiscoverRunner