Thursday, January 18, 2024

Refactoring code

After having a few endpoints, before continuing, it might be best to stop and look at ways in which the code can be a bit more efficient with less repetition. For now, I refactored only the user_auth and courses endpoints, as these two had tests and the tests can be used to verify the code refactor.

To begin with, the view functions were handling too many exceptions. While it is necessary to produce explicit error messages along with appropriate status codes so that the frontend does not have to do much processing, I found several except blocks producing the same error. For example, in the user register view POST method:

def post(self, *args, **kwargs):
    '''Create new user from API request'''
    try:
        user = RegisterUserSerializer(data=self.request.data)
    except Exception as e:
        logger.critical(
            'Error in registering new user - {error}'.format(error=str(e))
        )
        return Response(
            data=DEFAULT_ERROR_RESPONSE,
            status=status.HTTP_400_BAD_REQUEST
        )
    new_user = None
    if user.is_valid():
        try:
            new_user = user.save()
            send_verification_link_email(new_user)
            logger.info('New user {} created'.format(new_user.username))
            return Response(user.data, status=status.HTTP_201_CREATED)
        except Http400Error as e:
            logger.error(
                'Error in registering new user {username} - {error}'.format(
                    username=user.data['username'],
                    error=str(e)
                )
            )
            return Response(
                data=str(e),
                status=status.HTTP_400_BAD_REQUEST
            )
        except Exception as e:
            logger.critical(
                'Error in registering new user - {error}'.format(
                    error=str(e)
                )
            )
            return Response(
                data=DEFAULT_ERROR_RESPONSE,
                status=status.HTTP_400_BAD_REQUEST
            )
    else:
        return serializer_error_response(user, 'Could not register user')

I am returning a 400 error response in four different places - the first in case the RegisterUserSerializer fails maybe due to corrupt request object, the second when there is a problem with the user model instance creation, the third is if there is an unexpected error and the last is if there is a serializer error such as missing username or password etc. All I need is two errors - the first is if there is a missing field or wrong API request and the second is if there is an unexpected error. 

So, a better way to write this would be:

def post(self, *args, **kwargs):
    '''Create new user from API request'''
    try:
        user = RegisterUserSerializer(data=self.request.data)
        new_user = user.save()
        send_verification_link_email(new_user)
        logger.info('New user {} created'.format(new_user.username))
        return Response(user.data, status=status.HTTP_201_CREATED)
    except Http400Error as e:
        logger.error(
            'Error in registering new user {}'.format(str(e))
        )
        return Response(
            data=str(e),
            status=status.HTTP_400_BAD_REQUEST
        )
    except Exception as e:
        logger.critical(
            'Error in registering new user - {}'.format(str(e))
        )
        return Response(
            data=DEFAULT_ERROR_RESPONSE,
            status=status.HTTP_400_BAD_REQUEST
        )

This would mean that some of the exception handling would move into the serializer as the .save() method which was returning a serializer error will now have to return a custom Http400Error. For this reason, the .save() method will need to be overloaded:

def save(self):
    if self.is_valid():
        return super().save()
    else:
        raise Http400Error(
            extract_serializer_error(self.errors)
        )

The is_valid() which was before in the view method is now in the serializer method which seems a better place for this to occur. A new method extract_serializer_error has to be defined:

def extract_serializer_error(error, default_msg=DEFAULT_ERROR_RESPONSE):
    '''Return error string as generic error'''
    try:
        err_message = [error[e][0]
                       for e in error][0]
    except:
        err_message = default_msg
    return err_message

It merely extracts the error string from the serializer Validation error.

In refactoring the Course view class, there was a greater deal of complication. In the create() method which I was using since I was inheriting the CreateAPIView:

