10

Building an E-Learning Platform

In the previous chapter, you added internationalization to your online shop project. You also built a coupon system using sessions and a product recommendation engine using Redis. In this chapter, you will start a new Django project. You will build an e-learning platform with your own content management system (CMS). Online learning platforms are a great example of applications where you need to provide tools to generate content with flexibility in mind. In this chapter, you will learn how to build the functionality for instructors to create courses and manage the contents of courses in a versatile and efficient manner.

In this chapter, you will learn how to:

  • Create fixtures for your models
  • Use model inheritance
  • Create custom model fields
  • Use class-based views and mixins
  • Build formsets
  • Manage groups and permissions
  • Create a CMS

Setting up the e-learning project

Your final practical project will be an e-learning platform. First, create a virtual environment for your new project and activate it with the following commands:

mkdir env
python3 -m venv env/educa
source env/educa/bin/activate

Install Django in your virtual environment with the following command:

pip install "Django==3.0.*"

You are going to manage image uploads in your project, so you also need to install Pillow with the following command:

pip install Pillow==7.0.0

Create a new project using the following command:

django-admin startproject educa

Enter the new educa directory and create a new application using the following commands:

cd educa
django-admin startapp courses

Edit the settings.py file of the educa project and add courses to the INSTALLED_APPS setting, as follows:

INSTALLED_APPS = [
    'courses.apps.CoursesConfig',
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
]

The courses application is now active for the project. Let's define the models for courses and course contents.

Building the course models

Your e-learning platform will offer courses on various subjects. Each course will be divided into a configurable number of modules, and each module will contain a configurable number of contents. The contents will be of various types: text, file, image, or video. The following example shows what the data structure of your course catalog will look like:

Subject 1
  Course 1
    Module 1
      Content 1 (image)
      Content 2 (text)
    Module 2
      Content 3 (text)
      Content 4 (file)
      Content 5 (video)
      ...

Let's build the course models. Edit the models.py file of the courses application and add the following code to it:

from django.db import models
from django.contrib.auth.models import User
class Subject(models.Model):
    title = models.CharField(max_length=200)
    slug = models.SlugField(max_length=200, unique=True)
    class Meta:
        ordering = ['title']
    def __str__(self):
        return self.title
class Course(models.Model):
    owner = models.ForeignKey(User,
                              related_name='courses_created',
                              on_delete=models.CASCADE)
    subject = models.ForeignKey(Subject,
                                related_name='courses',
                                on_delete=models.CASCADE)
    title = models.CharField(max_length=200)
    slug = models.SlugField(max_length=200, unique=True)
    overview = models.TextField()
    created = models.DateTimeField(auto_now_add=True)
    class Meta:
        ordering = ['-created']
    def __str__(self):
        return self.title
class Module(models.Model):
    course = models.ForeignKey(Course,
                               related_name='modules',
                               on_delete=models.CASCADE)
    title = models.CharField(max_length=200)
    description = models.TextField(blank=True)
    def __str__(self):
        return self.title

These are the initial Subject, Course, and Module models. The Course model fields are as follows:

  • owner: The instructor who created this course.
  • subject: The subject that this course belongs to. It is a ForeignKey field that points to the Subject model.
  • title: The title of the course.
  • slug: The slug of the course. This will be used in URLs later.
  • overview: A TextField column to store an overview of the course.
  • created: The date and time when the course was created. It will be automatically set by Django when creating new objects because of auto_now_add=True.

Each course is divided into several modules. Therefore, the Module model contains a ForeignKey field that points to the Course model.

Open the shell and run the following command to create the initial migration for this application:

python manage.py makemigrations

You will see the following output:

Migrations for 'courses':
  courses/migrations/0001_initial.py:
    - Create model Course
    - Create model Module
    - Create model Subject
    - Add field subject to course

Then, run the following command to apply all migrations to the database:

python manage.py migrate

You should see output that includes all applied migrations, including those of Django. The output will contain the following line:

Applying courses.0001_initial... OK

The models of your courses application have been synced with the database.

Registering the models in the administration site

Let's add the course models to the administration site. Edit the admin.py file inside the courses application directory and add the following code to it:

from django.contrib import admin
from .models import Subject, Course, Module
@admin.register(Subject)
class SubjectAdmin(admin.ModelAdmin):
    list_display = ['title', 'slug']
    prepopulated_fields = {'slug': ('title',)}
class ModuleInline(admin.StackedInline):
    model = Module
@admin.register(Course)
class CourseAdmin(admin.ModelAdmin):
    list_display = ['title', 'subject', 'created']
    list_filter = ['created', 'subject']
    search_fields = ['title', 'overview']
    prepopulated_fields = {'slug': ('title',)}
    inlines = [ModuleInline]

The models for the course application are now registered in the administration site. Remember that you use the @admin.register() decorator to register models in the administration site.

Using fixtures to provide initial data for models

Sometimes, you might want to prepopulate your database with hardcoded data. This is useful for automatically including initial data in the project setup, instead of having to add it manually. Django comes with a simple way to load and dump data from the database into files that are called fixtures. Django supports fixtures in JSON, XML, or YAML formats. You are going to create a fixture to include several initial Subject objects for your project.

First, create a superuser using the following command:

python manage.py createsuperuser

Then, run the development server using the following command:

python manage.py runserver

Open http://127.0.0.1:8000/admin/courses/subject/ in your browser. Create several subjects using the administration site. The list display page should look as follows:

Figure 10.1: The subject change list view

Run the following command from the shell:

python manage.py dumpdata courses --indent=2

You will see output similar to the following:

[
{
  "model": "courses.subject",
  "pk": 1,
  "fields": {
    "title": "Mathematics",
    "slug": "mathematics"
  }
},
{
  "model": "courses.subject",
  "pk": 2,
  "fields": {
    "title": "Music",
    "slug": "music"
  }
},
{
  "model": "courses.subject",
  "pk": 3,
  "fields": {
    "title": "Physics",
    "slug": "physics"
  }
},
{
  "model": "courses.subject",
  "pk": 4,
  "fields": {
    "title": "Programming",
    "slug": "programming"
  }
}
]

The dumpdata command dumps data from the database into the standard output, serialized in JSON format by default. The resulting data structure includes information about the model and its fields for Django to be able to load it into the database.

You can limit the output to the models of an application by providing the application names to the command, or specifying single models for outputting data using the app.Model format. You can also specify the format using the --format flag. By default, dumpdata outputs the serialized data to the standard output. However, you can indicate an output file using the --output flag. The --indent flag allows you to specify indentation. For more information on dumpdata parameters, run python manage.py dumpdata --help.

Save this dump to a fixtures file in a new fixtures/ directory in the courses application using the following commands:

mkdir courses/fixtures
python manage.py dumpdata courses --indent=2 --output=courses/fixtures/subjects.json

Run the development server and use the administration site to remove the subjects you created. Then, load the fixture into the database using the following command:

python manage.py loaddata subjects.json

All Subject objects included in the fixture are loaded into the database.

By default, Django looks for files in the fixtures/ directory of each application, but you can specify the complete path to the fixture file for the loaddata command. You can also use the FIXTURE_DIRS setting to tell Django additional directories to look in for fixtures.

Fixtures are not only useful for setting up initial data, but also for providing sample data for your application or data required for your tests.

You can read about how to use fixtures for testing at https://docs.djangoproject.com/en/3.0/topics/testing/tools/#fixture-loading.

If you want to load fixtures in model migrations, take a look at Django's documentation about data migrations. You can find the documentation for migrating data at https://docs.djangoproject.com/en/3.0/topics/migrations/#data-migrations.

Creating models for diverse content

