From 1daa1d88aafcef8186400baa9f3802cb48105a57 Mon Sep 17 00:00:00 2001 From: bbe Date: Thu, 31 Mar 2022 17:19:49 +0200 Subject: [PATCH 01/11] Create an API endpoint to list/create/update/delete tickets (including custom fields !) + Refactoring in forms.py --- demo/demodesk/config/settings.py | 1 + demo/demodesk/config/urls.py | 1 + helpdesk/forms.py | 45 +++----------------- helpdesk/lib.py | 19 +++++++-- helpdesk/models.py | 70 ++++++++++++++++++++++++++++++++ helpdesk/serializers.py | 60 +++++++++++++++++++++++---- helpdesk/settings.py | 19 ++++++++- helpdesk/urls.py | 13 +++++- helpdesk/views/api.py | 26 ++++++++++++ 9 files changed, 199 insertions(+), 55 deletions(-) create mode 100644 helpdesk/views/api.py diff --git a/demo/demodesk/config/settings.py b/demo/demodesk/config/settings.py index 7914d648..e2998c30 100644 --- a/demo/demodesk/config/settings.py +++ b/demo/demodesk/config/settings.py @@ -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 = [ diff --git a/demo/demodesk/config/urls.py b/demo/demodesk/config/urls.py index 0c37aac6..52f6f407 100644 --- a/demo/demodesk/config/urls.py +++ b/demo/demodesk/config/urls.py @@ -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) diff --git a/helpdesk/forms.py b/helpdesk/forms.py index 399b7e18..6b776bfc 100644 --- a/helpdesk/forms.py +++ b/helpdesk/forms.py @@ -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, diff --git a/helpdesk/lib.py b/helpdesk/lib.py index da853990..05e80dfd 100644 --- a/helpdesk/lib.py +++ b/helpdesk/lib.py @@ -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( 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 diff --git a/helpdesk/models.py b/helpdesk/models.py index f8ffb8b1..80aa3bae 100644 --- a/helpdesk/models.py +++ b/helpdesk/models.py @@ -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( diff --git a/helpdesk/serializers.py b/helpdesk/serializers.py index df0c6a5f..3d7b61fd 100644 --- a/helpdesk/serializers.py +++ b/helpdesk/serializers.py @@ -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', 'last_escalation', '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['assigned_to']: + data['assigned_to'] = data['assigned_to'].id + if data['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 diff --git a/helpdesk/settings.py b/helpdesk/settings.py index c68cc4b9..56be1dbb 100644 --- a/helpdesk/settings.py +++ b/helpdesk/settings.py @@ -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, @@ -97,6 +99,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 # diff --git a/helpdesk/urls.py b/helpdesk/urls.py index e9df6bb2..29a6654f 100644 --- a/helpdesk/urls.py +++ b/helpdesk/urls.py @@ -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 @@ -218,6 +219,14 @@ urlpatterns += [ ] +# API +router = DefaultRouter() +router.register(r'tickets', TicketViewSet, basename='ticket') +urlpatterns += [ + url(r'^api/', include(router.urls)) +] + + urlpatterns += [ url(r'^login/$', login.login, diff --git a/helpdesk/views/api.py b/helpdesk/views/api.py new file mode 100644 index 00000000..272b386f --- /dev/null +++ b/helpdesk/views/api.py @@ -0,0 +1,26 @@ +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 + From 88b234958a2fc7aeb2b915249a7a7bc89690ab47 Mon Sep 17 00:00:00 2001 From: Benbb96 Date: Thu, 7 Apr 2022 00:42:12 +0200 Subject: [PATCH 02/11] Fix a bug when assigned_to or merged_to were not passed in body --- helpdesk/serializers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/helpdesk/serializers.py b/helpdesk/serializers.py index 3d7b61fd..7852f620 100644 --- a/helpdesk/serializers.py +++ b/helpdesk/serializers.py @@ -93,9 +93,9 @@ class TicketSerializer(serializers.ModelSerializer): data['body'] = data['description'] # TicketForm needs id for ForeignKey (not the instance themselves) data['queue'] = data['queue'].id - if data['assigned_to']: + if data.get('assigned_to'): data['assigned_to'] = data['assigned_to'].id - if data['merged_to']: + if data.get('merged_to'): data['merged_to'] = data['merged_to'].id ticket_form = TicketForm(data=data, queue_choices=queue_choices) From b8451eef164bcab0598de456b8fc4c5e70687653 Mon Sep 17 00:00:00 2001 From: Benbb96 Date: Thu, 7 Apr 2022 00:43:00 +0200 Subject: [PATCH 03/11] Add an API section in documentation + fix small things --- docs/api.rst | 59 ++++++++++++++++++++++++++++++++++++++++++++++++ docs/index.rst | 5 +++- docs/install.rst | 19 ++++++++-------- 3 files changed, 73 insertions(+), 10 deletions(-) create mode 100644 docs/api.rst diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 00000000..c11df0c5 --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,59 @@ +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. + +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/`` 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 optonal 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 +- **last_escalation**: date representation of when last escalation has been done +- **merged_to**: ID of the ticket to which it is merged + +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/`` 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/`` 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/`` with a **DELETE** request will let you delete the ticket you provided the ID. diff --git a/docs/index.rst b/docs/index.rst index 15888094..6ed44704 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -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. diff --git a/docs/install.rst b/docs/install.rst index e1107b07..6b34c3d4 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -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:: From 25b1e1e5bdbb8a2480c671e3bab9d56f02b9bfbf Mon Sep 17 00:00:00 2001 From: Benbb96 Date: Thu, 7 Apr 2022 00:48:50 +0200 Subject: [PATCH 04/11] Add a sentence about custom fields --- docs/api.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/api.rst b/docs/api.rst index c11df0c5..805000ca 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -32,6 +32,8 @@ You need to provide a JSON body with the following data : - **last_escalation**: date representation of when last escalation has been done - **merged_to**: ID of the ticket to which it is merged +Moreover, if you created custom fields, you can add them into the body with the key ``custom_``. + 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/' \ From dbcd0fe6b45de144d7d989fad38ca9cf1707a925 Mon Sep 17 00:00:00 2001 From: Benbb96 Date: Mon, 11 Apr 2022 00:15:11 +0200 Subject: [PATCH 05/11] Remove last_escalation field from serializer since it is a non-editable field --- helpdesk/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helpdesk/serializers.py b/helpdesk/serializers.py index 7852f620..eb0cc447 100644 --- a/helpdesk/serializers.py +++ b/helpdesk/serializers.py @@ -75,7 +75,7 @@ class TicketSerializer(serializers.ModelSerializer): model = Ticket fields = ( 'id', 'queue', 'title', 'description', 'resolution', 'submitter_email', 'assigned_to', 'status', 'on_hold', - 'priority', 'due_date', 'last_escalation', 'merged_to' + 'priority', 'due_date', 'merged_to' ) def __init__(self, *args, **kwargs): From 5701bf0229129ebbc794a97545c6afb8c02f70f1 Mon Sep 17 00:00:00 2001 From: Benbb96 Date: Mon, 11 Apr 2022 00:15:48 +0200 Subject: [PATCH 06/11] Fix a bug with get_choices function which doesn't return anything if empty_selection_list is False --- helpdesk/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helpdesk/models.py b/helpdesk/models.py index 80aa3bae..cb3d4149 100644 --- a/helpdesk/models.py +++ b/helpdesk/models.py @@ -1891,7 +1891,7 @@ class CustomField(models.Model): choices = self.choices_as_array if self.empty_selection_list: choices.insert(0, ('', '---------')) - return choices + return choices def build_api_field(self): customfield_to_api_field_dict = { From 31acfb11ddccb66f9383e797acba750ca7f44ccd Mon Sep 17 00:00:00 2001 From: Benbb96 Date: Mon, 11 Apr 2022 00:16:54 +0200 Subject: [PATCH 07/11] Add tests for the API --- helpdesk/tests/test_api.py | 262 +++++++++++++++++++++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 helpdesk/tests/test_api.py diff --git a/helpdesk/tests/test_api.py b/helpdesk/tests/test_api.py new file mode 100644 index 00000000..3213509b --- /dev/null +++ b/helpdesk/tests/test_api.py @@ -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_201_CREATED, HTTP_200_OK, HTTP_204_NO_CONTENT, HTTP_400_BAD_REQUEST +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, 403) + + 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, 403) + + 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) + 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) + + 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' + }) + From 7e5ff05b02b3be8bc3dd37551a320c5435b734ad Mon Sep 17 00:00:00 2001 From: Benbb96 Date: Mon, 11 Apr 2022 00:23:22 +0200 Subject: [PATCH 08/11] Update documentation --- docs/api.rst | 7 ++++--- helpdesk/tests/test_api.py | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 805000ca..42f4ec5d 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -22,16 +22,17 @@ 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 optonal text for the resoltuion 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 -- **last_escalation**: date representation of when last escalation has been done - **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_``. Here is an example of a cURL request to create a ticket (using Basic authentication) :: @@ -39,7 +40,7 @@ Here is an example of a cURL request to create a ticket (using Basic authenticat 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}' + --data-raw '{"queue": 1, "title": "Test Ticket API", "description": "Test create ticket from API", "submitter_email": "test@mail.com", "priority": 4}' PUT --- diff --git a/helpdesk/tests/test_api.py b/helpdesk/tests/test_api.py index 3213509b..4b679479 100644 --- a/helpdesk/tests/test_api.py +++ b/helpdesk/tests/test_api.py @@ -109,10 +109,10 @@ class TicketTest(APITestCase): 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) + 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) + 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) From 9320df02683f084a4af01c2dc03c38bdd4d5a502 Mon Sep 17 00:00:00 2001 From: Benbb96 Date: Thu, 14 Apr 2022 23:30:42 +0200 Subject: [PATCH 09/11] Fix a bug I introduced in earlier commit + fix one test --- helpdesk/lib.py | 2 +- helpdesk/tests/test_attachments.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/helpdesk/lib.py b/helpdesk/lib.py index 05e80dfd..f9adb174 100644 --- a/helpdesk/lib.py +++ b/helpdesk/lib.py @@ -136,7 +136,7 @@ def process_attachments(followup, attached_files): if attached.size: filename = smart_text(attached.name) - att = followup.followupattachment_set( + att = followup.followupattachment_set.create( file=attached, filename=filename, mime_type=attached.content_type or diff --git a/helpdesk/tests/test_attachments.py b/helpdesk/tests/test_attachments.py index 942a2353..82983e68 100644 --- a/helpdesk/tests/test_attachments.py +++ b/helpdesk/tests/test_attachments.py @@ -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 """ From 3e331c08fb52b526dbc3bbbe92be463cf4283226 Mon Sep 17 00:00:00 2001 From: Benbb96 Date: Thu, 14 Apr 2022 23:45:19 +0200 Subject: [PATCH 10/11] Try to fix other tests --- helpdesk/validators.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/helpdesk/validators.py b/helpdesk/validators.py index 01500a9a..1c0ccb24 100644 --- a/helpdesk/validators.py +++ b/helpdesk/validators.py @@ -16,10 +16,10 @@ 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', '.pdf', '.doc', '.docx', '.odt', '.jpg', '.png', '.eml'] if not ext.lower() in valid_extensions: raise ValidationError('Unsupported file extension.') From dc60d8a7f95ed789872817bf3a7e269f38b5fc3e Mon Sep 17 00:00:00 2001 From: Benbb96 Date: Fri, 15 Apr 2022 00:05:51 +0200 Subject: [PATCH 11/11] Add setting HELPDESK_ACTIVATE_API_ENDPOINT and document it --- demo/demodesk/config/settings.py | 3 +++ docs/api.rst | 4 ++++ docs/settings.rst | 4 ++++ helpdesk/settings.py | 3 +++ helpdesk/urls.py | 13 +++++++------ 5 files changed, 21 insertions(+), 6 deletions(-) diff --git a/demo/demodesk/config/settings.py b/demo/demodesk/config/settings.py index e2998c30..e4ac94f1 100644 --- a/demo/demodesk/config/settings.py +++ b/demo/demodesk/config/settings.py @@ -117,6 +117,9 @@ HELPDESK_KB_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 diff --git a/docs/api.rst b/docs/api.rst index 42f4ec5d..474bfe22 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -3,6 +3,10 @@ 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 diff --git a/docs/settings.rst b/docs/settings.rst index 405e8f8d..246ce4b6 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -168,6 +168,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 ------------------------------ diff --git a/helpdesk/settings.py b/helpdesk/settings.py index 56be1dbb..a46af953 100644 --- a/helpdesk/settings.py +++ b/helpdesk/settings.py @@ -172,6 +172,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 # diff --git a/helpdesk/urls.py b/helpdesk/urls.py index 29a6654f..fc185cc9 100644 --- a/helpdesk/urls.py +++ b/helpdesk/urls.py @@ -219,12 +219,13 @@ urlpatterns += [ ] -# API -router = DefaultRouter() -router.register(r'tickets', TicketViewSet, basename='ticket') -urlpatterns += [ - url(r'^api/', include(router.urls)) -] +# 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 += [