def create(self, request, *args, **kwargs):
    '''Create a new course - POST request'''
    try:
        self.authenticate(request)
        return super().create(request, *args, **kwargs)
    except Http400Error as e:
        logger.error('Error creating course - {}'.format(str(e)))
        return Response(
            data=str(e),
            status=status.HTTP_400_BAD_REQUEST
        )
    except Http403Error as e:
        logger.critical('Course creation by non admin attempted')
        return Response(
            data=str(e),
            status=status.HTTP_403_FORBIDDEN
        )
    except ValidationError as e:
        logger.error('Error creating course - {}'.format(str(e)))
        return rest_framework_validation_error(e, 'Course could not be created')
    except Exception as e:
        logger.critical('Error creating course - {}'.format(str(e)))
        return Response(
            data=DEFAULT_ERROR_RESPONSE,
            status=status.HTTP_400_BAD_REQUEST
        )

Here, there is an unnecessary except block checking for ValidationError and which also returns a 400 error response. But, moving the validation into the overloaded save() method of the course serializer does not solve the problem:

def save(self, *args, **kwargs):
    if self.is_valid():
        return super().save(*args, **kwargs)
    else:
        raise Http400Error(extract_serializer_error(self.errors))

The reason this will not work is because of the way the create() method provided by the CreateAPIView works. Looking into the source code in the Django Rest Framework Github repository, I found that the create method is as:

def create(self, request, *args, **kwargs):
    serializer = self.get_serializer(data=request.data)
    serializer.is_valid(raise_exception=True)
    self.perform_create(serializer)
    headers = self.get_success_headers(serializer.data)
    return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)

The create() method runs is_valid() under the hood and therefore will not wait for the overloaded save() method of the serializer to be called. The result is that exceptions will be of type serializers.ValidationError. The only way forward is to repeat some of the code:

def post(self, request, *args, **kwargs):
    '''Create a new course - POST request'''
    try:
        self.authenticate(request)
        serializer = self.get_serializer(data=request.data)
        self.perform_create(serializer)
        return Response(
            serializer.data,
            status=status.HTTP_201_CREATED
        )
    except Http400Error as e:
        logger.error('Error creating course - {}'.format(str(e)))
        return Response(
            data=str(e),
            status=status.HTTP_400_BAD_REQUEST
        )
    except Http403Error as e:
        logger.critical('Course creation by non admin attempted')
        return Response(
            data=str(e),
            status=status.HTTP_403_FORBIDDEN
        )
    except Exception as e:
        logger.critical('Error creating course - {}'.format(str(e)))
        return Response(
            data=DEFAULT_ERROR_RESPONSE,
            status=status.HTTP_400_BAD_REQUEST
        )

I explicitly create the serializer from the request and then call perform_create() which has not changed. Only, I do not call is_valid() and I let the perform_create() method call the save() method:

def perform_create(self, serializer):
    '''Create a new course with authenticated user as instructor'''
    course = serializer.save(user=self.request.user)
    logger.info(
        'Creating course {course} by user {user}'.format(
            course=course.title,
            user=self.request.user.username
        )
    )

With this refactor, the code repetitions have decreased. The tests are still passing which implies on the whole the code should still work.

Monday, January 15, 2024

Course registration for students and instructors

To complete the student registration endpoint, the add_students method was removed from the Course model as now an instance of the CourseStudentRegistration model needs to be created. To create this, I created a ModelManager for the CourseStudentRegistration model:

class CourseStudentRegistrationManager(models.Manager):
    '''Student registration manager'''

    def register_student(self, user, course):
        '''Register student for course'''

        register_obj = None
        try:
            register_obj = self.get(user=user, course=course)
        except:
            pass
        if register_obj is not None:
            raise CourseGenericError('User is already registered')

        return self.create(user=user, course=course)

Might seem like a roundabout way to do this, but I figure it is a direct query on the database. The get will throw an error if no object is returned and this error needs to be suppressed as that means the student has not yet registered for the course.

Besides this change, I changed the student registration into a POST request as it is not a modification of the Course model instance anymore. Moreover from a conceptual point of view, when a student registers for a course, it is a new create request and so POST seems best.