You plan to add different types of content to the course modules, such as text, images, files, and videos. Therefore, you need a versatile data model that allows you to store diverse content. In Chapter 6, Tracking User Actions, you learned the convenience of using generic relations to create foreign keys that can point to the objects of any model. You are going to create a Content model that represents the modules' contents, and define a generic relation to associate any kind of content.

Edit the models.py file of the courses application and add the following imports:

from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes.fields import GenericForeignKey

Then, add the following code to the end of the file:

class Content(models.Model):
    module = models.ForeignKey(Module,
                               related_name='contents',
                               on_delete=models.CASCADE)
    content_type = models.ForeignKey(ContentType,
                                     on_delete=models.CASCADE)
    object_id = models.PositiveIntegerField()
    item = GenericForeignKey('content_type', 'object_id')

This is the Content model. A module contains multiple contents, so you define a ForeignKey field that points to the Module model. You also set up a generic relation to associate objects from different models that represent different types of content. Remember that you need three different fields to set up a generic relation. In your Content model, these are:

  • content_type: A ForeignKey field to the ContentType model
  • object_id: A PositiveIntegerField to store the primary key of the related object
  • item: A GenericForeignKey field to the related object combining the two previous fields

Only the content_type and object_id fields have a corresponding column in the database table of this model. The item field allows you to retrieve or set the related object directly, and its functionality is built on top of the other two fields.

You are going to use a different model for each type of content. Your content models will have some common fields, but they will differ in the actual data they can store.

Using model inheritance

Django supports model inheritance. It works in a similar way to standard class inheritance in Python. Django offers the following three options to use model inheritance:

  • Abstract models: Useful when you want to put some common information into several models.
  • Multi-table model inheritance: Applicable when each model in the hierarchy is considered a complete model by itself.
  • Proxy models: Useful when you need to change the behavior of a model, for example, by including additional methods, changing the default manager, or using different meta options.

Let's take a closer look at each of them.

Abstract models

An abstract model is a base class in which you define fields you want to include in all child models. Django doesn't create any database tables for abstract models. A database table is created for each child model, including the fields inherited from the abstract class and the ones defined in the child model.

To mark a model as abstract, you need to include abstract=True in its Meta class. Django will recognize that it is an abstract model and will not create a database table for it. To create child models, you just need to subclass the abstract model.

The following example shows an abstract Content model and a child Text model:

from django.db import models
class BaseContent(models.Model):
    title = models.CharField(max_length=100)
    created = models.DateTimeField(auto_now_add=True)
    class Meta:
        abstract = True
class Text(BaseContent):
    body = models.TextField()

In this case, Django would create a table for the Text model only, including the title, created, and body fields.

Multi-table model inheritance

In multi-table inheritance, each model corresponds to a database table. Django creates a OneToOneField field for the relationship between the child model and its parent model. To use multi-table inheritance, you have to subclass an existing model. Django will create a database table for both the original model and the sub-model. The following example shows multi-table inheritance:

from django.db import models
class BaseContent(models.Model):
    title = models.CharField(max_length=100)
    created = models.DateTimeField(auto_now_add=True)
class Text(BaseContent):
    body = models.TextField()

Django would include an automatically generated OneToOneField field in the Text model and create a database table for each model.

Proxy models

A proxy model changes the behavior of a model. Both models operate on the database table of the original model. To create a proxy model, add proxy=True to the Meta class of the model. The following example illustrates how to create a proxy model:

from django.db import models
from django.utils import timezone
class BaseContent(models.Model):
    title = models.CharField(max_length=100)
    created = models.DateTimeField(auto_now_add=True)
class OrderedContent(BaseContent):
    class Meta:
        proxy = True
        ordering = ['created']
    def created_delta(self):
        return timezone.now() - self.created

Here, you define an OrderedContent model that is a proxy model for the Content model. This model provides a default ordering for QuerySets and an additional created_delta() method. Both models, Content and OrderedContent, operate on the same database table, and objects are accessible via the ORM through either model.

Creating the content models

The Content model of your courses application contains a generic relation to associate different types of content with it. You will create a different model for each type of content. All content models will have some fields in common and additional fields to store custom data. You are going to create an abstract model that provides the common fields for all content models.

Edit the models.py file of the courses application and add the following code to it:

class ItemBase(models.Model):
    owner = models.ForeignKey(User,
                              related_name='%(class)s_related',
                              on_delete=models.CASCADE)
    title = models.CharField(max_length=250)
    created = models.DateTimeField(auto_now_add=True)
    updated = models.DateTimeField(auto_now=True)
    class Meta:
        abstract = True
    def __str__(self):
        return self.title
class Text(ItemBase):
    content = models.TextField()
class File(ItemBase):
    file = models.FileField(upload_to='files')
class Image(ItemBase):
       file = models.FileField(upload_to='images')
class Video(ItemBase):
    url = models.URLField()

In this code, you define an abstract model named ItemBase. Therefore, you set abstract=True in its Meta class.

In this model, you define the owner, title, created, and updated fields. These common fields will be used for all types of content.

The owner field allows you to store which user created the content. Since this field is defined in an abstract class, you need a different related_name for each sub-model. Django allows you to specify a placeholder for the model class name in the related_name attribute as %(class)s. By doing so, related_name for each child model will be generated automatically. Since you use '%(class)s_related' as the related_name, the reverse relationship for child models will be text_related, file_related, image_related, and video_related, respectively.

You have defined four different content models that inherit from the ItemBase abstract model. These are as follows:

  • Text: To store text content
  • File: To store files, such as PDFs
  • Image: To store image files
  • Video: To store videos; you use an URLField field to provide a video URL in order to embed it

Each child model contains the fields defined in the ItemBase class in addition to its own fields. A database table will be created for the Text, File, Image, and Video models, respectively. There will be no database table associated with the ItemBase model, since it is an abstract model.

Edit the Content model you created previously and modify its content_type field, as follows:

content_type = models.ForeignKey(ContentType,
                   on_delete=models.CASCADE,
                   limit_choices_to={'model__in':(
                                     'text',
                                     'video',
                                     'image',
                                     'file')})

You add a limit_choices_to argument to limit the ContentType objects that can be used for the generic relation. You use the model__in field lookup to filter the query to the ContentType objects with a model attribute that is 'text', 'video', 'image', or 'file'.

Let's create a migration to include the new models you have added. Run the following command from the command line:

python manage.py makemigrations

You will see the following output:

Migrations for 'courses':
  courses/migrations/0002_content_file_image_text_video.py
    - Create model Video
    - Create model Text
    - Create model Image
    - Create model File
    - Create model Content

Then, run the following command to apply the new migration:

python manage.py migrate

The output you see should end with the following line:

Applying courses.0002_content_file_image_text_video... OK

You have created models that are suitable for adding diverse content to the course modules. However, there is still something missing in your models: the course modules and contents should follow a particular order. You need a field that allows you to order them easily.

Creating custom model fields

Django comes with a complete collection of model fields that you can use to build your models. However, you can also create your own model fields to store custom data or alter the behavior of existing fields.

You need a field that allows you to define an order for objects. An easy way to specify an order for objects using existing Django fields is by adding a PositiveIntegerField to your models. Using integers, you can easily specify the order of objects. You can create a custom order field that inherits from PositiveIntegerField and provides additional behavior.

There are two relevant functionalities that you will build into your order field:

  • Automatically assign an order value when no specific order is provided: When saving a new object with no specific order, your field should automatically assign the number that comes after the last existing ordered object. If there are two objects with order 1 and 2 respectively, when saving a third object, you should automatically assign the order 3 to it if no specific order has been provided.
  • Order objects with respect to other fields: Course modules will be ordered with respect to the course they belong to and module contents with respect to the module they belong to.

Create a new fields.py file inside the courses application directory and add the following code to it:

