diff --git a/docs/api.rst b/docs/api.rst index 234d4a5b..77e4b587 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1,20 +1,25 @@ 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. +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/) +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 complete list of tickets with their +followups and their attachment files. -Accessing the endpoint ``/api/tickets/`` with a **GET** request will return you the data of the ticket you provided the ID. +Accessing the endpoint ``/api/tickets/`` with a **GET** request will return you the data of the ticket you +provided the ID. POST ---- @@ -35,7 +40,8 @@ You need to provide a JSON body with the following data : - **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``. +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_``. @@ -46,6 +52,34 @@ Here is an example of a cURL request to create a ticket (using Basic authenticat --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}' +Note that you can attach one file as attachment but in this case, you cannot use JSON for the request content type. +Here is an example with form-data (curl default) :: + + curl --location --request POST 'http://127.0.0.1:8000/api/tickets/' \ + --header 'Authorization: Basic YWRtaW46YWRtaW4=' \ + --form 'queue="1"' \ + --form 'title="Test Ticket API with attachment"' \ + --form 'description="Test create ticket from API avec attachment"' \ + --form 'submitter_email="test@mail.com"' \ + --form 'priority="2"' \ + --form 'attachment=@"/C:/Users/benbb96/Documents/file.txt"' + +---- + +Accessing the endpoint ``/api/followups/`` with a **POST** request will let you create a new followup on a ticket. + +This time, you can attach multiple files thanks to the `attachments` field. Here is an example :: + + curl --location --request POST 'http://127.0.0.1:8000/api/followups/' \ + --header 'Authorization: Basic YWRtaW46YWRtaW4=' \ + --form 'ticket="44"' \ + --form 'title="Test ticket answer"' \ + --form 'comment="This answer contains multiple files as attachment."' \ + --form 'attachments=@"/C:/Users/benbb96/Documents/doc.pdf"' \ + --form 'attachments=@"/C:/Users/benbb96/Documents/image.png"' + +---- + Accessing the endpoint ``/api/users/`` with a **POST** request will let you create a new user. You need to provide a JSON body with the following data : @@ -59,18 +93,21 @@ You need to provide a JSON body with the following data : PUT --- -Accessing the endpoint ``/api/tickets/`` with a **PUT** request will let you update the data of the ticket you provided the ID. +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. +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. +Accessing the endpoint ``/api/tickets/`` with a **DELETE** request will let you delete the ticket you +provided the ID. diff --git a/helpdesk/serializers.py b/helpdesk/serializers.py index b5734c49..ebd5cb30 100644 --- a/helpdesk/serializers.py +++ b/helpdesk/serializers.py @@ -4,8 +4,8 @@ from django.contrib.humanize.templatetags import humanize from rest_framework.exceptions import ValidationError from .forms import TicketForm -from .models import Ticket, CustomField -from .lib import format_time_spent +from .models import Ticket, CustomField, FollowUp, FollowUpAttachment +from .lib import format_time_spent, process_attachments from .user import HelpdeskUser @@ -71,12 +71,44 @@ class DatatablesTicketSerializer(serializers.ModelSerializer): return obj.kbitem.title if obj.kbitem else "" +class FollowUpAttachmentSerializer(serializers.ModelSerializer): + class Meta: + model = FollowUpAttachment + fields = ('id', 'followup', 'file', 'filename', 'mime_type', 'size') + + +class FollowUpSerializer(serializers.ModelSerializer): + followupattachment_set = FollowUpAttachmentSerializer(many=True, read_only=True) + attachments = serializers.ListField( + child=serializers.FileField(), + write_only=True, + required=False + ) + + class Meta: + model = FollowUp + fields = ( + 'id', 'ticket', 'date', 'title', 'comment', 'public', 'user', 'new_status', 'message_id', + 'time_spent', 'followupattachment_set', 'attachments' + ) + + def create(self, validated_data): + attachments = validated_data.pop('attachments', None) + followup = super().create(validated_data) + if attachments: + process_attachments(followup, attachments) + return followup + + class TicketSerializer(serializers.ModelSerializer): + followup_set = FollowUpSerializer(many=True, read_only=True) + attachment = serializers.FileField(write_only=True, required=False) + class Meta: model = Ticket fields = ( 'id', 'queue', 'title', 'description', 'resolution', 'submitter_email', 'assigned_to', 'status', 'on_hold', - 'priority', 'due_date', 'merged_to' + 'priority', 'due_date', 'merged_to', 'attachment', 'followup_set' ) def __init__(self, *args, **kwargs): @@ -99,7 +131,9 @@ class TicketSerializer(serializers.ModelSerializer): if data.get('merged_to'): data['merged_to'] = data['merged_to'].id - ticket_form = TicketForm(data=data, queue_choices=queue_choices) + files = {'attachment': data.pop('attachment', None)} + + ticket_form = TicketForm(data=data, files=files, queue_choices=queue_choices) if ticket_form.is_valid(): ticket = ticket_form.save(user=self.context['request'].user) ticket.set_custom_field_values() diff --git a/helpdesk/tests/test_api.py b/helpdesk/tests/test_api.py index 0a0dedfe..ab9a146c 100644 --- a/helpdesk/tests/test_api.py +++ b/helpdesk/tests/test_api.py @@ -1,8 +1,11 @@ import base64 +from collections import OrderedDict from datetime import datetime +from django.core.files.uploadedfile import SimpleUploadedFile +from freezegun import freeze_time + 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 @@ -72,6 +75,7 @@ class TicketTest(APITestCase): self.assertEqual(created_ticket.description, 'Test description\nMulti lines') self.assertEqual(created_ticket.submitter_email, 'test@mail.com') self.assertEqual(created_ticket.priority, 4) + self.assertEqual(created_ticket.followup_set.count(), 1) def test_create_api_ticket_with_basic_auth(self): username = 'admin' @@ -178,6 +182,7 @@ class TicketTest(APITestCase): self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) self.assertFalse(Ticket.objects.exists()) + @freeze_time('2022-06-30 23:09:44') def test_create_api_ticket_with_custom_fields(self): # Create custom fields for field_type, field_display in CustomField.DATA_TYPE_CHOICES: @@ -247,6 +252,19 @@ class TicketTest(APITestCase): 'priority': 4, 'due_date': None, 'merged_to': None, + 'followup_set': [OrderedDict([ + ('id', 1), + ('ticket', 1), + ('date', '2022-06-30T23:09:44'), + ('title', 'Ticket Opened'), + ('comment', 'Test description\nMulti lines'), + ('public', True), + ('user', 1), + ('new_status', None), + ('message_id', None), + ('time_spent', None), + ('followupattachment_set', []) + ])], 'custom_varchar': 'test', 'custom_text': 'multi\nline', 'custom_integer': 1, @@ -262,3 +280,54 @@ class TicketTest(APITestCase): 'custom_slug': 'test-slug' }) + def test_create_api_ticket_with_attachment(self): + staff_user = User.objects.create_user(username='test', is_staff=True) + self.client.force_authenticate(staff_user) + test_file = SimpleUploadedFile('file.jpg', b'file_content', content_type='image/jpg') + 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, + 'attachment': test_file + }) + 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) + self.assertEqual(created_ticket.followup_set.count(), 1) + self.assertEqual(created_ticket.followup_set.get().followupattachment_set.count(), 1) + attachment = created_ticket.followup_set.get().followupattachment_set.get() + self.assertEqual( + attachment.file.name, + f'helpdesk/attachments/test-queue-1-{created_ticket.secret_key}/1/file.jpg' + ) + + def test_create_follow_up_with_attachments(self): + staff_user = User.objects.create_user(username='test', is_staff=True) + self.client.force_authenticate(staff_user) + ticket = Ticket.objects.create(queue=self.queue, title='Test') + test_file_1 = SimpleUploadedFile('file.jpg', b'file_content', content_type='image/jpg') + test_file_2 = SimpleUploadedFile('doc.pdf', b'Doc content', content_type='application/pdf') + + response = self.client.post('/api/followups/', { + 'ticket': ticket.id, + 'title': 'Test', + 'comment': 'Test answer\nMulti lines', + 'attachments': [ + test_file_1, + test_file_2 + ] + }) + self.assertEqual(response.status_code, HTTP_201_CREATED) + created_followup = ticket.followup_set.last() + self.assertEqual(created_followup.title, 'Test') + self.assertEqual(created_followup.comment, 'Test answer\nMulti lines') + self.assertEqual(created_followup.followupattachment_set.count(), 2) + self.assertEqual(created_followup.followupattachment_set.first().filename, 'doc.pdf') + self.assertEqual(created_followup.followupattachment_set.first().mime_type, 'application/pdf') + self.assertEqual(created_followup.followupattachment_set.last().filename, 'file.jpg') + self.assertEqual(created_followup.followupattachment_set.last().mime_type, 'image/jpg') diff --git a/helpdesk/urls.py b/helpdesk/urls.py index 03924e98..b87a89a4 100644 --- a/helpdesk/urls.py +++ b/helpdesk/urls.py @@ -17,7 +17,7 @@ from rest_framework.routers import DefaultRouter from helpdesk.decorators import helpdesk_staff_member_required, protect_view from helpdesk.views import feeds, staff, public, login from helpdesk import settings as helpdesk_settings -from helpdesk.views.api import TicketViewSet, CreateUserView +from helpdesk.views.api import TicketViewSet, CreateUserView, FollowUpViewSet, FollowUpAttachmentViewSet if helpdesk_settings.HELPDESK_KB_ENABLED: from helpdesk.views import kb @@ -176,6 +176,8 @@ urlpatterns += [ if helpdesk_settings.HELPDESK_ACTIVATE_API_ENDPOINT: router = DefaultRouter() router.register(r"tickets", TicketViewSet, basename="ticket") + router.register(r"followups", FollowUpViewSet, basename="followups") + router.register(r"followups-attachments", FollowUpAttachmentViewSet, basename="followupattachments") router.register(r"users", CreateUserView, basename="user") urlpatterns += [re_path(r"^api/", include(router.urls))] diff --git a/helpdesk/views/api.py b/helpdesk/views/api.py index 266f821f..d217a1a4 100644 --- a/helpdesk/views/api.py +++ b/helpdesk/views/api.py @@ -4,8 +4,8 @@ from rest_framework.viewsets import GenericViewSet from rest_framework.mixins import CreateModelMixin from django.contrib.auth import get_user_model -from helpdesk.models import Ticket -from helpdesk.serializers import TicketSerializer, UserSerializer +from helpdesk.models import Ticket, FollowUp, FollowUpAttachment +from helpdesk.serializers import TicketSerializer, UserSerializer, FollowUpSerializer, FollowUpAttachmentSerializer class TicketViewSet(viewsets.ModelViewSet): @@ -28,6 +28,18 @@ class TicketViewSet(viewsets.ModelViewSet): return ticket +class FollowUpViewSet(viewsets.ModelViewSet): + queryset = FollowUp.objects.all() + serializer_class = FollowUpSerializer + permission_classes = [IsAdminUser] + + +class FollowUpAttachmentViewSet(viewsets.ModelViewSet): + queryset = FollowUpAttachment.objects.all() + serializer_class = FollowUpAttachmentSerializer + permission_classes = [IsAdminUser] + + class CreateUserView(CreateModelMixin, GenericViewSet): queryset = get_user_model().objects.all() serializer_class = UserSerializer diff --git a/requirements-testing.txt b/requirements-testing.txt index db93a92f..c07a8ced 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -5,3 +5,4 @@ coverage argparse pbr mock +freezegun