Monday, June 17, 2024

Setting up multi-languages in Django

Following the last post where I had briefly covered the idea behind multiple languages through Django, I finally setup the app to support for now just two languages - the first being English and the second German as that is the only foreign language I speak to some extent. Other than default settings with Django, it also needs django-modeltranslation which can be found from:

 https://github.com/deschler/django-modeltranslation

In the settings.py file, the model translation app will appear before all other apps, as most other apps will be modified because of multi language support.

INSTALLED_APPS = [
    'modeltranslation',
    #
]

It needs the LocaleMiddleware to be able to handle multi language content. This needs to come after SessionMiddleware as sessions will store data and this will need to be translated as well, but should come before CommonMiddleware. So far I am not using CacheMiddleware, but I will need to enable cache later. In case CacheMiddleware is being used, LocaleMiddleware must come after it.

Finally, comes the language settings. Creating a Django project usually produces LANGUAGE_CODE, USE_I18N and USE_TZ variables. The LANGUAGE_CODE is usually en_US, but I changed it to en to keep things simple.

LANGUAGE_CODE = 'en'

LANGUAGES = [
    ('en', _('English')),
    ('de', _('German')),
]

TIME_ZONE = 'UTC'

USE_I18N = True

USE_TZ = True

# Where translations will be stored
LOCALE_PATHS = [
    BASE_DIR / 'locale',
]

After creating the locale directory in the root of the project, we can now start generating translation files. The very first time, it is necessary to specify the exact locales that you want to create translations for:

django-admin makemessages --locale=de

This will create the .po files where translations need to be inserted. To start inserting translations, all the text that needs to be translated needs to wrapped with:

from django.utils.translation import gettext_lazy as _
#
_('Course not found from URL')

The normal text 'Course not found from URL' will be wrapped with gettext_lazy from the translation module.

The makemessages command will create a folder for each language in the locale directory and create a django.po file. All that is need to be done is go through the file and insert translations:

msgid "Course not found from URL"
msgstr "Translation of Course not found from URL"

To get translations enabled at the database level, a new file translation.py file needs to be created in each app that contains a models.py file. As an example, for the courses app:

from modeltranslation.translator import translator, TranslationOptions

from .models import Course


class CourseTranslation(TranslationOptions):
    '''
    Translation of Course model
    '''
    fields = ('title', 'subtitle', 'description')


translator.register(Course, CourseTranslation)

We need to specify the fields for which translation needs to be enabled, and register these fields along with the model. This will now create additional columns for those fields for each language that is supported  - so title becomes title, title_en and title_de. The default title will be the same as the default language title_en.

To ensure that multiple languages can also be edited in Django admin, the admin.py file also needs modification:

from django.contrib import admin
from modeltranslation.admin import TranslationAdmin

from .models import Course


class CourseAdmin(TranslationAdmin):
    '''
    Translated model of Course model for admin
    '''
    pass


admin.site.register(Course, CourseAdmin)

I had to delete the database and run the migrations again to ensure that there were no violations. But once that was done, the rest was pretty smooth - the admin panel showed multiple language fields.

What I was surprised about was that not much change needed to made for the API and the hook between the API and the database. To enable multi-language support in the API, the API calls will need to have an Accept-Language header. If this header is absent, the default English is used. To now hook this up with the rest, I needed to create a base view which then is inherited by all other views:

from django.utils import translation
from rest_framework.generics import GenericAPIView
class BaseAPIView(GenericAPIView):
    '''
    Base API view for handling language content
    '''

    def initial(self, request, *args, **kwargs):
        super().initial(request, *args, **kwargs)
        lang = request.META.get('HTTP_ACCEPT_LANGUAGE')
        if lang is not None:
            translation.activate(lang)

The initial method is defined in the APIView of Django Rest Framework and

"Performs any actions that need to occur before the handler method gets called. This method is used to enforce permissions and throttling, and perform content negotiation."

The documentation also adds that one would typically not need to override this method. However, enabling language support does seem like a legitimate override of this method. Once the language is enabled with translation.activate(), the bridge is complete. Not only is it possible to retrieve data in different languages, but also to create and update data in different languages using POST and PUT methods.

So far this seems like a very convenient way to enable multi-language support in the app. It would be interesting to see if any issues crop up when many languages are enabled as then the columns of the tables will increase in number.

Thursday, May 30, 2024

Multi language support for the app