from django.db import models
from django.core.exceptions import ObjectDoesNotExist
class OrderField(models.PositiveIntegerField):
    def __init__(self, for_fields=None, *args, **kwargs):
        self.for_fields = for_fields
        super().__init__(*args, **kwargs)
    def pre_save(self, model_instance, add):
        if getattr(model_instance, self.attname) is None:
            # no current value
            try:
                qs = self.model.objects.all()
                if self.for_fields:
                    # filter by objects with the same field values
                    # for the fields in "for_fields"
                    query = {field: getattr(model_instance, field)
                    for field in self.for_fields}
                    qs = qs.filter(**query)
                # get the order of the last item
                last_item = qs.latest(self.attname)
                value = last_item.order + 1
            except ObjectDoesNotExist:
                value = 0
            setattr(model_instance, self.attname, value)
            return value
        else:
            return super().pre_save(model_instance, add)

This is your custom OrderField. It inherits from the PositiveIntegerField field provided by Django. Your OrderField field takes an optional for_fields parameter that allows you to indicate the fields that the order has to be calculated with respect to.

Your field overrides the pre_save() method of the PositiveIntegerField field, which is executed before saving the field into the database. In this method, you perform the following actions:

  1. You check whether a value already exists for this field in the model instance. You use self.attname, which is the attribute name given to the field in the model. If the attribute's value is different to None, you calculate the order you should give it as follows:
    1. You build a QuerySet to retrieve all objects for the field's model. You retrieve the model class the field belongs to by accessing self.model.
    2. If there are any field names in the for_fields attribute of the field, you filter the QuerySet by the current value of the model fields in for_fields. By doing so, you calculate the order with respect to the given fields.
    3. You retrieve the object with the highest order with last_item = qs.latest(self.attname) from the database. If no object is found, you assume this object is the first one and assign the order 0 to it.
    4. If an object is found, you add 1 to the highest order found.
    5. You assign the calculated order to the field's value in the model instance using setattr() and return it.
  2. If the model instance has a value for the current field, you use it instead of calculating it.

When you create custom model fields, make them generic. Avoid hardcoding data that depends on a specific model or field. Your field should work in any model.

You can find more information about writing custom model fields at https://docs.djangoproject.com/en/3.0/howto/custom-model-fields/.

Adding ordering to module and content objects

Let's add the new field to your models. Edit the models.py file of the courses application, and import the OrderField class and a field to the Module model, as follows:

from .fields import OrderField
class Module(models.Model):
    # ...
    order = OrderField(blank=True, for_fields=['course'])

You name the new field order, and specify that the ordering is calculated with respect to the course by setting for_fields=['course']. This means that the order for a new module will be assigned by adding 1 to the last module of the same Course object.

Now, you can, edit the __str__() method of the Module model to include its order, as follows:

class Module(models.Model):
    # ...
    def __str__(self):
        return f'{self.order}. {self.title}'

Module contents also need to follow a particular order. Add an OrderField field to the Content model, as follows:

class Content(models.Model):
    # ...
    order = OrderField(blank=True, for_fields=['module'])

This time, you specify that the order is calculated with respect to the module field.

Finally, let's add a default ordering for both models. Add the following Meta class to the Module and Content models:

class Module(models.Model):
    # ...
    class Meta:
        ordering = ['order']
class Content(models.Model):
    # ...
    class Meta:
        ordering = ['order']

The Module and Content models should now look as follows:

class Module(models.Model):
    course = models.ForeignKey(Course,
                               related_name='modules',
                               on_delete=models.CASCADE)
    title = models.CharField(max_length=200)
    description = models.TextField(blank=True)
    order = OrderField(blank=True, for_fields=['course'])
    class Meta:
        ordering = ['order']
    def __str__(self):
        return f'{self.order}. {self.title}'
class Content(models.Model):
    module = models.ForeignKey(Module,
                               related_name='contents',
                               on_delete=models.CASCADE)
    content_type = models.ForeignKey(ContentType,
                                   on_delete=models.CASCADE,
                                   limit_choices_to={'model__in':(
                                                     'text',
                                                     'video',
                                                     'image',
                                                     'file')})
    object_id = models.PositiveIntegerField()
    item = GenericForeignKey('content_type', 'object_id')
    order = OrderField(blank=True, for_fields=['module'])
    class Meta:
            ordering = ['order']

Let's create a new model migration that reflects the new order fields. Open the shell and run the following command:

python manage.py makemigrations courses

You will see the following output:

You are trying to add a non-nullable field 'order' to content without a default; we can't do that (the database needs something to populate existing rows).
Please select a fix:
 1) Provide a one-off default now (will be set on all existing rows with a null value for this column)
 2) Quit, and let me add a default in models.py
Select an option:

Django is telling you that you have to provide a default value for the new order field for existing rows in the database. If the field had null=True, it would accept null values and Django would create the migration automatically instead of asking for a default value. You can specify a default value, or cancel the migration and add a default attribute to the order field in the models.py file before creating the migration.

Enter 1 and press Enter to provide a default value for existing records. You will see the following output:

Please enter the default value now, as valid Python
The datetime and django.utils.timezone modules are available, so you can do e.g. timezone.now
Type 'exit' to exit this prompt
>>>

Enter 0 so that this is the default value for existing records and press Enter. Django will ask you for a default value for the Module model too. Choose the first option and enter 0 as the default value again. Finally, you will see an output similar to the following one:

Migrations for 'courses':
  courses/migrations/0003_auto_20191214_1253.py
    - Change Meta options on content
    - Change Meta options on module
    - Add field order to content
    - Add field order to module

Then, apply the new migrations with the following command:

python manage.py migrate

The output of the command will inform you that the migration was successfully applied, as follows:

Applying courses.0003_auto_20191214_1253... OK

Let's test your new field. Open the shell with the following command:

python manage.py shell

Create a new course, as follows:

>>> from django.contrib.auth.models import User
>>> from courses.models import Subject, Course, Module
>>> user = User.objects.last()
>>> subject = Subject.objects.last()
>>> c1 = Course.objects.create(subject=subject, owner=user, title='Course 1', slug='course1')

You have created a course in the database. Now, you will add modules to the course and see how their order is automatically calculated. You create an initial module and check its order:

>>> m1 = Module.objects.create(course=c1, title='Module 1')
>>> m1.order
0

OrderField sets its value to 0, since this is the first Module object created for the given course. You, create a second module for the same course:

>>> m2 = Module.objects.create(course=c1, title='Module 2')
>>> m2.order
1

OrderField calculates the next order value, adding 1 to the highest order for existing objects. Let's create a third module, forcing a specific order:

>>> m3 = Module.objects.create(course=c1, title='Module 3', order=5)
>>> m3.order
5

If you specify a custom order, the OrderField field does not interfere and the value given to order is used.

Let's add a fourth module:

>>> m4 = Module.objects.create(course=c1, title='Module 4')
>>> m4.order
6

The order for this module has been automatically set. Your OrderField field does not guarantee that all order values are consecutive. However, it respects existing order values and always assigns the next order based on the highest existing order.

Let's create a second course and add a module to it:

>>> c2 = Course.objects.create(subject=subject, title='Course 2', slug='course2', owner=user)
>>> m5 = Module.objects.create(course=c2, title='Module 1')
>>> m5.order
0

To calculate the new module's order, the field only takes into consideration existing modules that belong to the same course. Since this is the first module of the second course, the resulting order is 0. This is because you specified for_fields=['course'] in the order field of the Module model.

Congratulations! You have successfully created your first custom model field.

Creating a CMS

Now that you have created a versatile data model, you are going to build the CMS. The CMS will allow instructors to create courses and manage their contents. You need to provide the following functionality:

  • Log in to the CMS
  • List the courses created by the instructor
  • Create, edit, and delete courses
  • Add modules to a course and reorder them
  • Add different types of content to each module and reorder them

