diff --git a/demo/demodesk/config/settings.py b/demo/demodesk/config/settings.py index 7914d648..ec301a91 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 = [ @@ -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 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/docs/api.rst b/docs/api.rst new file mode 100644 index 00000000..474bfe22 --- /dev/null +++ b/docs/api.rst @@ -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/`` 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_``. + +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 cbe7dd45..3fd64a3a 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:: diff --git a/docs/settings.rst b/docs/settings.rst index 405e8f8d..d8f5f7af 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -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 ------------------------------ 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..f9adb174 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.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 diff --git a/helpdesk/models.py b/helpdesk/models.py index f8ffb8b1..cb3d4149 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..eb0cc447 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', '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 diff --git a/helpdesk/settings.py b/helpdesk/settings.py index c68cc4b9..4b5fb7bb 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, @@ -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 # diff --git a/helpdesk/templates/helpdesk/followup_edit.html b/helpdesk/templates/helpdesk/followup_edit.html index e74e3203..af61e6e1 100644 --- a/helpdesk/templates/helpdesk/followup_edit.html +++ b/helpdesk/templates/helpdesk/followup_edit.html @@ -46,8 +46,10 @@
{{ form.new_status }}

If the status was changed, what was it changed to?

+ {% if helpdesk_settings.HELPDESK_ENABLE_TIME_SPENT_ON_TICKET %}
{{ form.time_spent }}
+ {% endif %}

{% csrf_token %} diff --git a/helpdesk/templates/helpdesk/report_index.html b/helpdesk/templates/helpdesk/report_index.html index 4d82f40c..63194afb 100644 --- a/helpdesk/templates/helpdesk/report_index.html +++ b/helpdesk/templates/helpdesk/report_index.html @@ -45,7 +45,7 @@ {% trans "Open" %} {% trans "Resolved" %} {% trans "Closed" %} - {% trans "Time spent" %} + {% if helpdesk_settings.HELPDESK_ENABLE_TIME_SPENT_ON_TICKET %}{% trans "Time spent" %}{% endif %} @@ -55,7 +55,7 @@ {% if queue.open %}{% endif %}{{ queue.open }}{% if queue.open %}{% endif %} {% if queue.resolved %}{% endif %}{{ queue.resolved }}{% if queue.resolved %}{% endif %} {% if queue.closed %}{% endif %}{{ queue.closed }}{% if queue.closed %}{% endif %} - {{ queue.time_spent }}{% if queue.dedicated_time %} / {{ queue.dedicated_time }}{% endif %} + {% if helpdesk_settings.HELPDESK_ENABLE_TIME_SPENT_ON_TICKET %}{{ queue.time_spent }}{% if queue.dedicated_time %} / {{ queue.dedicated_time }}{% endif %}{% endif %} {% empty %} {% trans "There are no unassigned tickets." %} diff --git a/helpdesk/templates/helpdesk/ticket.html b/helpdesk/templates/helpdesk/ticket.html index 5eade600..ff0a3fa8 100644 --- a/helpdesk/templates/helpdesk/ticket.html +++ b/helpdesk/templates/helpdesk/ticket.html @@ -46,7 +46,7 @@
{{ followup.title|num_to_link }}
-   +  

{% if followup.comment %} @@ -156,12 +156,14 @@

{% trans "If this is public, the submitter will be e-mailed your comment or resolution." %}
{% endif %} + {% if helpdesk_settings.HELPDESK_ENABLE_TIME_SPENT_ON_TICKET %} {% if user.is_staff %}
{% trans "(Optional)" %}
{% endif %} + {% endif %}

diff --git a/helpdesk/templates/helpdesk/ticket_desc_table.html b/helpdesk/templates/helpdesk/ticket_desc_table.html index 36df46fb..4a09a58b 100644 --- a/helpdesk/templates/helpdesk/ticket_desc_table.html +++ b/helpdesk/templates/helpdesk/ticket_desc_table.html @@ -70,7 +70,9 @@ {{ ticketcc_string }} {% if SHOW_SUBSCRIBE %} {% endif %} + {% if helpdesk_settings.HELPDESK_ENABLE_DEPENDENCIES_ON_TICKET != False and helpdesk_settings.HELPDESK_ENABLE_TIME_SPENT_ON_TICKET != False %} + {% if helpdesk_settings.HELPDESK_ENABLE_DEPENDENCIES_ON_TICKET %} {% trans "Dependencies" %} @@ -82,9 +84,19 @@ {% trans "This ticket has no dependencies." %} {% endfor %} + {% else %} + + + {% endif %} + {% if helpdesk_settings.HELPDESK_ENABLE_TIME_SPENT_ON_TICKET %} {% trans "Total time spent" %} {{ ticket.time_spent_formated }} + {% else %} + + + {% endif %} + {% endif %} {% if ticket.kbitem %} {% trans "Knowlegebase item" %} diff --git a/helpdesk/templates/helpdesk/ticket_list.html b/helpdesk/templates/helpdesk/ticket_list.html index 2894349b..b8aa32f7 100644 --- a/helpdesk/templates/helpdesk/ticket_list.html +++ b/helpdesk/templates/helpdesk/ticket_list.html @@ -33,6 +33,7 @@ {% block helpdesk_body %}
+ {% if helpdesk_settings.HELPDESK_TICKETS_TIMELINE_ENABLED %}
+ {% endif %}
{{ search_message|safe }}
@@ -74,8 +76,8 @@ {% trans "Due Date" %} {% trans "Owner" %} {% trans "Submitter" %} - {% trans "Time Spent" %} - {% trans "KB item" %} + {% if helpdesk_settings.HELPDESK_ENABLE_TIME_SPENT_ON_TICKET %}{% trans "Time Spent" %}{% endif %} + {% if helpdesk_settings.HELPDESK_KB_ENABLED %}{% trans "KB item" %}{% endif %} @@ -125,9 +127,11 @@

+ {% if helpdesk_settings.HELPDESK_TICKETS_TIMELINE_ENABLED %}
+ {% endif %}
@@ -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 %} ] }); diff --git a/helpdesk/tests/__init__.py b/helpdesk/tests/__init__.py index 807df353..221459dd 100644 --- a/helpdesk/tests/__init__.py +++ b/helpdesk/tests/__init__.py @@ -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 diff --git a/helpdesk/tests/test_api.py b/helpdesk/tests/test_api.py new file mode 100644 index 00000000..8ca8a43c --- /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_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' + }) + 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 """ diff --git a/helpdesk/tests/test_get_email.py b/helpdesk/tests/test_get_email.py index c113b04f..07df3348 100644 --- a/helpdesk/tests/test_get_email.py +++ b/helpdesk/tests/test_get_email.py @@ -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, "íářčšáíéřášč") diff --git a/helpdesk/tests/test_ticket_submission.py b/helpdesk/tests/test_ticket_submission.py index d9a6b22d..ce4813b2 100644 --- a/helpdesk/tests/test_ticket_submission.py +++ b/helpdesk/tests/test_ticket_submission.py @@ -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 diff --git a/helpdesk/urls.py b/helpdesk/urls.py index e9df6bb2..711ffa00 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 @@ -107,14 +108,6 @@ urlpatterns = [ staff.ticket_cc_del, name='ticket_cc_del'), - url(r'^tickets/(?P[0-9]+)/dependency/add/$', - staff.ticket_dependency_add, - name='ticket_dependency_add'), - - url(r'^tickets/(?P[0-9]+)/dependency/delete/(?P[0-9]+)/$', - staff.ticket_dependency_del, - name='ticket_dependency_del'), - url(r'^tickets/(?P[0-9]+)/attachment_delete/(?P[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[0-9]+)/dependency/add/$', + staff.ticket_dependency_add, + name='ticket_dependency_add'), + + url(r'^tickets/(?P[0-9]+)/dependency/delete/(?P[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, diff --git a/helpdesk/validators.py b/helpdesk/validators.py index 01500a9a..f7e5b5f5 100644 --- a/helpdesk/validators.py +++ b/helpdesk/validators.py @@ -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()) diff --git a/helpdesk/views/api.py b/helpdesk/views/api.py new file mode 100644 index 00000000..dbeabf7c --- /dev/null +++ b/helpdesk/views/api.py @@ -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 diff --git a/helpdesk/views/staff.py b/helpdesk/views/staff.py index d8cd1f5d..82cddfa6 100644 --- a/helpdesk/views/staff.py +++ b/helpdesk/views/staff.py @@ -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, )) diff --git a/quicktest.py b/quicktest.py index 0c488f9b..3837abd8 100644 --- a/quicktest.py +++ b/quicktest.py @@ -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