I took a break from back-end dev to do some front-end dev using React. One part of this app will be interactive videos, and for that purpose, I was planning to build an interactive video player. This would be a custom video player that can play multiple videos according to a configuration file, and can accept feedback from the user/student.

This app has now reached a decent level of maturity though it needs more work:

https://github.com/shivkiyer/accordion-player

Now that the interactive video player can be augmented at a gradual pace, it is now time to return to the Django app. So far, the only language supported has been English. For an app to be practically useful, it must support multiple languages, and this would be even more so in the case of an educational app. As an example, most of my students of electrical engineering would have a language other than English as their first language. From the demographics, the biggest language groups would be Spanish, Arabic, Portugese and maybe French and Russian to some extent. This makes the app design interesting for several reasons.

The first is that the webpage has to be in other languages. Though one could argue that most modern browsers come with extensions that perform automatic translation, these extensions do have some limitations. For example, dynamic content that appears in drop down boxes or modals may not be translated. In such cases, where an interaction occurs with the user, it is best that content be generated in the user's preferred language.

The second is with respect to the actual educational content - the video and audio. Here, things get interesting, as many MOOC websites offer an automatic generation of subtitles of uploaded videos, and subsequently translate these subtitles in other languages. One major flaw is that the subtitles generated can be fairly incorrect especially if the speaker is not a native language speaker or has an accent - such as my case. I have found that automatic subtitles generated from my videos are usually full of errors.

Though subtitles are important especially to make the content accessible to those with hearing impairments, learning from reading the subtitles alone would make the videos a bit boring. As of now, there are many AI based translation tools that also allow for translation of audio into other languages. This implies that video content can be made separately available in different languages. Of course the translation may be glitchy, but it will get better with time.

So, for this the first would be to enable a language preference on the front-end. This is quite simple, it can be done on the home page or with a user's account. Once done, all requests made by the front-end to the back-end will contain the "Accept-Language" header, and the back-end will provide content in the language specified in the header. The default language will be English, so if the translated content does not exist, the default English content will be returned.

With Django, this actually turned out much easier than expected. I used the gettext package available with Linux. Apparently, the python-gettext package through pip did not work. Once this was done, the gettext  or gettext_lazy can be used to indicate translations to be expected. More on this in a separate post.

Another feature will be to provide translations in the database, and for this I plan to use django-modeltranslation. Both configurations and set up to be described in another post.

Monday, April 15, 2024

Lecture detail view with video list

To get started with videos, I need a new model VideoContent that has the name of the video, the course to which the video belongs and the video file. A video is associated with a course and then can be added to a lecture. This association between course and video is loose, it is merely to have some association while uploading them.

def video_file_path(instance, filename):
    '''
    Generate path that has course slug.
    Called when a video file is uploaded to Video model instance.

    Attributes
    ----------------
    instance : Video model instance
    filename : str

    Raises
    ----------------
    400 error
        If uploading for a course that does not exist

    Returns
    ----------------
    String with file path containing course slug
    '''

    course = instance.course
    if course is None:
        raise CustomAPIError(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail='Associated course not found'
        )
    dir_name = ''.join(filter(str.isalnum, course.slug))
    return f'{dir_name}/{filename}'


class VideoContent(models.Model):
    '''
    Video model for lectures

    Attributes
    ---------------
    course : Reference to a course model instance
    video_file : File
    created_at: Datetime
        Autogenerated when model is created
    updated_at: Datetime
        Autoupdated when model is updated
    '''
    course = models.ForeignKey(
        'courses.Course',
        related_name='videos',
        null=True,
        on_delete=models.SET_NULL
    )
    name = models.CharField(
        max_length=300,
        unique=True,
        default='Video name'
    )
    video_file = models.FileField(upload_to=video_file_path, max_length=300)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    @property
    def video_file_path(self):
        return f'{settings.BASE_URL}{self.video_file.url}'

    objects = VideoContentManager()

    def __str__(self):
        return self.video_file.url

The VideoContent model is quite simple - there is a name field which is unique, but that is just to be able to search for videos while associating them with lectures. There is mainly the video_file field which contains the uploaded file contents. I defined a property video_file_path that returns the URL of the uploaded file - this will be used when returning the video details so that the frontend can fetch the video and play it. To upload the video file, I created an uploader method that uploads the file into a directory according to the course slug.