Adding an authentication system

You are going to use Django's authentication framework in your platform. Both instructors and students will be instances of Django's User model, so they will be able to log in to the site using the authentication views of django.contrib.auth.

Edit the main urls.py file of the educa project and include the login and logout views of Django's authentication framework:

from django.contrib import admin
from django.urls import path
from django.contrib.auth import views as auth_views
urlpatterns = [
    path('accounts/login/', auth_views.LoginView.as_view(), 
          name='login'),
    path('accounts/logout/', auth_views.LogoutView.as_view(), 
          name='logout'),
    path('admin/', admin.site.urls),
]

Creating the authentication templates

Create the following file structure inside the courses application directory:

templates/
    base.html
    registration/
        login.html
        logged_out.html

Before building the authentication templates, you need to prepare the base template for your project. Edit the base.html template file and add the following content to it:

{% load static %}
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8" />
  <title>{% block title %}Educa{% endblock %}</title>
  <link href="{% static "css/base.css" %}" rel="stylesheet">
</head>
<body>
  <div id="header">
    <a href="/" class="logo">Educa</a>
    <ul class="menu">
      {% if request.user.is_authenticated %}
        <li><a href="{% url "logout" %}">Sign out</a></li>
      {% else %}
        <li><a href="{% url "login" %}">Sign in</a></li>
      {% endif %}
    </ul>
  </div>
  <div id="content">
    {% block content %}
    {% endblock %}
  </div>
  <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js">
  </script>
  <script>
    $(document).ready(function() {
      {% block domready %}
      {% endblock %}
    });
  </script>
</body>
</html>

This is the base template that will be extended by the rest of the templates. In this template, you define the following blocks:

  • title: The block for other templates to add a custom title for each page.
  • content: The main block for content. All templates that extend the base template should add content to this block.
  • domready: Located inside the $(document).ready() function of jQuery. It allows you to execute code when the Document Object Model (DOM) has finished loading.

The CSS styles used in this template are located in the static/ directory of the courses application in the code that comes along with this chapter. Copy the static/ directory into the same directory of your project to use them. You can find the contents of the directory at https://github.com/PacktPublishing/Django-3-by-Example/tree/master/Chapter10/educa/courses/static.

Edit the registration/login.html template and add the following code to it:

{% extends "base.html" %}
{% block title %}Log-in{% endblock %}
{% block content %}
  <h1>Log-in</h1>
  <div class="module">
    {% if form.errors %}
      <p>Your username and password didn't match. Please try again.</p>
    {% else %}
      <p>Please, use the following form to log-in:</p>
    {% endif %}
    <div class="login-form">
      <form action="{% url 'login' %}" method="post">
        {{ form.as_p }}
        {% csrf_token %}
        <input type="hidden" name="next" value="{{ next }}" />
        <p><input type="submit" value="Log-in"></p>
      </form>
    </div>
  </div>
{% endblock %}

This is a standard login template for Django's login view.

Edit the registration/logged_out.html template and add the following code to it:

{% extends "base.html" %}
{% block title %}Logged out{% endblock %}
{% block content %}
  <h1>Logged out</h1>
  <div class="module">
    <p>You have been successfully logged out.
       You can <a href="{% url "login" %}">log-in again</a>.</p>
  </div>
{% endblock %}

This is the template that will be displayed to the user after logout. Run the development server with the following command:

python manage.py runserver

Open http://127.0.0.1:8000/accounts/login/ in your browser. You should see the login page:

Figure 10.2: The account login page

Creating class-based views

You are going to build views to create, edit, and delete courses. You will use class-based views for this. Edit the views.py file of the courses application and add the following code to it:

from django.views.generic.list import ListView
from .models import Course
class ManageCourseListView(ListView):
    model = Course
    template_name = 'courses/manage/course/list.html'
    def get_queryset(self):
        qs = super().get_queryset()
        return qs.filter(owner=self.request.user)

This is the ManageCourseListView view. It inherits from Django's generic ListView. You override the get_queryset() method of the view to retrieve only courses created by the current user. To prevent users from editing, updating, or deleting courses they didn't create, you will also need to override the get_queryset() method in the create, update, and delete views. When you need to provide a specific behavior for several class-based views, it is recommended that you use mixins.

Using mixins for class-based views

Mixins are a special kind of multiple inheritance for a class. You can use them to provide common discrete functionality that, when added to other mixins, allows you to define the behavior of a class. There are two main situations to use mixins:

  • You want to provide multiple optional features for a class
  • You want to use a particular feature in several classes

Django comes with several mixins that provide additional functionality to your class-based views. You can learn more about mixins at https://docs.djangoproject.com/en/3.0/topics/class-based-views/mixins/.

You are going to create a mixin class that includes a common behavior, and use it for the course views. Edit the views.py file of the courses application and modify it as follows:

from django.urls import reverse_lazy
from django.views.generic.list import ListView
from django.views.generic.edit import CreateView, UpdateView, 
                                      DeleteView
from .models import Course
class OwnerMixin(object):
    def get_queryset(self):
        qs = super().get_queryset()
        return qs.filter(owner=self.request.user)
class OwnerEditMixin(object):
    def form_valid(self, form):
        form.instance.owner = self.request.user
        return super().form_valid(form)
class OwnerCourseMixin(OwnerMixin):
    model = Course
    fields = ['subject', 'title', 'slug', 'overview']
    success_url = reverse_lazy('manage_course_list')
class OwnerCourseEditMixin(OwnerCourseMixin, OwnerEditMixin):
    template_name = 'courses/manage/course/form.html'
class ManageCourseListView(OwnerCourseMixin, ListView):
    template_name = 'courses/manage/course/list.html'
class CourseCreateView(OwnerCourseEditMixin, CreateView):
    pass
class CourseUpdateView(OwnerCourseEditMixin, UpdateView):
    pass
class CourseDeleteView(OwnerCourseMixin, DeleteView):
    template_name = 'courses/manage/course/delete.html'
    

In this code, you create the OwnerMixin and OwnerEditMixin mixins. You will use these mixins together with the ListView, CreateView, UpdateView, and DeleteView views provided by Django. OwnerMixin implements the get_queryset() method, which is used by the views to get the base QuerySet. Your mixin will override this method to filter objects by the owner attribute to retrieve objects that belong to the current user (request.user).

OwnerEditMixin implements the form_valid() method, which is used by views that use Django's ModelFormMixin mixin, that is, views with forms or model forms such as CreateView and UpdateView. form_valid() is executed when the submitted form is valid.

The default behavior for this method is saving the instance (for model forms) and redirecting the user to success_url. You override this method to automatically set the current user in the owner attribute of the object being saved. By doing so, you set the owner for an object automatically when it is saved.

Your OwnerMixin class can be used for views that interact with any model that contains an owner attribute.

You also define an OwnerCourseMixin class that inherits OwnerMixin and provides the following attributes for child views:

  • model: The model used for QuerySets; it is used by all views.
  • fields: The fields of the model to build the model form of the CreateView and UpdateView views.
  • success_url: Used by CreateView, UpdateView, and DeleteView to redirect the user after the form is successfully submitted or the object is deleted. You use a URL with the name manage_course_list, which you are going to create later.

You define an OwnerCourseEditMixin mixin with the following attribute:

  • template_name: The template you will use for the CreateView and UpdateView views

Finally, you create the following views that subclass OwnerCourseMixin:

  • ManageCourseListView: Lists the courses created by the user. It inherits from OwnerCourseMixin and ListView. It defines a specific template_name attribute for a template to list courses.
  • CourseCreateView: Uses a model form to create a new Course object. It uses the fields defined in OwnerCourseMixin to build a model form and also subclasses CreateView. It uses the template defined in OwnerCourseEditMixin.
  • CourseUpdateView: Allows the editing of an existing Course object. It uses the fields defined in OwnerCourseMixin to build a model form and also subclasses UpdateView. It uses the template defined in OwnerCourseEditMixin.
  • CourseDeleteView: Inherits from OwnerCourseMixin and the generic DeleteView. It defines a specific template_name attribute for a template to confirm the course deletion.