Following this, an endpoint is needed to add new instructors. The request is again a POST request and can be made only by someone who is already an instructor for the course.

class CourseInstructorAddView(GenericAPIView, UserAuthentication):
    '''Add an instructor to a course'''

    serializer_class = CourseSerializer
    lookup_field = 'slug'
    user_model = User

    def get_queryset(self, *args, **kwargs):
        '''Return published courses'''
        if self.request.user is not None and self.request.user.is_staff:
            return Course.objects.all()
        else:
            raise CourseForbiddenError('Must be logged in as an instructor')

    def post(self, request, *args, **kwargs):
        try:
            user = self.authenticate(request)
            course_obj = self.get_object()
            if user is not None and course_obj.check_user_is_instructor(user):
                new_user = User.objects.get_user_by_email(
                    request.data.get('email'))
                course_obj.add_instructor(new_user)
                return Response()
            else:
                return Response(
                    data='Must be logged in as an instructor',
                    status=status.HTTP_403_FORBIDDEN
                )
        except InvalidToken as e:
            return Response(
                data='Must be logged in as instructor',
                status=status.HTTP_403_FORBIDDEN
            )
        except CourseForbiddenError as e:
            return Response(
                data=str(e),
                status=status.HTTP_403_FORBIDDEN
            )
        except CourseGenericError as e:
            return Response(
                data=str(e),
                status=status.HTTP_400_BAD_REQUEST
            )
        except Exception as e:
            return Response(
                data=DEFAULT_ERROR_RESPONSE,
                status=status.HTTP_400_BAD_REQUEST
            )

The get_queryset() method will return courses only if the request comes from an admin. At this stage, no need to check for whether the user is an instructor as this is merely returning the superset against which the get_object filters with the slug field.

The add_instructor method in the Course model has to be modified:

def add_instructor(self, user):
    '''Add instructors to the course'''
    if self.check_user_is_instructor(user):
        raise CourseGenericError('Already an instructor')
    if user.is_staff:
        self.instructors.add(user)
    else:
        raise CourseForbiddenError('Instructors have to be administrators')

This is to ensure that the user has not already been added as instructor. At this point, I will keep the instructors field on the Course model as a simple ManyToManyField without a through table, as a course will not have a large number of instructors, and it does not seem very critical to keep track of when each instructor was added.

With this it seems like the basic User and Course model are now in working state. Before continuing, I need to address the repetition of code, especially in the view classes. On one hand, it is important to provide clear error messages and error status codes. However, instead of having GenericError and ForbiddenError exceptions for each app, it might be better to create one class of error definitions and use them all over the app. The idea is merely to provide a placeholder to determine the status code for a particular type of error and pass on the error message as it is. There will still be the last uncaught exception that returns the default error message of "Something unexpected happened. Please try again later."

Saturday, January 13, 2024

Registration app and model

After creating a basic student registration end-point, now expanding on this registration feature as registration will not just be the user but also time of registration, price paid etc. For now, let us just have the time of registration and handle the discount options later.

Since the registration may be a fairly complex logic at a later stage, I created another app called registration. Within this app, I created a model CourseStudentRegistration:

class CourseStudentRegistration(models.Model):
    '''Registration of a student in a course'''
    user = models.ForeignKey(
        'user_auth.User',
        null=True,
        on_delete=models.SET_NULL
    )
    course = models.ForeignKey(
        'courses.Course',
        null=True,
        on_delete=models.SET_NULL
    )
    registered_at = models.DateTimeField(auto_now_add=True)

Back to the Course model, the students field is changed to point to this model as a through table, but for now the instructors field is kept the same:

instructors = models.ManyToManyField(
    'user_auth.User', related_name='courses_taught')
students = models.ManyToManyField(
    'user_auth.User',
    blank=True,
    through='registration.CourseStudentRegistration'
)

