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.

No comments:

Post a Comment