Monday, November 27, 2023

GET and PATCH requests on the Course model

The next two endpoints related to the Course model are the retrieve and update. In terms of retrieve, there will be two types of GET requests - fetch all courses and fetch a particular course. The results should also change according to who is making the requests. An admin user should be able to see all courses, including those in draft mode or those that have been archived. A normal user should only see those courses that have been published and are active.

I use the ListModelMixin and the RetrieveModelMixin  from Django Rest Framework to reduce the amount of code. In terms of fetching a particular course, this has to be done using the course slug rather than the ID as the slug is more human readable. All that needs to be done is to use the list() and retrieve() methods in the get() method:

def get(self, request, *args, **kwargs):
    '''Fetch all courses or specific course by slug'''
    slug = self.kwargs.get('slug', None)
    self.authenticate(request)
    if slug:
        return self.retrieve(request, *args, *kwargs)
    else:
        return self.list(request, *args, **kwargs)

These two methods under the hood call the get_queryset() method which is provided by GenericAPIView. The authenticate method is being called here to ensure that any user credentials in the header are extracted. The get_queryset method is:

def get_queryset(self, *args, **kwargs):
    '''Return courses not in draft mode and not archived'''
    if self.request.user is not None and self.request.user.is_staff:
        return Course.objects.all()
    return Course.objects.fetch_courses()

Here, is the logged in user is an admin, all courses will be returned or else a subset will be returned. This subset is determined by the method fetch_courses() which has been defined as a query in a model manager.

class CourseManager(models.Manager):
    '''Manager for Course model'''

    def fetch_courses(self, is_draft=False, is_archived=False, *args, **kwargs):
        return self.get_queryset().filter(
            is_draft=is_draft,
            is_archived=is_archived
        )

Here fetch_courses() has default arguments that courses in draft mode or those that have been archived should be excluded. The default get_queryset() is merely the all() filter. Therefore, fetch_courses() without any arguments will return those courses that have been published and have not been archived.

Coming to the last endpoint - the update course API call. For this the UpdateModelMixin is used with Django Rest Framework. The UpdateModelMixin provides an update() and a partial_update() method. The partial_update() method is suitable for PATCH requests with fields being optional. The patch() method is very similar to the create() method for POST requests:

def patch(self, request, *args, **kwargs):
    '''Update a course by an authenticated instructor'''
    try:
        self.authenticate(request)
        return self.partial_update(request, *args, **kwargs)
    except CourseGenericError as e:
        return Response(
            data=str(e),
            status=status.HTTP_400_BAD_REQUEST
        )
    except CourseForbiddenError as e:
        logger.critical('Course update by non admin attempted')
        return Response(
            data=str(e),
            status=status.HTTP_403_FORBIDDEN
        )
    except ValidationError as e:
        logger.error('Error updating course - {}'.format(str(e)))
        return rest_framework_validation_error(e, 'Course could not be updated')
    except InvalidToken as e:
        logger.critical('Course update by non admin attempted')
        return Response(
            data='Must be logged in as administrator to update a course',
            status=status.HTTP_403_FORBIDDEN
        )
    except Exception as e:
        logger.critical('Error updating course - {}'.format(str(e)))
        return Response(
            data=DEFAULT_ERROR_RESPONSE,
            status=status.HTTP_400_BAD_REQUEST
        )

This patch() method calls the partial_update() method similar to how the create() method call the perform_create() method.

def perform_update(self, serializer):
    '''Update a course by an authenticated instructor'''
    course = serializer.save(user=self.request.user)

By calling the save() method on the serializer, the update() method of the serializer will be called instead of the create() method:

def update(self, instance, validated_data):
    user = validated_data.get('user', None)
    if user is not None and instance.check_user_is_instructor(user):
        instance.title = validated_data.get('title', instance.title)
        instance.subtitle = validated_data.get(
            'subtitle', instance.subtitle)
        instance.description = validated_data.get(
            'description', instance.description)
        instance.price = validated_data.get('price', instance.price)
        instance.is_free = validated_data.get('is_free', instance.is_free)
        instance.is_published = validated_data.get(
            'is_published', instance.is_published)
        instance.is_draft = validated_data.get(
            'is_draft', instance.is_draft)
        instance.is_archived = validated_data.get(
            'is_archived', instance.is_archived)
        instance.save()
        return instance
    else:
        raise CourseForbiddenError(
            'Only an instructor of a course can update a course'
        )

Here, there is a model method check_user_is_instructor():

def check_user_is_instructor(self, user):
    '''Check if a user is an instructor of the course'''
    instuctor_emails = [x.username for x in self.instructors.all()]
    if user and user.username in instuctor_emails:
        return True
    return False

This is to ensure that only a user who is one of the instructors can update the course.

With this the CRU endpoints of CRUD for courses is done. The D (delete) endpoint will be deferred until I figure out the best kind of multi-factor authentication should be used when deleting data through API calls.

Detailed tests have been written for these three endpoints. The tests will be skipped in the blog posts as it is merely to verify the functionality.

No comments:

Post a Comment