At this point, there was a quite a lot of confusion. Before, all the models in the ForeignKey and ManyToMany fields were specified as Python objects rather than strings with the format '<app_label>.<model_name>'. So before, there was just User instead of 'user_auth.User' and CourseStudentRegistration rather than 'registration.CourseStudentRegistration'. But specifying Python objects results in circular dependency errors as we have a model that references another model which in turn references the original model. So the conflict is understandable.

But, quite strangely, getting the migrations right even with the above relative strings was a bit tricky and not much in the documentation to help. In the documentation, all models are in a single file and so running migrations works. For that matter, if I put the CourseStudentRegistration in the same file as Course, migrations worked. The problem comes with having them in different apps and files.

When there are multiple apps, one would think that migrations have to be run successively:

python manage.py makemigrations app1
python manage.py makemigrations app2
...

If I ran makemigrations on the three apps - user_auth, courses and registration - one after the other, I was getting circular dependency errors. Turns out when there are such interconnections between models, one should run all the migrations in a single command and let Django figure out how they are connected:

python manage.py makemigrations app1 app2 ...

After this, the circular dependencies were resolved and the server can be launched. The tests related to course registration failed because I had commented out the part related to adding students:

def add_students(self, user):
    '''Add students to the course'''
    # TODO - registering students logic will 
    # need to be separated from Course model
    # CourseStudentRegistration.objects.create(
    #     course=self,
    #     user=user
    # )
    pass
    # if user not in self.students.all():
    #     self.students.add(user)
    # else:
    #     raise CourseGenericError('User is already registered')

With a through table now being defined in the ManyToMany field, one cannot simply add the user, but rather a new CourseStudentRegistration model instance has to be created pointing to the Course and the User. This now implies that this method will go, as it makes little sense accessing another model manager method - CourseStudentRegistration.objects.create - from another model method. The sensible thing to do is to create a model Manager for the CourseStudentRegistration that can be called directly from the view class.

Thursday, January 11, 2024

Changes to student registration and database change

Testing the register-student API endpoint brought out a few changes and also made me think about how this registration needs to be handled to include a few more details.

To begin with, only a student logged in with an active account should be able to register for courses. So this means any API requests without credentials or with credentials of an inactive user should not be allowed. 

user = self.authenticate(request, check_admin=False)
if user is not None:
    course_obj = self.get_object()
    course_obj.add_students(user)
    return Response(
        data=CourseSerializer(
            user.course_set.all(),
            many=True
        ).data
    )
else:
    return Response(
        data='Must be logged in to register for course',
        status=status.HTTP_403_FORBIDDEN
    )

Before, the response was outside the if check whether user is not None. This would throw a 400 error as then I was accessing course_set of user which is None. The else condition handles this case specifically.

In the case of an inactive account, it is handled by the authenticate function which is an extension of the authenticate method provided by JWTAuthentication. If the user is inactive, it raises an exception. This exception seems generic and is difficult to catch in a specific except block. Rather than let the authenticate method raise an exception, for now, it seems better to let it return None if a valid user is not found.

def authenticate(self, request,
                check_admin=True,
                *args, **kwargs
    ):
    try:
        user = super().authenticate(
            request,
            *args,
            **kwargs
        )
        if user is not None:
            if check_admin and not user[0].is_staff:
                return None
            request.user = user[0]
            return user[0]
    except Exception as e:
        pass
    return None

For now, an exception is not thrown, but I could later throw a UserForbiddenException if necessary - something that can be explicitly caught in the view method.

Coming to the next topic - how should additional details be handled in student registration? These details include the time and date of registration, the price paid, the actual price (in case of discount applied) and also any association (such as a group in the case of group study). Django handles ManyToMany fields using a through table. Found a nice blog post on this:

https://www.sankalpjonna.com/learn-django/the-right-way-to-use-a-manytomanyfield-in-django 

All that needs to be done is to specify this through table explicitly with the additional fields. For now it will only be the time of registration and the price.

Tuesday, January 9, 2024

Endpoint for registering users for courses

