Django
Django E-learning Platform III

Django E-learning Platform III

Part III: Reordering modules and their contents

We will implement a JavaScript drag-and-drop functionality to let course instructors reorder the modules of a course by dragging them.

To implement this feature, we will use the HTML5 Sortable library, which simplifies the process of creating sortable lists using the native HTML5 Drag and Drop API.

When users finish dragging a module, we will use the JavaScript Fetch API to send an asynchronous HTTP request to the server that stores the new module order.

Using mixins from django-braces

django-braces is third-party module that contains a collection of generic mixins for Django. We will use the following mixins of django-braces:

  • CsrfExemptMixin: Used to avoid checking the Cross-site request forgery (CSRF) token in the POST requests. We need this to perform AJAX POST requests without the need to pass 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:

#terminal
pip install django-braces

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

....................................................
from braces.views import CsrfExemptMixin, JSONRequestResponseMixin

.....................................................
    
#this ModuleOrderView allows us to update the order of course modules
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'})

We can build a similar view to order a module’s contents.

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'),

To implement the drag and drop functionality we need to edit our base.html and use Sortable library of HTML5.

...................................................
        <div id="content">
            {%block content%}
            {%endblock%}
        </div>
        {% block_include_js %}
        {% endblock %}
        <script>
            document.addEventListener('DOMContentLoaded',(event)=>{
                //DOM loaded
                {% block domready %}
                {% endblock %}
            });
        </script>
    </body>
</html>

This new block named include_js will allow us to insert JavaScript files in any template that extends the base.html template.

Now, edit content_list.html.

{% block include_js %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/html5sortable/0.13.3/
html5sortable.min.js"></script>

{% endblock %}
{% block domready %}
var options = {
  method:'POST',
  mode:'same-origin'
}
const moduleOrderUrl = '{% url 'module_order' %}';

sortable('#modules',{
  forcePlaceholderSize:true,
  placeholderClass:'placeholder'
});

{% endblock %}

We loaded the HTML5 sortable library from a public CDN.

We added JavaScript code to the {% block domready%} block that was defined in the event listener for the DOMContentLoaded event in the base.html template. This guarantees that our JS code will be executed once the page has been loaded. With this code, we define the options for the HTTP request to reorder modules.

We define a sortable element for the HTML element with id=”modules”, which is the module list in the sidebar. Remember that we use a CSS selector # to select the element with the given id. When we start dragging an item, the HTML5 Sortable library creates a placeholder item so that we can easily see where the element will be placed.

We set the forcePlaceholderSize option to true, to force the placeholder element to have a height and we use the placeholderClass to define the CSS class for the placeholder element. We use the class named placeholder that is defined in the css/base.css static file loaded in the base.html template.

Output so far

Run the server and visit. http://127.0.0.1:8000/course/module/1/

Edit the domready block of content_list.html template and add the following line of codes.

{% extends "base.html" %}
{% load course %}

{% 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 }} ({{ item|model_name }})</p>
            <a href="{% url "module_content_update" module.id item|model_name item.id %}">
              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 %}

{% block include_js %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/html5sortable/0.13.3/
html5sortable.min.js"></script>

{% endblock %}
{% block domready %}
var options = {
  method:'POST',
  mode:'same-origin'
}
const moduleOrderUrl = '{% url 'module_order' %}';

sortable('#modules',{
  forcePlaceholderSize:true,
  placeholderClass:'placeholder'
})[0].addEventListener('sortupdate',function(e){
  modulesOrder = {};
  var modules = document.querySelectorAll('#modules li');
  modules.forEach(function (module,index){
    //update module index
    modulesOrder[module.dataset.id] = index;
    //update index in HTML element
    module.querySelector('.order').innerHTML = index+1;
    //add new order to the HTTP request options
    options['body']=JSON.stringify(modulesOrder);

    //send HTTP request
    fetch(moduleOrderUrl,options)
  });
});

{% endblock %}

We have created an event listener for the sortupdate event of the sortable element. The sortupdate event is triggred when an element is dropped in a different position. The following tasks are performed in the event function.

  • An empty modulesOrder dictionary is created. The keys for this dictionary will be the module IDs, and the values will contain the index of each module.
  • The listelements of the #modules HTML element are selected with document.querySelectorAll(), using the #modules li CSS selector.
  • forEach() is used to iterate over each list element.
  • The new index for each module is sorted in the modulesOrder dictionary. The ID of each module is retrieved from the HTML data-id attribute by accessing module.dataset.id. We use the ID as the key of the modulesOrder dictionary and the new index of the module as the value
  • The order displayed for each module is updated by selecting the element with the order CSS class. Since the index is zero-based and we want to display a one-based index, we add 1 to index.
  • A key named body is added to the options dictionary with the new order contained in modulesOrder. The JSON.stringify() method converts the JS object into a JSON string. This is the body for the HTTP request to update the module order.
  • The Fetch API is used by creating a fetch() HTTP request to update the module order. The view ModuleOrderView that corresponds to the module_order URL takes care of updating the order of modules.

Now, we can drag and drop modules. When we finish dragging a module, an HTTP request is sent to the module_order URL to update the order of the modules. The latest module order will be update in the database and kept even though we refresh our page.

Output so far

Reorder module contents

Edit the domready block of the content_list.html template and add the following code highlighted in bold.

{% extends "base.html" %}
{% load course %}

{% block title %}
...................................................
{% endwith %}
{% endblock %}

{% block include_js %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/html5sortable/0.13.3/
html5sortable.min.js"></script>

{% endblock %}
{% block domready %}
var options = {
  method:'POST',
  mode:'same-origin'
}
const moduleOrderUrl = '{% url 'module_order' %}';

sortable('#modules',{
  forcePlaceholderSize:true,
  placeholderClass:'placeholder'
})[0].addEventListener('sortupdate',function(e){
  modulesOrder = {};
  var modules = document.querySelectorAll('#modules li');
  modules.forEach(function (module,index){
    //update module index
    modulesOrder[module.dataset.id] = index;
    //update index in HTML element
    module.querySelector('.order').innerHTML = index+1;
    //add new order to the HTTP request options
    options['body']=JSON.stringify(modulesOrder);

    //send HTTP request
    fetch(moduleOrderUrl,options)
  });
});

const contentOrderUrl = '{% url "content_order" %}';
sortable('#module-contents',{
  forcePlaceholderSize:true,
  placeholderClass:'placeholder'
})[0].addEventListener('sortupdate',function(e){
  contentOrder = {};
  var contents = document.querySelectorAll('#module-contents div');
  contents.forEach(function (content,index) {
    //update content index
    contentOrder[content.dataset.id] = index;
    //add new order to the HTTP request options
    options['body'] = JSON.stringify(contentOrder);

    //sent HTTP request
    fetch(contentOrderUrl,options)
  });
});

{% endblock %}

Output for sorting contents

Run the server and visit http://127.0.0.1:8000/course/mine/