To upload this video through an API, Django Rest Framework recommends using the FormParser and the MultiPartParser. Also, the file name needs to be a part of the API url. Therefore, the definition of the URL for uploading videos will be:

/api/courses/course-slug/lectures/id/add-video/filename

To be able to use Postman to upload files, the body of the request needs to be form-data with name as a parameter and the second parameter will be of type File. How this will be done with React will need to be checked out later.

The view class for video upload will be:

class VideoContentView(APIView, UserAuthentication):
    '''
    View for uploading videos

    Attributes
    -----------------
    parser_classes : list
        FormParser and MultiPartParser used for file uploads

    Methods
    -----------------
    post(request, filename, *args, **kwargs):
        Creates VideoContent model instance and handles file uploads
    '''

    parser_classes = [FormParser, MultiPartParser, ]
    user_model = User

    def post(self, request, filename, *args, **kwargs):
        '''
        Creates VideoContent model instance and handles file uploads

        Parameters
        ---------------
        request : Request object
        filename : str
            Name of the video file

        Raises
        ---------------
        400 error:
            If course slug is not present in url
            If video name is not provided in request body
            If video name is not unique
        403 error:
            If no credentials are provided in header
            If non-admin credentials are provided in header
            If non-instructor credentials are provided in header
        404 error:
            If lecture cannot be found

        Returns
        ---------------
        201 with VideoContentSerializer data
        '''
        user = self.authenticate(request)
        file_obj = request.data['File']

        course_slug = self.kwargs.get('slug', None)
        course_obj = Course.objects.get_course_by_slug(
            course_slug,
            admin_only=True
        )
        if not course_obj.check_user_is_instructor(user):
            raise CustomAPIError(
                status_code=status.HTTP_403_FORBIDDEN,
                detail='Only an instructor can add videos'
            )

        video_name = request.data.get('name')
        if video_name is None:
            raise CustomAPIError(
                status_code=status.HTTP_400_BAD_REQUEST,
                detail='Video name is required'
            )

        VideoContent.objects.is_video_name_unique(video_name)

        video_obj = VideoContent.objects.create(
            name=video_name,
            course=course_obj,
            video_file=file_obj
        )

        lecture_id = self.kwargs.get('id')
        Lecture.objects.add_video_to_lecture(lecture_id, video_obj)

        serializer = VideoContentSerializer(video_obj)

        return Response(
            data=serializer.data,
            status=status.HTTP_201_CREATED
        )

After the video is uploaded, the video is added to the lecture with the manager method add_video_to_lecture which will connect the video model instance to the videos field in the lecture instance with the ID specified in the API URL. The API returns the serializer which is:

class VideoContentSerializer(serializers.ModelSerializer):
    '''
    Serializer for model VideoContent
    '''
    video_file_path = serializers.ReadOnlyField()

    class Meta:
        model = VideoContent
        fields = ['name', 'video_file_path']

Now that the video has been uploaded, the videos have to show up in the lecture detail view. The lecture list view will not show the video list as the lecture list is an open end-point while the detail view is only for registered users or admin.

Therefore, defining a separate LectureDetailSerializer:

class LectureDetailSerializer(serializers.ModelSerializer):
    '''
    Serializer for detail view of Lecture including related videos
    '''

    videos = VideoContentSerializer(many=True, read_only=True)

    class Meta:
        model = Lecture
        fields = ['id', 'title', 'description', 'seq_no', 'videos']

The LectureDetailSerializer has an embedded serializer with video details. This now needs to be connected to the lecture view class. the only change that needs to be made in the LectureView class is to add a get_serializer_class that returns which serializer class to use according to the request type:

def get_serializer_class(self):
    '''
    Return the serializer_class according to the view.

    Returns
    ------------------
    LectureDetailSerializer for detail views and
    LectureSerializer for all other views
    '''
    if self.request.method == 'GET' and self.kwargs.get('id') is not None:
        return LectureDetailSerializer
    return LectureSerializer

Only when the request type is a GET and there is a lecture ID present in the URL parameters, should the detail serializer be used - in all other cases, the basic serializer should be used.

With this, now there is separate behavior for list and detail views as intended. And, a basic structure of the backend now exists - user registration, user login, course creation, course update, course fetch, lecture creation, lecture update, lecture fetch, video upload. Now to start coding the frontend and add features to the backend as needed.

Saturday, March 16, 2024

Handling videos