Coming back to this project after a month as I get back from the holiday season. I left off where I had written the endpoint and the tests for publishing and unpublishing a course. The next endpoint will be to register a logged in student for the course.

I created a new class for this as for now I can't see how this endpoint can be multiplexed with another one in the manner in which CRU for the basic course creation was done. This endpoint will add the logged in user to the list of students in the course and then return all the courses which the student has registered so far. Conceptually, a logged in user will visit a course page, go through the overview and maybe watch a few videos, and if interesting will click on the register button. If the registration is successful, the user will get back a list of all the courses he has registered for maybe with this latest course first on the list.

The class is:

class CourseRegisterView(UpdateAPIView, UserAuthentication):
    '''
    Register a student for a course and
    return list of courses for the student.
    '''

    serializer_class = CourseSerializer
    lookup_field = 'slug'
    user_model = User

    def get_queryset(self, *args, **kwargs):
        '''Return published courses'''
        return Course.objects.fetch_courses()

    def partial_update(self, request, *args, **kwargs):
        try:
            user = self.authenticate(
            		request,
                        check_admin=False
                   )
            if user is not None:
                course_obj = self.get_object()
                course_obj.add_students(user)
            return Response(
                data=CourseSerializer(
                	user.course_set.all(),
                    	many=True
                     ).data
            )
        except CourseGenericError as e:
            return Response(
                data=str(e),
                status=status.HTTP_400_BAD_REQUEST
            )
        except InvalidToken as e:
            return Response(
                data='Must be logged in to register for course',
                status=status.HTTP_403_FORBIDDEN
            )
        except Exception as e:
            return Response(
                data=str(e),
                status=status.HTTP_400_BAD_REQUEST
            )

Some of it is a duplicate of the previous CourseView class. The first few properties of the class like serializer_class, lookup_field and user_model seem basic and so not sure if a base class can be created to not repeat these lines.

The get_queryset() method has been simplified to return only published courses as no one (admin or normal user) should be able to register for an unpublished course. The class uses the UpdateAPIView which is apt for editing a detail and provides a PUT and PATCH request. Theoretically, a register should be a POST, but then this would mean the extraction of the course object would need to be done manually. Since a user is being added to the "students" ManyToManyField, this can be interpreted as a PATCH request.

The UpdateAPIView handles a put or a patch request and provides an update() or partial_update method(). The UpdateModelMixin was used for the CourseView class as well. The logged in user is the user to be added to the students list. And so the view class inherits the UserAuthentication class which overloads the authenticate() method provided by JWTAuthentication.

To extract the course, I had initially overloaded the get_object() method. This get_object method is provided by GenericAPIView from which the UpdateAPIView inherits. This was my original overloaded method:

def get_object(self):
    try:
        return get_object_or_404(
            self.get_queryset(),
            slug=self.kwargs['slug']
        )
    except:
        raise Exception('Course not found')

Then I thought - what for? If the lookup_field has been specified as 'slug' and the get_queryset() method has been defined as all the published courses, the default get_object which filters the queryset with the value of the slug should be good enough. And so getting rid of this get_object() method makes no difference to the working of the code.

Trying to register a user for a course if the user has already been registered will through a 400 error. This can be displayed as a modal in the frontend. Will write tests for the this endpoint and check if any changes need to be made.

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.

Friday, November 24, 2023

Getting started with the Course model

Spent a couple of weeks coding the basics of a course after the basic user authentication was done. The plan is to have the outermost model to be that of a course which in turn contains lectures and the lectures will then have content which could be videos, documents or just text.

So far the idea is that a course can be created only by an admin user. This deviates from the normal online model where any instructor can create a course, as in the case of this web app, the idea is for the admin to be the creators and managers of courses. So a course will have the usual fields like title, subtitle, description and price. It will also have a few flags to indicate whether the course is free, in draft mode, is archived. The course model will link to the user model for the list of instructors and students.