Working with groups and permissions

You have created the basic views to manage courses. Currently, any user could access these views. You want to restrict these views so that only instructors have the permission to create and manage courses.

Django's authentication framework includes a permission system that allows you to assign permissions to users and groups. You are going to create a group for instructor users and assign permissions to create, update, and delete courses.

Run the development server using the command python manage.py runserver and open http://127.0.0.1:8000/admin/auth/group/add/ in your browser to create a new Group object. Add the name Instructors and choose all permissions of the courses application, except those of the Subject model, as follows:

Figure 10.3: The Instructors group permissions

As you can see, there are four different permissions for each model: can view, can add, can change, and can delete. After choosing permissions for this group, click on the SAVE button.

Django creates permissions for models automatically, but you can also create custom permissions. You will learn to create custom permissions in Chapter 12, Building an API. You can read more about adding custom permissions at https://docs.djangoproject.com/en/3.0/topics/auth/customizing/#custom-permissions.

Open http://127.0.0.1:8000/admin/auth/user/add/ and create a new user. Edit the user and add it to the Instructors group, as follows:

Figure 10.4: User group selection

Users inherit the permissions of the groups they belong to, but you can also add individual permissions to a single user using the administration site. Users that have is_superuser set to True have all permissions automatically.

Restricting access to class-based views

You are going to restrict access to the views so that only users with the appropriate permissions can add, change, or delete Course objects. You are going to use the following two mixins provided by django.contrib.auth to limit access to views:

  • LoginRequiredMixin: Replicates the login_required decorator's functionality.
  • PermissionRequiredMixin: Grants access to the view to users with a specific permission. Remember that superusers automatically have all permissions.

Edit the views.py file of the courses application and add the following import:

from django.contrib.auth.mixins import LoginRequiredMixin, 
                                       PermissionRequiredMixin

Make OwnerCourseMixin inherit LoginRequiredMixin and PermissionRequiredMixin, like this:

class OwnerCourseMixin(OwnerMixin,
                       LoginRequiredMixin,
                       PermissionRequiredMixin):
    model = Course
    fields = ['subject', 'title', 'slug', 'overview']
    success_url = reverse_lazy('manage_course_list')

Then, add a permission_required attribute to the course views, as follows:

class ManageCourseListView(OwnerCourseMixin, ListView):
    template_name = 'courses/manage/course/list.html'
    permission_required = 'courses.view_course'
class CourseCreateView(OwnerCourseEditMixin, CreateView):
    permission_required = 'courses.add_course'
class CourseUpdateView(OwnerCourseEditMixin, UpdateView):
    permission_required = 'courses.change_course'
class CourseDeleteView(OwnerCourseMixin, DeleteView):
    template_name = 'courses/manage/course/delete.html'
    permission_required = 'courses.delete_course'

PermissionRequiredMixin checks that the user accessing the view has the permission specified in the permission_required attribute. Your views are now only accessible to users with proper permissions.

Let's create URLs for these views. Create a new file inside the courses application directory and name it urls.py. Add the following code to it:

from django.urls import path
from . import views
urlpatterns = [
    path('mine/',
         views.ManageCourseListView.as_view(),
         name='manage_course_list'),
    path('create/',
         views.CourseCreateView.as_view(),
         name='course_create'),
    path('<pk>/edit/',
         views.CourseUpdateView.as_view(),
         name='course_edit'),
    path('<pk>/delete/',
         views.CourseDeleteView.as_view(),
         name='course_delete'),
]

These are the URL patterns for the list, create, edit, and delete course views. Edit the main urls.py file of the educa project and include the URL patterns of the courses application, as follows:

from django.urls import path, include
urlpatterns = [
    path('accounts/login/', auth_views.LoginView.as_view(), 
          name='login'),
    path('accounts/logout/', auth_views.LogoutView.as_view(), 
          name='logout'),
    path('admin/', admin.site.urls),
    path('course/', include('courses.urls')),
]

You need to create the templates for these views. Create the following directories and files inside the templates/ directory of the courses application:

courses/
    manage/
        course/
            list.html
            form.html
            delete.html

Edit the courses/manage/course/list.html template and add the following code to it:

{% extends "base.html" %}
{% block title %}My courses{% endblock %}
{% block content %}
  <h1>My courses</h1>
  <div class="module">
    {% for course in object_list %}
      <div class="course-info">
        <h3>{{ course.title }}</h3>
        <p>
          <a href="{% url "course_edit" course.id %}">Edit</a>
          <a href="{% url "course_delete" course.id %}">Delete</a>
        </p>
      </div>
    {% empty %}
      <p>You haven't created any courses yet.</p>
    {% endfor %}
    <p>
      <a href="{% url "course_create" %}" class="button">Create new course</a>
    </p>
  </div>
{% endblock %}

This is the template for the ManageCourseListView view. In this template, you list the courses created by the current user. You include links to edit or delete each course, and a link to create new courses.

Run the development server using the command python manage.py runserver. Open http://127.0.0.1:8000/accounts/login/?next=/course/mine/ in your browser and log in with a user belonging to the Instructors group. After logging in, you will be redirected to the http://127.0.0.1:8000/course/mine/ URL and you should see the following page:

Figure 10.5: The instructor courses page with no courses

This page will display all courses created by the current user.

Let's create the template that displays the form for the create and update course views. Edit the courses/manage/course/form.html template and write the following code:

{% extends "base.html" %}
{% block title %}
  {% if object %}
    Edit course "{{ object.title }}"
  {% else %}
    Create a new course
  {% endif %}
{% endblock %}
{% block content %}
  <h1>
    {% if object %}
      Edit course "{{ object.title }}"
    {% else %}
      Create a new course
    {% endif %}
  </h1>
  <div class="module">
    <h2>Course info</h2>
    <form method="post">
      {{ form.as_p }}
      {% csrf_token %}
      <p><input type="submit" value="Save course"></p>
    </form>
  </div>
{% endblock %}

The form.html template is used for both the CourseCreateView and CourseUpdateView views. In this template, you check whether an object variable is in the context. If object exists in the context, you know that you are updating an existing course, and you use it in the page title. Otherwise, you are creating a new Course object.

Open http://127.0.0.1:8000/course/mine/ in your browser and click the CREATE NEW COURSE button. You will see the following page:

Figure 10.6: The form to create a new course

Fill in the form and click the SAVE COURSE button. The course will be saved and you will be redirected to the course list page. It should look as follows:

Figure 10.7: The instructor courses page with one course

Then, click the Edit link for the course you have just created. You will see the form again, but this time you are editing an existing Course object instead of creating one.

Finally, edit the courses/manage/course/delete.html template and add the following code:

{% extends "base.html" %}
{% block title %}Delete course{% endblock %}
{% block content %}
  <h1>Delete course "{{ object.title }}"</h1>
  <div class="module">
    <form action="" method="post">
      {% csrf_token %}
      <p>Are you sure you want to delete "{{ object }}"?</p>
      <input type="submit" value="Confirm">
    </form>
  </div>
{% endblock %}

This is the template for the CourseDeleteView view. This view inherits from DeleteView, provided by Django, which expects user confirmation to delete an object.

Open the course list in the browser and click the Delete link of your course. You should see the following confirmation page:

Figure 10.8: The delete course confirmation page

Click the CONFIRM button. The course will be deleted and you will be redirected to the course list page again.