In this app, I would like to handle videos a little differently than regular online education websites. In any other MOOC website, a lecture may have video content or may just be some text instructions, and the lecture may have some downloadable resources/links. In my online app, I would like to introduce a new concept - interactive videos.

Interactive videos are typically used in marketing when a company produces promotional videos for their products, and if they offer a range of products and services, the videos might also be quite a few. In such cases, they would make their videos interactive, where the user decides which products or services they are interested in, and only watch the promo videos relevant to that. This user feedback can be from various means - a simple table with choices asking the user to select the products they are interested in, or a little more graphic - for example, a sports company showing the photo of a famous sports person and clicking on different clothing or equipment can take the user to the relevant products.

The reason for trying to bring this into online education is that most students do not have time to study regularly. Some might find time only on weekends, while for others it may be just a couple of times a month. In such a case, coming back to a course after a week, and especially a course that is heavily technical, would mean they might have to go back and review some of the older lectures and re-watch previous videos. Here comes the need for interactivity - if every lecture is completely self-sufficient, and this implies if a lecture uses a certain number of concepts, and there is specific review material for all these prerequisites, then the student can watch every lecture without any need to go back and forth - just select the prerequisites that need to be refreshed before watching the lecture. As for the students who are regularly watching and do not need review, they can skip the refreshers and go on to watch the main lecture video.

To enable the above, the video content for lectures needs to be reviewed. Instead of having just a single video for a lecture, there can be many videos for a lecture - the main video and also the prerequisites. Moreover, videos themselves can be reused, especially the prerequisites, and this implies that a video can be present in many lectures. This points to a ManyToMany relationship between videos and lectures. Furthermore, to make the handling of file content efficient, it would be best to reduce the duplication of video files and potentially even make them unique. If you want to reuse a video from another lecture or even course, no need to upload it again - merely refer to it.

Therefore, to structure the VideoContent model, I have been making a number of commits. The first was to associate a VideoContent with a Lecture as a foreign key which implies a lecture can have many videos. Next, I thought it might be better to add reference to Course as foreign key which means a course can have many videos, and add a reference to Lecture as a ManyToMany field, which implies that a lecture can have many videos, and a video can belong to many videos. However, going further, it looks like the ManyToMany relationship between lectures and videos is better handled in the Lecture model rather than the VideoContent model. While uploading a video, all that is needed is a primary identifier - which course the video belongs to, as this will also help to store the video in a folder that resembles the course slug. The video of course will be uploaded when a lecture is created, and therefore, it will be added to the videos list. There can be only a single video, if the instructor does not want any interactivity, or there could be multiple videos. How these multiple videos need to be played in the lecture will be decided by a configuration table - something that will come soon.

Monday, March 11, 2024

After documentation

I spent the last couple of weeks learning how to document Python code as well as Angular and React projects. I figured this is best done when the project is in a nascent stage, so as to establish this as the baseline going forward.

 The best documentation experience was with Angular. I used Jsdoc to document the Typescript code, but for Angular, there is a package compodoc that produces an elegant web document. This is the typical documentation for a Angular component:

/**
 * Generates a container for the video player
 *
 * @param {number} width The width of the container (optional)
 * @param {number} height The height of the container (optional)
 * @returns A container for the video and controls
 *
 * @example
 * Without any inputs, container adjusts to browser window
 * 
 *
 * @example
 * With only width, container has fixed width and
 * aspect ratio of 16:9
 * 
 *
 * @example
 * With only height, container has fixed width and
 * aspect ratio of 16:9
 * 
 *
 */

After documenting all components and utility functions, I created a very basic config file for the documentation and saved it in tsconfig.doc.json:

{
  "include": ["src/**/*.ts"],
  "exclude": ["src/test.ts", "src/**/*.spec.ts", "src/app/file-to-exclude.ts"]
}

Running compodoc produces an entire directory of html files that depicts the entire app graphically.

 

Very impressed to see documentation arranged in such a visually appealing manner.

In comparison, documentation for the React app was with Jsdoc and another package better-docs which has a special addition for components. Though running jsdoc produces another directory with html files, the links are broken and navigation seems a bit jumpy.

For Python, I used almost a markdown approach. Classes are documented as this example:

    '''
    Base view for a course based on course URL

    Attributes
    -------------
    serializer_class : class
        CourseSerializer class
    user_model : class
        User class
    lookup_field : str
        The field in URL used to look up model instance

    Methods
    -------------
    get_queryset() : Base method for course list view
    get_object() : Returns course model instance
    '''