class Course(models.Model):
    '''Course model'''
    title = models.CharField(max_length=300, unique=True)
    subtitle = models.CharField(max_length=300, null=True, blank=True)
    slug = models.SlugField(max_length=200)
    description = models.TextField()
    instructors = models.ManyToManyField(User, related_name='courses_taught')
    students = models.ManyToManyField(User, blank=True)
    price = models.DecimalField(default=10.99, max_digits=4, decimal_places=2)
    is_free = models.BooleanField(default=False)
    is_published = models.BooleanField(default=False)
    is_draft = models.BooleanField(default=True)
    is_archived = models.BooleanField(default=False)


def generate_course_slug(sender, instance, *args, **kwargs):
    '''Generate slug for course'''
    if not instance.slug:
        instance.slug = slugify(instance.title)


pre_save.connect(generate_course_slug, sender=Course)

The computed field is the slug for the Course which is merely to be able to access the course as a web link without using the course ID but rather a human readable string. Since the course title is unique, the slugify method available with Django does the job.

With the Course model defined, the next step is to define the CourseSerializer:

class CourseSerializer(serializers.ModelSerializer):
    '''Serializer for course'''

    title = serializers.CharField(
        error_messages={
            'blank': 'Course title is required',
            'required': 'Course title is required'
        },
        validators=[
            UniqueValidator(
                queryset=Course.objects.all(),
                message='A course with this title already exists'
            )
        ]
    )

    class Meta:
        model = Course
        fields = ['title', 'subtitle', 'description', 'price', 'is_free',]
        extra_kwargs = {
            'description': {
                'error_messages': {
                    'blank': 'Course description is required',
                    'required': 'Course description is required'
                }
            },
        }

In the most basic form, the serializer accepts the fields related to title, subtitle, description, price and whether the course is free. For the title, besides the required error, an additional validator needs to be included for checking whether the title is unique. This UniqueValidator needs a queryset against which it will check for uniqueness (which is all courses).

Added to these classes, a few more methods needed to be added. First, methods are needed for adding instructors and students to a course. These can be model methods. In the case of instructors, it is necessary to check if the users are admin.

def add_instructor(self, user):
    '''Add instructors to the course'''
    if user.is_staff:
        self.instructors.add(user)
    else:
        raise CourseForbiddenError('Instructors have to be administrators')

def add_students(self, user):
    '''Add students to the course'''
    self.students.add(user)

At the model level, it is necessary to add a check before the model is saved - if a course is free, the price should be 0, while if a course is not specified as free, a price should be specified. Model level validation can be done with the clean_fields() method, but this clean() method will be called when a ModelForm is saved and not when the model is created or saved elsewhere. Therefore, I overloaded the save() method in addition to creating a clean_fields() method which works with the admin dashboard:

def save(self, *args, **kwargs):
    if not self.is_free and self.price <= 0:
        raise CourseGenericError('Price of a non-free course is required.')
    if self.is_free:
        self.price = 0.00
    super().save(*args, **kwargs)

def clean_fields(self, exclude=None):
    '''Validation in admin dashboard'''
    if not self.is_free and self.price <= 0:
        raise ValidationError('Price of a non-free course is required.')

With this update to the Course model, the CourseSerializer can also be updated to introduce some validation and also facilitate the addition of instructors at the time of creating a course.

def validate(self, data):
    course_is_free = data.get('is_free', False)
    course_price = data.get('price', None)
    if course_price is not None and course_price > 0:
        data['is_free'] = False
    elif course_is_free:
        data['price'] = 0.00
    elif course_price is None or course_price <= 0:
        raise CourseGenericError('Course price is required')
    return data

def create(self, validated_data):
    user = validated_data.get('user', None)
    if user is not None and user.is_staff:
        del validated_data['user']
        course = Course.objects.create(**validated_data)
        course.add_instructor(user)
        return course
    else:
        raise CourseForbiddenError(
            'Must be logged in as administrator to create a course'
        )

The validation ensures that if the price is provided in the body of an API call, the is_free flag is automatically turned OFF. Alternatively, the API call can only mention that the course is free in which case the price needs to be set to zero. If nothing is mentioned about the course being free and the price is either skipped or is 0 or a negative number, an error should be thrown.

While creating a course, the user is extracted from the validated data and check is made whether this user exists and is admin. This is to ensure that an anonymous user or a normal non-admin user cannot create a course. The admin user is automatically added to the instructor list. This user needs to be passed into the save() method of the serializer, as any additional fields passed to the save method will show up in the validated_data dictionary.

With this, a basic course creation endpoint can be created and the following view class written that inherits the CreateAPIView from the generic view classes in Django Rest Framework:

class CourseView(
    CreateAPIView,
    UserAuthentication
):
    '''Create a course with logged-in user as instructor'''

    serializer_class = CourseSerializer
    user_model = User
    lookup_field = 'slug'
    queryset = Course.objects.all()

    def perform_create(self, serializer):
        '''Create a new course with authenticated user as instructor'''
        course = serializer.save(user=self.request.user)
        logger.info(
            'Creating course {course} by user {user}'.format(
                course=course.title,
                user=self.request.user.username
            )
        )

    def create(self, request, *args, **kwargs):
        '''Create a new course - POST request'''
        try:
            self.authenticate(request)
            return super().create(request, *args, **kwargs)
        except CourseGenericError as e:
            logger.error('Error creating course - {}'.format(str(e)))
            return Response(
                data=str(e),
                status=status.HTTP_400_BAD_REQUEST
            )
        except CourseForbiddenError as e:
            logger.critical('Course creation by non admin attempted')
            return Response(
                data=str(e),
                status=status.HTTP_403_FORBIDDEN
            )
        except ValidationError as e:
            logger.error('Error creating course - {}'.format(str(e)))
            return rest_framework_validation_error(e, 'Course could not be created')
        except InvalidToken as e:
            logger.critical('Course creation by non admin attempted')
            return Response(
                data='Must be logged in as administrator to create a course',
                status=status.HTTP_403_FORBIDDEN
            )
        except Exception as e:
            logger.critical('Error creating course - {}'.format(str(e)))
            return Response(
                data=DEFAULT_ERROR_RESPONSE,
                status=status.HTTP_400_BAD_REQUEST
            )

The CreateAPIView provides the create() method which essentially handles the POST request. The CreateAPIView inherits from the GenericAPIView which needs a serializer_class and queryset to be specified.

In order to handle authentication, I created a custom class UserAuthentication which inherits JWTAuthentication.

class UserAuthentication(JWTAuthentication):
    '''Returns user from token in header'''

    def authenticate(self, request, check_admin=True, *args, **kwargs):
        user = super().authenticate(request, *args, **kwargs)
        if user is not None:
            if check_admin and not user[0].is_staff:
                return None
            request.user = user[0]
            return user[0]

JWTAuthentication contains an authenticate method which extracts from the request a header with the name Authorization which is a string in the format "Bearer <jwt-access-token>". The good part is that the authenticate() method that ships with JWTAuthentication does this and returns a user object. The problem is that the object is not just the user object but also details about the token - when it was created and when it will expire. To further generalize this method, an optional argument check_admin is passed which is by default True - therefore this method will by default return a user object if the user is admin. Moreover, the user object is attached to the request object.

By inheriting this custom UserAuthentication class in the CourseView, an authenticate() method is available which will provide a user object if an admin user access token is provided in the header of the POST request.

To be able to use this user object while creating the course, it is necessary to overload the perform_create() method which is used by the create() method of CreateAPIView. By passing the user into the save() method, the user can be extracted from the validated data while saving the serializer.

With this POST requests to create new courses are in place and the C in CRUD is done. Remaining are RUD - Retrieve, Update and Delete. Out of these, Delete will be deferred until later, as this might need two-factor authentication.