Instructors can now create, edit, and delete courses. Next, you need to provide them with a CMS to add course modules and their contents. You will start by managing course modules.

Managing course modules and their contents

You are going to build a system to manage course modules and their contents. You will need to build forms that can be used for managing multiple modules per course and different types of content for each module. Both modules and their contents will have to follow a specific order and you should be able to reorder them using the CMS.

Using formsets for course modules

Django comes with an abstraction layer to work with multiple forms on the same page. These groups of forms are known as formsets. Formsets manage multiple instances of a certain Form or ModelForm. All forms are submitted at once and the formset takes care of the initial number of forms to display, limiting the maximum number of forms that can be submitted and validating all the forms.

Formsets include an is_valid() method to validate all forms at once. You can also provide initial data for the forms and specify how many additional empty forms to display. You can learn more about formsets at https://docs.djangoproject.com/en/3.0/topics/forms/formsets/ and about model formsets at https://docs.djangoproject.com/en/3.0/topics/forms/modelforms/#model-formsets.

Since a course is divided into a variable number of modules, it makes sense to use formsets to manage them. Create a forms.py file in the courses application directory and add the following code to it:

from django import forms
from django.forms.models import inlineformset_factory
from .models import Course, Module
ModuleFormSet = inlineformset_factory(Course,
                                      Module,
                                      fields=['title',
                                              'description'],
                                      extra=2,
                                      can_delete=True)

This is the ModuleFormSet formset. You build it using the inlineformset_factory() function provided by Django. Inline formsets are a small abstraction on top of formsets that simplify working with related objects. This function allows you to build a model formset dynamically for the Module objects related to a Course object.

You use the following parameters to build the formset:

  • fields: The fields that will be included in each form of the formset.
  • extra: Allows you to set the number of empty extra forms to display in the formset.
  • can_delete: If you set this to True, Django will include a Boolean field for each form that will be rendered as a checkbox input. It allows you to mark the objects that you want to delete.

Edit the views.py file of the courses application and add the following code to it:

from django.shortcuts import redirect, get_object_or_404
from django.views.generic.base import TemplateResponseMixin, View
from .forms import ModuleFormSet
class CourseModuleUpdateView(TemplateResponseMixin, View):
    template_name = 'courses/manage/module/formset.html'
    course = None
    def get_formset(self, data=None):
        return ModuleFormSet(instance=self.course,
                             data=data)
    def dispatch(self, request, pk):
        self.course = get_object_or_404(Course,
                                        id=pk,
                                        owner=request.user)
        return super().dispatch(request, pk)
    def get(self, request, *args, **kwargs):
        formset = self.get_formset()
        return self.render_to_response({'course': self.course,
                                        'formset': formset})
    def post(self, request, *args, **kwargs):
        formset = self.get_formset(data=request.POST)
        if formset.is_valid():
            formset.save()
            return redirect('manage_course_list')
        return self.render_to_response({'course': self.course,
                                        'formset': formset})

The CourseModuleUpdateView view handles the formset to add, update, and delete modules for a specific course. This view inherits from the following mixins and views:

  • TemplateResponseMixin: This mixin takes charge of rendering templates and returning an HTTP response. It requires a template_name attribute that indicates the template to be rendered and provides the render_to_response() method to pass it a context and render the template.
  • View: The basic class-based view provided by Django.

In this view, you implement the following methods:

  • get_formset(): You define this method to avoid repeating the code to build the formset. You create a ModuleFormSet object for the given Course object with optional data.
  • dispatch(): This method is provided by the View class. It takes an HTTP request and its parameters and attempts to delegate to a lowercase method that matches the HTTP method used. A GET request is delegated to the get() method and a POST request to post(), respectively. In this method, you use the get_object_or_404() shortcut function to get the Course object for the given id parameter that belongs to the current user. You include this code in the dispatch() method because you need to retrieve the course for both GET and POST requests. You save it into the course attribute of the view to make it accessible to other methods.
  • get(): Executed for GET requests. You build an empty ModuleFormSet formset and render it to the template together with the current Course object using the render_to_response() method provided by TemplateResponseMixin.
  • post(): Executed for POST requests.

    In this method, you perform the following actions:

    1. You build a ModuleFormSet instance using the submitted data.
    2. You execute the is_valid() method of the formset to validate all of its forms.
    3. If the formset is valid, you save it by calling the save() method. At this point, any changes made, such as adding, updating, or marking modules for deletion, are applied to the database. Then, you redirect users to the manage_course_list URL. If the formset is not valid, you render the template to display any errors instead.

Edit the urls.py file of the courses application and add the following URL pattern to it:

path('<pk>/module/',
     views.CourseModuleUpdateView.as_view(),
     name='course_module_update'),

Create a new directory inside the courses/manage/ template directory and name it module. Create a courses/manage/module/formset.html template and add the following code to it:

{% extends "base.html" %}
{% block title %}
  Edit "{{ course.title }}"
{% endblock %}
{% block content %}
  <h1>Edit "{{ course.title }}"</h1>
  <div class="module">
    <h2>Course modules</h2>
    <form method="post">
      {{ formset }}
      {{ formset.management_form }}
      {% csrf_token %}
      <input type="submit" value="Save modules">
    </form>
  </div>
{% endblock %}

In this template, you create a <form> HTML element in which you include formset. You also include the management form for the formset with the variable {{ formset.management_form }}. The management form includes hidden fields to control the initial, total, minimum, and maximum number of forms. You can see that it's very easy to create a formset.

Edit the courses/manage/course/list.html template and add the following link for the course_module_update URL below the course edit and delete links:

<a href="{% url "course_edit" course.id %}">Edit</a>
<a href="{% url "course_delete" course.id %}">Delete</a>
<a href="{% url "course_module_update" course.id %}">Edit modules</a>

You have included the link to edit the course modules.

Open http://127.0.0.1:8000/course/mine/ in your browser. Create a course and click the Edit modules link for it. You should see a formset, as follows:

Figure 10.9: The course edit page, including the formset for course modules

The formset includes a form for each Module object contained in the course. After these, two empty extra forms are displayed because you set extra=2 for ModuleFormSet. When you save the formset, Django will include another two extra fields to add new modules.

Adding content to course modules

Now, you need a way to add content to course modules. You have four different types of content: text, video, image, and file. You could consider creating four different views to create content, with one for each model. However, you are going to take a more generic approach and create a view that handles creating or updating the objects of any content model.

Edit the views.py file of the courses application and add the following code to it:

from django.forms.models import modelform_factory
from django.apps import apps
from .models import Module, Content
class ContentCreateUpdateView(TemplateResponseMixin, View):
    module = None
    model = None
    obj = None
    template_name = 'courses/manage/content/form.html'
    def get_model(self, model_name):
        if model_name in ['text', 'video', 'image', 'file']:
            return apps.get_model(app_label='courses',
                                  model_name=model_name)
        return None
    def get_form(self, model, *args, **kwargs):
        Form = modelform_factory(model, exclude=['owner',
                                                 'order',
                                                 'created',
                                                 'updated'])
        return Form(*args, **kwargs)
    def dispatch(self, request, module_id, model_name, id=None):
        self.module = get_object_or_404(Module,
                                       id=module_id,
                                       course__owner=request.user)
        self.model = self.get_model(model_name)
        if id:
            self.obj = get_object_or_404(self.model,
                                         id=id,
                                         owner=request.user)
        return super().dispatch(request, module_id, model_name, id)