The VSCode IDE displays the documentation once you hover over a class. A function has documentation similar to JS:

'''
Create a new course - POST request

Parameters
--------------
request - dict

Raises
--------------
400 error
    Course title missing or not unique
    Course price missing for non-free course
403 error
    If course created by non-admin user

Returns
--------------
201 response with course data
'''

Next step is to find a package that will convert these doc strings into html viewable pages that can be committed into Git.


Thursday, February 22, 2024

Continuing with video contents and getting started with the frontend

With some basic course and lecture end-points created and tested, the next step is the actual video contents of the course along with other downloadable resources like .pdf, .docx, .zip and other files. Since the backend is also now around 1/4th done, it is time to slowly get started with the frontend as well. Rather than start with the frontend for the app directly, I am starting with the video player.

Instead of using the regular video player, I have decided to build a custom video player for a few reasons. First, and quite obviously, I would like to have more control over the videos rather than what comes with a default video player. Second, a year or so back, I was working on another interesting project with a friend that involved building an interactive video player. This was a video player that would take user inputs and play certain parts of the video accordingly. The application for this is primarily in advertising and marketing as the user can choose to watch only what they are interested in watching. With respect to online courses, I would like to use this interactivity in asking students if they would like to review certain concepts necessary for the particular lecture they are about to watch. This is due to the fact that not all students can invest time regularly, and if they are watching videos after several days, they might need a refresher.

This project was called the Accordion player, a name that my friend came up with. I have started hosting this project at this Github link:

https://github.com/shivkiyer/accordion-player

I would definitely like to build the app in React, and would like to also use Angular as it is something I have used in the past. So, there are currently two sub-projects in this repo - one with React and the other with Angular.

Since the app is in the nascent stage, another thing which I would like to go back and work on is documentation. This time, with the Django backend, I have done my best to make the code as modular as possible. This implies classes and functions that are as small as possible. This has the advantage that the single doc string at the beginning of the function or class is usually good enough and there doesn't have to be comments all over the code which can become a little bothersome.

However, with both Python and JavaScript, it might be worthwhile to write proper doc strings which can then be used to create nice understandable documentation of the code. So there might be a few commits only related to documentation over the next week.

Wednesday, February 7, 2024

Additions to the lecture API endpoints

With the basic CRUD for lecture endpoints done, I will need to plan some other details as well as the lecture contents and resources (or attachments). For now, a lecture must have a title which must be unique in a course, and an optional description. Another field that a lecture must have is whether a preview is enabled, and in that case anyone can view it for free. Also, if the lecture has video content, the duration of the video needs to be displayed even in the list view.

Next comes the sequence of lectures in a course. There has to be a sequence ID to indicate how the lectures are to appear, and an instructor can move lectures around and change the sequence. This sequence ID is also unique within a course, and must not be set directly by the instructor, but rather must be generated during the creation process. In the case of a single lecture creation, it might be fairly easy, as one only needs to check the largest sequence number in the course and add the new lecture. In the case of bulk lecture creation, it might need some kind of counter as the contents of a spreadsheet might be read. Finally, in the lecture list view, the lectures should appear in the ascending order of the sequence ID.

There has been no difference between the Lecture List view and the Detail view. However, these two should be different - the list view should only display the title, description and time duration of a lecture. The detail view should provide everything - details of the video content, resources for download etc.

Some changes such as the detail view can wait until the other models such as videos and downloads are ready. But the sequence ID can be handled right away. Along with creating the sequence ID and retrieving lectures according to sequence ID, an API call for changing the sequence ID of a lecture must also be created. So, if lecture 15 in a course needs to be inserted before lecture 9, the sequence ID of lecture 15 will become 9, and the sequence IDs of lectures 9 to 14 will increase by 1. And the reverse process if a lecture needs to be moved down the list.

Thursday, February 1, 2024

Starting with the lecture detail view

The list view where all the lectures of a course can be fetched is a more public view, as the list of lectures should be visible to the general public even without login and registration. The only constraint is that the course should be published. Unpublished courses should be visible only to admin and to instructors.

For this reason, the GET method handler in lecture view is:

def get(self, request, *args, **kwargs):
    try:
        self.authenticate(request)
    except Exception as e:
        pass
    if self.request.user is not None and self.request.user.is_staff:
        self.init_lecture()
    else:
        self.init_lecture(admin_only=False)
    if self.kwargs.get('id', None) is None:
        return self.list(request, *args, **kwargs)
    self.check_permissions(request)
    return self.retrieve(request, *args, **kwargs)

First I call the authenticate method which will insert the user object into the request body from the JWT in the header. But, I call it in a way that it does not throw an exception. If the user is a staff user, I extract the course even if it is unpublished. Otherwise, only published courses will be extracted. This is by modifying the init_lecture method to be:

def init_lecture(self, admin_only=True):
    '''
    Initialize lecture view
        - fetch course object
    '''
    course_slug = self.kwargs.get('slug', None)
    self.course = Course.objects.get_course_by_slug(
        course_slug,
        admin_only=admin_only
    )

Unless the method is called with admin_only=False, it will extract even unpublished courses. In the view method, this happens only when there are no credentials or when the user is not admin. If a course is unpublished, a normal user or anonymous user will get a 'Course not found' 404 error.

In the GET method handler, if an id is passed in the url, it will proceed to the detail view. Here, for now, I am only checking permissions in the sense that a user should be logged in. The method check_permissions is:

def check_permissions(self, request):
    if request.user is None:
        raise CustomAPIError(
            status_code=status.HTTP_403_FORBIDDEN,
            detail='Must be logged in to access a lecture'
    )

Later this will check for registration and payment. For example, it an instructor wishes to have a course completely paid, the check_permissions will ensure that the user has paid for the course. My plan is to keep a certain percentage of videos of a course to be free, and so this can check if a video can be watched for free or needs to be paid for. This of course also implies that the lectures need a sequence ID within a course, so that you can find out beyond which lecture, a user will need to pay for the course.

The next view will be the PATCH and DELETE view. The lecture model needs a few changes and so does the serializer, as the detail view will need more details including the video content and also other resources like .pdf attachments etc.

Tuesday, January 30, 2024

Code refactor to simplify view classes

After having written a few view classes for endpoints, it is quite clear that there is way too much repetition. The reason for the repetition is the need to generate clear and different error messages from the backend so that the frontend will not have to do much. My first thought was that I would have to define a base view class that would define a wrapper method that would include these exception blocks and then all other view classes would inherit this base class. But this seemed like a very basic requirement that a million other Django developers might have wanted and so the chances that this would not somehow already be built into DRF seemed unlikely. After a little bit of googling, the answer was in the APIView class in DRF.

The GenericAPIView has only a few method such as get_queryset, get_serializer_class, get_object etc. What I was looking for was a base method similar to how the View class in Django had the dispatch method and a few others that were called under the hood. The GenericAPIView inherits the APIView class. The APIView class has a number of methods that get called under the hood, and one of them is the handle_exception method. The documentation says that any exception that is thrown by any handler method (get, post, patch etc) is passed to this method which either returns the appropriate Response or re-raises the exception if it can't handle it.

To fully appreciate what is going on, one really has to read the source code in Django Rest Framework. This is the second time I found myself browsing DRF source code and in my opinion, every one should regularly do so, as reading the source code gives you an understanding of DRF that is far deeper than just reading the documentation.

The handle_exception method is in the views.py file inside rest_framework folder of the Github repo:

def handle_exception(self, exc):
    """
    Handle any exception that occurs, by returning an appropriate response,
    or re-raising the error.
    """
    if isinstance(exc, (exceptions.NotAuthenticated,
                        exceptions.AuthenticationFailed)):
        # WWW-Authenticate header for 401 responses, else coerce to 403
        auth_header = self.get_authenticate_header(self.request)

        if auth_header:
            exc.auth_header = auth_header
        else:
            exc.status_code = status.HTTP_403_FORBIDDEN

    exception_handler = self.get_exception_handler()

    context = self.get_exception_handler_context()
    response = exception_handler(exc, context)

    if response is None:
        self.raise_uncaught_exception(exc)

    response.exception = True
    return response

There is a special handling for errors related to a user not being authenticated or authentication failed.  But the actual exception handling is a bit dynamic and the method is returned by the get_exception_handler method:

def get_exception_handler(self):
    """
    Returns the exception handler that this view uses.
    """
    return self.settings.EXCEPTION_HANDLER

The exception handler to be used is defined in the settings, and this is probably in case you want to choose a custom exception handler. But, the EXCEPTION_HANDLER is the settings is merely the method defined in the same file:

def exception_handler(exc, context):
    """
    Returns the response that should be used for any given exception.

    By default we handle the REST framework `APIException`, and also
    Django's built-in `Http404` and `PermissionDenied` exceptions.

    Any unhandled exceptions may return `None`, which will cause a 500 error
    to be raised.
    """
    if isinstance(exc, Http404):
        exc = exceptions.NotFound(*(exc.args))
    elif isinstance(exc, PermissionDenied):
        exc = exceptions.PermissionDenied(*(exc.args))

    if isinstance(exc, exceptions.APIException):
        headers = {}
        if getattr(exc, 'auth_header', None):
            headers['WWW-Authenticate'] = exc.auth_header
        if getattr(exc, 'wait', None):
            headers['Retry-After'] = '%d' % exc.wait

        if isinstance(exc.detail, (list, dict)):
            data = exc.detail
        else:
            data = {'detail': exc.detail}

        set_rollback()
        return Response(data, status=exc.status_code, headers=headers)

    return None

There is special handling for 404 and permission denied errors (403). But other than all it does it checks if the error is of type APIException.

class APIException(Exception):
    """
    Base class for REST framework exceptions.
    Subclasses should provide `.status_code` and `.default_detail` properties.
    """
    status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
    default_detail = _('A server error occurred.')
    default_code = 'error'

    def __init__(self, detail=None, code=None):
        if detail is None:
            detail = self.default_detail
        if code is None:
            code = self.default_code

        self.detail = _get_error_details(detail, code)

    def __str__(self):
        return str(self.detail)

    def get_codes(self):
        """
        Return only the code part of the error details.

        Eg. {"name": ["required"]}
        """
        return _get_codes(self.detail)

    def get_full_details(self):
        """
        Return both the message & code parts of the error details.

        Eg. {"name": [{"message": "This field is required.", "code": "required"}]}
        """
        return _get_full_details(self.detail)

So APIException inherits Exception but defines status_code and detail. The exception_handler method merely extracts the detail attribute along with the status_code in the APIException object and returns Response with the data being a dictionary containing the detail message. So, this exception_handler in reality is doing what I have been trying to do manually with different except blocks.

So, the first is to define a CustomAPIError that subclasses this APIException:

class CustomAPIError(APIException):
    def __init__(self, status_code, detail):
        self.status_code = status_code
        self.detail = detail

And all the other error classes like Http400Error etc can all be deleted. Where I was throwing these specific errors, I now throw the CustomAPIError. And the view classes can be much simpler, as once these errors are thrown, the handle_exception will call the exception_handler which will return the error Response. So, now the POST method handler for courses will be:

def post(self, request, *args, **kwargs):
    '''Create a new course - POST request'''
    self.authenticate(request)
    serializer = self.get_serializer(data=request.data)
    self.perform_create(serializer)
    return Response(
        serializer.data,
        status=status.HTTP_201_CREATED
    )

No need to enclose it in a try/except with different excepts throwing different errors - all taken care of by handle_exception. The only problem is that now logging is a bit broken. That will need that base class with a custom handle_exception method that uses logging.

Friday, January 26, 2024

Starting with the lecture app

A new app called lectures was created and the following very basic model just as a starting point:

