A shorter one for today. I had a proof of concept to put together a few weeks ago that required an order list of items which I needed to be able to reorder. Naturally, drag and drop was the obvious choice for the user experience, over for example numerical order dropdowns (YUCK)! This seems simple to start with, but breaking it down you need;

  • A way to persist in Django the order of a list of items,
  • A way to allow a user to reorder an HTML list via drag and drop,
  • A way to detect changes and save the new order into Django.

This tutorial assumes you know the basics of Django, including;

  • A basic project layout,
  • How to add a model to Django Admin,
  • Create and apply a database migration.

Persistence

My proof of concept was to create an exam paper builder, allowing a user to add and order questions to a paper. Therefore my data model roughly looks like;

One question can belong to a paper (but doesn't have too) and a question can belong to many papers. This is a many-to-many relationship. In the simplest of situations Django allows the use of  models.ManyToManyField on its own, however, this doesn't support explicit ordering of items. To achieve this, we need a 'through' model with the ManyToManyField.

from django.db import models

class Paper(models.Model):
    title = models.CharField(max_length=255)

class Question(models.Model):
    description = models.TextField()
    answer = models.TextField()
    papers = models.ManyToManyField(Paper, through='PaperQuestion')

class PaperQuestion(models.Model):
    question = models.ForeignKey(Question, on_delete=models.PROTECT)
    paper = models.ForeignKey(Paper, on_delete=models.PROTECT)
    active = models.BooleanField()
    order = models.IntegerField()
    
    class Meta():
        ordering = ['order',]

PaperQuestion explicitly defines the join between Paper and Question, and importantly, defines an order attribute. If you're following along, create a similar model structure for your needs, and create + apply the migration to your database.

Creating the Drag and Drop interface

I'm assuming you're using django-cookiecutter as the basis of your project (you really should). As a result, you already have a good basis to start off with. You have various options for your drag and drop functionality, some of which will be driven by your UI framework (if you're using one). I wanted something that played nice with Bootstrap 4 (which is bundled with recent django-cookiecutter invocations) and seemed relatively simple. I'm also very lazy at times and ended up going with the first option from Google.

So .... roll up, roll up, marvel in ..... jQuery Sortable. It isn't the flashiest, but it has good browser support, is customisable and the repo has frequent updates. There are plenty of options out there including Shopify's Draggable and SortableJS. Fundamentally, what you choose doesn't particularly matter as long as there is a way to serialize the order of list and send an AJAX request to Django.

{% extends "base.html" %}

{% load static i18n %}

{% block title %}Paper Builder{% endblock %}

{% block extracss %}
<style>
body.dragging, body.dragging * {
  cursor: move !important;
}

.dragged {
  position: absolute;
  opacity: 0.5;
  z-index: 2000;
}

ol.exmaple li:hover {
  cursor: move !important;
}

ol.example li.placeholder {
  position: relative;
  /** More li styles **/
}
ol.example li.placeholder:before {
  position: absolute;
  /** Define arrowhead **/
}

.list-group-item {
  display: list-item;
  list-style-position: inside
}

</style>
{% endblock %}

{% block content %}
<ol class='example list-group'>
    {% for question in questions %}
    <li class="list-group-item" data-id={{question.id}}>{{ question.description }}</li>
    {% endfor %}
</ol>

{% endblock %}


{% block extrajavascript %}

<!-- required for CSRF cookie parsing //-->
<script src="{% static 'js/jquery-sortable.js' %}"></script>
<script src="https://cdn.jsdelivr.net/npm/js-cookie@2/src/js.cookie.min.js"></script>
<script>
// get the Django CSRF Cookie
$(function() {
    var csrftoken = Cookies.get('csrftoken');

function csrfSafeMethod(method) {
    // these HTTP methods do not require CSRF protection
    return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method));
}

// Ensure jQuery AJAX calls set the CSRF header to prevent security errors
$.ajaxSetup({
    beforeSend: function(xhr, settings) {
        if (!csrfSafeMethod(settings.type) && !this.crossDomain) {
            xhr.setRequestHeader("X-CSRFToken", csrftoken);
        }
    }
});

// Make our ordered list with a class of example sortable.
// onDrop (reorder item) make a JSON representation of the list and POST the JSON to the current page
var group = $("ol.example").sortable({
        delay: 500,
        onDrop: function ($item, container, _super) {
            var data = group.sortable("serialize").get();
            var jsonString = JSON.stringify(data, null, ' ');
            _super($item, container);
            $.ajax({
                          type: "POST",
                          data: jsonString,
                          url: ""
                    });
         },
  });
});
</script>

{% endblock %}

We have a simple template loop to print our questions in an <ol> block. The data-id is what will be serialized into our AJAX request. Our JavaScript is pretty simple. It is pretty much the example code from the author's site. It;

  • Parses the Django CSRF cookie (CSRF protection is on by default and I don't want to disable it),
  • Sets up the AJAX handler for jQuery,
  • Sets up our draggable list and event listener for onDrop

Processing the AJAX request

The final piece of the puzzle is the Django View which will both display the question list on GET and process the AJAX on POST.

import json
from django.shortcuts import render
from django.http import JsonResponse, HttpResponse
from django.views import View
from .models import Question, PaperQuestion, Paper
from django.views.decorators.csrf import ensure_csrf_cookie
from django.utils.decorators import method_decorator

class PaperQuestionReorder(View):
    template_name = "paper_question_reorder.tmpl"

    # Ensure we have a CSRF cooke set
    @method_decorator(ensure_csrf_cookie)
    def get(self, request, pk):
        return render(self.request, self.template_name, {'pk': pk, 'questions': Question.objects.filter(paperquestion__paper_id=pk).order_by('paperquestion__order'), 'paper': Paper.objects.get(id=pk)})

    # Process POST AJAX Request
    def post(self, request, pk):
        if request.method == "POST" and request.is_ajax():
            try:
                # Parse the JSON payload
                data = json.loads(request.body)[0]
                # Loop over our list order. The id equals the question id. Update the order and save
                for idx,question in enumerate(data):
                    pq = PaperQuestion.objects.get(paper=pk, question=question['id']) 
                    pq.order = idx + 1
                    pq.save()

            except KeyError:
                HttpResponseServerError("Malformed data!")

            return JsonResponse({"success": True}, status=200)
        else:
            return JsonResponse({"success": False}, status=400)

This is a basic Django View to handle the display and updates of the list. The comments should be self-explanatory of the flow. Our Question order processing loop is the most complex, so let's focus on that. An example list of questions has an HTML representation as so;

<ol class="example list-group">
    <li class="list-group-item" data-id="2">4 divided by 2</li>
    <li class="list-group-item" data-id="1">1+1</li>
    <li class="list-group-item" data-id="3">10 * 10</li>
</ol>

When the list is reordered, an AJAX request is raised and sent to Django with the following JSON payload;

[
 [
  {
   "id": 1
  },
  {
   "id": 3
  },
  {
   "id": 2
  }
 ]
]: 

Processing of the JSON is as follows;

  • Loop over the question IDs in the JSON list,
  • Retrieve the question that equals the current ID,
  • Update the order attribute of the Question's through model to the current loop count + 1.

These 3 pieces should give you a working list. Want to test it's working? Refresh the page or inspect your data through Django Admin or the database directly.

How could this be optimised? The biggest bottleneck I see here is the heavy SQL writes involved here which will scale linearly with the number of items in the list. If the time came, I would buffer changes client side to only save say every 15 seconds, or make the user explicitly save to cut down on overlapping write operations.

Full code for this tutorial is on my GitLab repo - https://gitlab.com/fluffy-clouds-and-lines/drag-and-drop-ajax-lists-with-django

Comment and questions below as ever!