This is the first part of ContentCreateUpdateView. It will allow you to create and update different models' contents. This view defines the following methods:

  • get_model(): Here, you check that the given model name is one of the four content models: Text, Video, Image, or File. Then, you use Django's apps module to obtain the actual class for the given model name. If the given model name is not one of the valid ones, you return None.
  • get_form(): You build a dynamic form using the modelform_factory() function of the form's framework. Since you are going to build a form for the Text, Video, Image, and File models, you use the exclude parameter to specify the common fields to exclude from the form and let all other attributes be included automatically. By doing so, you don't have to know which fields to include depending on the model.
  • dispatch(): It receives the following URL parameters and stores the corresponding module, model, and content object as class attributes:
    • module_id: The ID for the module that the content is/will be associated with.
    • model_name: The model name of the content to create/update.
    • id: The ID of the object that is being updated. It's None to create new objects.

Add the following get() and post() methods to ContentCreateUpdateView:

def get(self, request, module_id, model_name, id=None):
    form = self.get_form(self.model, instance=self.obj)
    return self.render_to_response({'form': form,
                                    'object': self.obj})
def post(self, request, module_id, model_name, id=None):
    form = self.get_form(self.model,
                         instance=self.obj,
                         data=request.POST,
                         files=request.FILES)
    if form.is_valid():
        obj = form.save(commit=False)
        obj.owner = request.user
        obj.save()
        if not id:
            # new content
            Content.objects.create(module=self.module,
                                   item=obj)
        return redirect('module_content_list', self.module.id)
    return self.render_to_response({'form': form,
                                    'object': self.obj})

These methods are as follows:

  • get(): Executed when a GET request is received. You build the model form for the Text, Video, Image, or File instance that is being updated. Otherwise, you pass no instance to create a new object, since self.obj is None if no ID is provided.
  • post(): Executed when a POST request is received. You build the model form, passing any submitted data and files to it. Then, you validate it. If the form is valid, you create a new object and assign request.user as its owner before saving it to the database. You check for the id parameter. If no ID is provided, you know the user is creating a new object instead of updating an existing one. If this is a new object, you create a Content object for the given module and associate the new content with it.

Edit the urls.py file of the courses application and add the following URL patterns to it:

path('module/<int:module_id>/content/<model_name>/create/',
     views.ContentCreateUpdateView.as_view(),
     name='module_content_create'),
path('module/<int:module_id>/content/<model_name>/<id>/',
     views.ContentCreateUpdateView.as_view(),
     name='module_content_update'),

The new URL patterns are as follows:

  • module_content_create: To create new text, video, image, or file objects and add them to a module. It includes the module_id and model_name parameters. The first one allows linking the new content object to the given module. The latter specifies the content model to build the form for.
  • module_content_update: To update an existing text, video, image, or file object. It includes the module_id and model_name parameters and an id parameter to identify the content that is being updated.

Create a new directory inside the courses/manage/ template directory and name it content. Create the template courses/manage/content/form.html and add the following code to it:

{% extends "base.html" %}
{% block title %}
  {% if object %}
    Edit content "{{ object.title }}"
  {% else %}
    Add new content
  {% endif %}
{% endblock %}
{% block content %}
  <h1>
    {% if object %}
      Edit content "{{ object.title }}"
    {% else %}
      Add new content
    {% endif %}
  </h1>
  <div class="module">
    <h2>Course info</h2>
    <form action="" method="post" enctype="multipart/form-data">
      {{ form.as_p }}
      {% csrf_token %}
      <p><input type="submit" value="Save content"></p>
    </form>
  </div>
{% endblock %}

This is the template for the ContentCreateUpdateView view. In this template, you check whether an object variable is in the context. If object exists in the context, you are updating an existing object. Otherwise, you are creating a new object.

You include enctype="multipart/form-data" in the <form> HTML element because the form contains a file upload for the File and Image content models.

Run the development server, open http://127.0.0.1:8000/course/mine/, click Edit modules for an existing course, and create a module. Open the Python shell with the command python manage.py shell and obtain the ID of the most recently created module, as follows:

>>> from courses.models import Module
>>> Module.objects.latest('id').id
6

Run the development server and open http://127.0.0.1:8000/course/module/6/content/image/create/ in your browser, replacing the module ID with the one you obtained before. You will see the form to create an Image object, as follows:

Figure 10.10: The course add image content form

Don't submit the form yet. If you try to do so, it will fail because you haven't defined the module_content_list URL yet. You are going to create it in a bit.

You also need a view for deleting content. Edit the views.py file of the courses application and add the following code:

class ContentDeleteView(View):
    def post(self, request, id):
        content = get_object_or_404(Content,
                               id=id,
                               module__course__owner=request.user)
        module = content.module
        content.item.delete()
        content.delete()
        return redirect('module_content_list', module.id)

The ContentDeleteView class retrieves the Content object with the given ID. It deletes the related Text, Video, Image, or File object. Finally, it deletes the Content object and redirects the user to the module_content_list URL to list the other contents of the module.

Edit the urls.py file of the courses application and add the following URL pattern to it:

path('content/<int:id>/delete/',
     views.ContentDeleteView.as_view(),
     name='module_content_delete'),

Now instructors can create, update, and delete content easily.

Managing modules and their contents

You have built views to create, edit, and delete course modules and their contents. Next, you need a view to display all modules for a course and list the contents of a specific module.

Edit the views.py file of the courses application and add the following code to it:

class ModuleContentListView(TemplateResponseMixin, View):
    template_name = 'courses/manage/module/content_list.html'
    def get(self, request, module_id):
        module = get_object_or_404(Module,
                                   id=module_id,
                                   course__owner=request.user)
        return self.render_to_response({'module': module})

This is the ModuleContentListView view. This view gets the Module object with the given ID that belongs to the current user and renders a template with the given module.

Edit the urls.py file of the courses application and add the following URL pattern to it:

path('module/<int:module_id>/',
     views.ModuleContentListView.as_view(),
     name='module_content_list'),

Create a new template inside the templates/courses/manage/module/ directory and name it content_list.html. Add the following code to it:

{% extends "base.html" %}
{% block title %}
  Module {{ module.order|add:1 }}: {{ module.title }}
{% endblock %}
{% block content %}
{% with course=module.course %}
  <h1>Course "{{ course.title }}"</h1>
  <div class="contents">
    <h3>Modules</h3>
    <ul id="modules">
      {% for m in course.modules.all %}
        <li data-id="{{ m.id }}" {% if m == module %}
         class="selected"{% endif %}>
          <a href="{% url "module_content_list" m.id %}">
            <span>
              Module <span class="order">{{ m.order|add:1 }}</span>
            </span>
            <br>
            {{ m.title }}
          </a>
        </li>
      {% empty %}
        <li>No modules yet.</li>
      {% endfor %}
    </ul>
    <p><a href="{% url "course_module_update" course.id %}">
    Edit modules</a></p>
  </div>
  <div class="module">
    <h2>Module {{ module.order|add:1 }}: {{ module.title }}</h2>
    <h3>Module contents:</h3>
    <div id="module-contents">
      {% for content in module.contents.all %}
        <div data-id="{{ content.id }}">
          {% with item=content.item %}
            <p>{{ item }}</p>
            <a href="#">Edit</a>
            <form action="{% url "module_content_delete" content.id %}"
             method="post">
              <input type="submit" value="Delete">
              {% csrf_token %}
            </form>
          {% endwith %}
        </div>
      {% empty %}
        <p>This module has no contents yet.</p>
      {% endfor %}
    </div>
    <h3>Add new content:</h3>
    <ul class="content-types">
      <li><a href="{% url "module_content_create" module.id "text" %}">
      Text</a></li>
      <li><a href="{% url "module_content_create" module.id "image" %}">
      Image</a></li>
      <li><a href="{% url "module_content_create" module.id "video" %}">
      Video</a></li>
      <li><a href="{% url "module_content_create" module.id "file" %}">
      File</a></li>
    </ul>
  </div>
{% endwith %}
{% endblock %}

Make sure that no template tag is split into multiple lines.