class Lecture(models.Model):
    '''Lecture model'''

    course = models.ForeignKey(
        'courses.Course',
        models.SET_NULL,
        null=True
    )
    title = models.CharField(max_length=300)
    description = models.TextField(blank=True, null=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    def __str__(self):
        return self.title

Each lecture has to belong to a course, has to have a title and a description. For now, I have not created a ForeignKey pointing to User though it might be useful to know which instructor created the course and which one updated it. But, again, since this app is mainly for small-time instructors (such as myself) to host their courses rather than become a full-scale MOOC platform, there may not be that many instructors and so just logs might be good enough to keep track of what's going on. It can be added without too much fuss.

All URLs for lectures will be with respect to courses, and this implies, the structure will be /api/courses/<course-slug>/lectures/<lecture-urls>. For this reason, I did not add a placeholder for lectures in the main urls.py file, but rather inside the urls.py file of the courses app. In the urls.py file of the lectures app, I started off with a basic create view. Using the learning from the views in courses, I created a base view that inherits UserAuthentication and will later implement a get_object which will determine if the user has the authority to view the lecture.

class LectureBaseView(GenericAPIView, UserAuthentication):
    '''Basic lecture view'''

    serializer_class = LectureSerializer
    user_model = User
    lookup_field = 'id'


class LectureView(LectureBaseView, CreateModelMixin):
    '''Basic lecture view'''

    def get(self, request, *args, **kwargs):
        return Response('TODO')

    def post(self, request, *args, **kwargs):
        course_slug = self.kwargs.get('slug', None)
        try:
            course_obj = Course.objects.get_course_by_slug(course_slug)
            self.authenticate(request)
            serializer = LectureSerializer(data=request.data)
            serializer.save(
                user=self.request.user,
                course=course_obj
            )
            return Response(serializer.data)
        except Http400Error as e:
            return Response(
                data=str(e),
                status=status.HTTP_400_BAD_REQUEST
            )
        except Http403Error as e:
            return Response(
                data=str(e),
                status=status.HTTP_403_FORBIDDEN
            )
        except Http404Error as e:
            return Response(
                data=str(e),
                status=status.HTTP_404_NOT_FOUND
            )
        except Exception:
            return Response(
                data=DEFAULT_ERROR_RESPONSE,
                status=status.HTTP_400_BAD_REQUEST
            )

Though I am inheriting the CreateModelMixin, I am not really using it as I would like to throw custom exceptions rather than ValidationErrors by rest_framework. Maybe I will remove it later. But other than that the usual stuff, make sure user is authenticated and as an instructor (which means admin). Once course has been extracted from the slug and the user from the JWT token, the lecture can be created from the LectureSerializer:

class LectureSerializer(serializers.ModelSerializer):
    '''Serializer for Lecture model'''

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

    def check_user_is_instructor(self, course, user):
        if user is None:
            raise Http403Error(
                'Must be logged in as an instructor to create lectures'
            )
        if not course.check_user_is_instructor(user):
            raise Http403Error(
                'Must be an instructor of the course to create lectures'
            )
        return True

    def create(self, validated_data):
        user = validated_data.get('user', None)
        course = validated_data.get('course', None)
        if self.check_user_is_instructor(course, user):
            del validated_data['user']
            del validated_data['course']
            return Lecture.objects.create(
                **validated_data,
                course=course
            )

    class Meta:
        model = Lecture
        fields = ['title', 'description']
        extra_kwargs = {
            'title': {
                'error_messages': {
                    'required': 'The title of a lecture is required',
                    'blank': 'The title of a lecture is required'
                }
            }
        }

The create method checks if the user is an instructor and only then creates the course or else throws an exception.

Tried it out a bit through Postman and it works. A little more tweaking required to ensure that two lectures with the same title do not exist in the same course. That will probably a lecture manager method. Tests will make the code better.

Wednesday, January 24, 2024

Completing tests for basic course and user apps

Following the code refactor, I wrote tests for the three apps so far - user_auth, courses and registration. The tests cover almost all the non-trivial logic written so far, and so now the time has come to move on to the next part - course lectures.

A course can be created by an admin user and other admins can be added as instructors. Maybe, an option can be created for a teaching assistant at a later stage, in case an instructor does not want to add other co-instructors as admins but rather just other users as helpers. This I will figure out later along with the logic for deleting courses.

A course will have many lectures, so this is a simple case of a foreign key. The lectures can be video or audio or just text. Rather than having the content directly in the lectures, it might be better to create another table for that so that the content can be given metadata such as text translations for other languages or just merely audio in different languages.

In terms of a lecture, it should have a title, a number in a sequence, an optional description, content and optional attachments. Basic operations should be CRUD, except bulk delete should not be allowed in case a malicious instructor tries to delete a course. It should also be possible to change the sequence of lectures in a course. Of course, only an instructor should be able to perform any of these actions.

In terms of student registration, currently any student can register for any course paid or free. The payment processing will come later. In terms of student registrations, my plan is to allow any student to watch around 40% of a course for free and only then ask for payment for those courses which are paid. The reasoning - after watching 40% of the course, a student can judge whether the course is truly relevant and useful, and this way there will be no possibility of refunds later. The way to do this would be to update the CourseStudentRegistration model to include a paid field which can initially be False and will turn to True only when payment is processed. This can be used to check for authorization on whether a student can watch a particular video - if the lecture number is greater than the free quota, he will be asked to pay.

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.