This is the template that displays all modules for a course and the contents of the selected module. You iterate over the course modules to display them in a sidebar. You iterate over a module's contents and access content.item to get the related Text, Video, Image, or File object. You also include links to create new text, video, image, or file content.

You want to know which type of object each of the item objects is: Text, Video, Image, or File. You need the model name to build the URL to edit the object. Besides this, you could display each item in the template differently based on the type of content it is. You can get the model name for an object from the model's Meta class by accessing the object's _meta attribute. Nevertheless, Django doesn't allow accessing variables or attributes starting with an underscore in templates to prevent retrieving private attributes or calling private methods. You can solve this by writing a custom template filter.

Create the following file structure inside the courses application directory:

templatetags/
    __init__.py
    course.py

Edit the course.py module and add the following code to it:

from django import template
register = template.Library()
@register.filter
def model_name(obj):
    try:
        return obj._meta.model_name
    except AttributeError:
        return None

This is the model_name template filter. You can apply it in templates as object|model_name to get the model name for an object.

Edit the templates/courses/manage/module/content_list.html template and add the following line below the {% extends %} template tag:

{% load course %}

This will load the course template tags. Then, find the following lines:

<p>{{ item }}</p>
<a href="#">Edit</a>

Replace them with the following ones:

<p>{{ item }} ({{ item|model_name }})</p>
<a href="{% url "module_content_update" module.id item|model_name item.id %}">
  Edit
</a>

In the preceding code, you display the item model name in the template and also use the model name to build the link to edit the object.

Edit the courses/manage/course/list.html template and add a link to the module_content_list URL, like this:

<a href="{% url "course_module_update" course.id %}">Edit modules</a>
{% if course.modules.count > 0 %}
  <a href="{% url "module_content_list" course.modules.first.id %}">
  Manage contents</a>
{% endif %}

The new link allows users to access the contents of the first module of the course, if there are any.

Stop the development server and run it again using the command python manage.py runserver. By stopping and running the development server, you make sure that the course template tags file gets loaded.

Open http://127.0.0.1:8000/course/mine/ and click the Manage contents link for a course that contains at least one module. You will see a page like the following one:

Figure 10.11: The page to manage course module contents

When you click on a module in the left sidebar, its contents are displayed in the main area. The template also includes links to add new text, video, image, or file content for the module being displayed.

Add a couple of different types of content to the module and take a look at the result. Module contents will appear below Module contents:

Figure 10.12: Managing different module contents

Reordering modules and their contents

You need to provide a simple way to reorder course modules and their contents. You will use a JavaScript drag-and-drop widget to let your users reorder the modules of a course by dragging them. When users finish dragging a module, you will launch an asynchronous request (AJAX) to store the new module order.

Using mixins from django-braces

django-braces is a third-party module that contains a collection of generic mixins for Django. These mixins provide additional features for class-based views. You can see a list of all mixins provided by django-braces at https://django-braces.readthedocs.io/.

You will use the following mixins of django-braces:

  • CsrfExemptMixin: Used to avoid checking the cross-site request forgery (CSRF) token in the POST requests. You need this to perform AJAX POST requests without having to generate a csrf_token.
  • JsonRequestResponseMixin: Parses the request data as JSON and also serializes the response as JSON and returns an HTTP response with the application/json content type.

Install django-braces via pip using the following command:

pip install django-braces==1.14.0

You need a view that receives the new order of module IDs encoded in JSON. Edit the views.py file of the courses application and add the following code to it:

from braces.views import CsrfExemptMixin, JsonRequestResponseMixin
class ModuleOrderView(CsrfExemptMixin,
                      JsonRequestResponseMixin,
                      View):
    def post(self, request):
        for id, order in self.request_json.items():
            Module.objects.filter(id=id,
                   course__owner=request.user).update(order=order)
        return self.render_json_response({'saved': 'OK'})

This is the ModuleOrderView view.

You can build a similar view to order a module's contents. Add the following code to the views.py file:

class ContentOrderView(CsrfExemptMixin,
                       JsonRequestResponseMixin,
                       View):
    def post(self, request):
        for id, order in self.request_json.items():
            Content.objects.filter(id=id,
                       module__course__owner=request.user) 
                       .update(order=order)
        return self.render_json_response({'saved': 'OK'})

Now, edit the urls.py file of the courses application and add the following URL patterns to it:

path('module/order/',
     views.ModuleOrderView.as_view(),
     name='module_order'),
path('content/order/',
     views.ContentOrderView.as_view(),
     name='content_order'),

Finally, you need to implement the drag-and-drop functionality in the template. You will use the jQuery UI library for this. jQuery UI is built on top of jQuery and it provides a set of interface interactions, effects, and widgets. You will use its sortable element. First, you need to load jQuery UI in the base template. Open the base.html file located in the templates/ directory of the courses application, and add jQuery UI below the script to load jQuery, as follows:

<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>

You load the jQuery UI library just below the jQuery framework. Next, edit the courses/manage/module/content_list.html template and add the following code to it at the bottom of the template:

{% block domready %}
  $('#modules').sortable({
    stop: function(event, ui) {
      modules_order = {};
      $('#modules').children().each(function(){
        // update the order field
        $(this).find('.order').text($(this).index() + 1);
        // associate the module's id with its order
        modules_order[$(this).data('id')] = $(this).index();
      });
      $.ajax({
        type: 'POST',
        url: '{% url "module_order" %}',
        contentType: 'application/json; charset=utf-8',
        dataType: 'json',
        data: JSON.stringify(modules_order)
      });
    }
  });

  $('#module-contents').sortable({
    stop: function(event, ui) {
      contents_order = {};
      $('#module-contents').children().each(function(){
        // associate the module's id with its order
        contents_order[$(this).data('id')] = $(this).index();
      });

      $.ajax({
        type: 'POST',
        url: '{% url "content_order" %}',
        contentType: 'application/json; charset=utf-8',
        dataType: 'json',
        data: JSON.stringify(contents_order),
      });
    }
  });
{% endblock %}

This JavaScript code is in the {% block domready %} block and therefore it will be included in the $(document).ready() event of jQuery that you defined in the base.html template. This guarantees that your JavaScript code will be executed once the page has been loaded.

You define a sortable element for the module list in the sidebar and a different one for the module contents list. Both work in a similar manner.

In this code, you perform the following tasks:

  1. You define a sortable element for the modules HTML element. Remember that you use #modules, since jQuery uses CSS notation for selectors.
  2. You specify a function for the stop event. This event is triggered every time the user finishes sorting an element.
  3. You create an empty modules_order dictionary. The keys for this dictionary will be the module IDs, and the values will be the assigned order for each module.
  4. You iterate over the #module children elements. You recalculate the displayed order for each module and get its data-id attribute, which contains the module's ID. You add the ID as the key of the modules_order dictionary and the new index of the module as the value.
  5. You launch an AJAX POST request to the content_order URL, including the serialized JSON data of modules_order in the request. The corresponding ModuleOrderView takes care of updating the order of the modules.

The sortable element to order module contents is quite similar to this one. Go back to your browser and reload the page. Now you will be able to click and drag both modules and their contents to reorder them like the following example:

Figure 10.13: Reordering modules with the drag-and-drop functionality

Great! Now you can reorder both course modules and module contents.

Summary

In this chapter, you learned how to use fixtures to provide initial data for models. By using model inheritance, you created a versatile system to manage different types of content for the course modules. You implemented a custom model field to order objects. You also discovered how to use class-based views and mixins. You worked with groups and permissions to restrict access to your views. Finally, you used formsets to manage course modules, and you built a drag-and-drop functionality with jQuery UI to reorder modules and their contents.

In the next chapter, you will create a student registration system. You will also render different kinds of content, and you will learn how to work with Django's cache framework.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset