Batch Image Upload with Drag & Drop in the Django Admin

Django    Django1.3    2013-02-12

The assignment: Find a way to implement batch image uploads in the Django admin using a drag and drop interface. For the record, we're using jQuery 1.8, and Bootstrap for some of the styling. Temporary files are being posted to S3, using boto (for more information: http://boto.s3.amazonaws.com/s3_tut.html), while the final uploads on form save are going to MEDIA_ROOT/{upload_to value}.

Luckily, I started with a pretty simple model - for each image, there's an upload field that stores the path, and some metadata around that (we use PIL to determine height and width, and sorl for thumbnails, but I'm not going to cover either of those here):

# models.py

class Image(Content):
    """ Simple model for image file metadata """
    image = models.ImageField(upload_to='images/')
    width = models.IntegerField()
    height = models.IntegerField()
    credit = models.CharField(max_length=255, blank=True)
    display_caption = models.BooleanField(default=True)
    pub_date = models.DateTimeField((u'Publication Date'))

The form is fairly basic as well - note that while I'm listing the handful of metadata fields that I want to capture, I am not including the 'image' field itself:

#forms.py

class MultiImageUploadForm(ModelForm):
    """ This form is only used to handle the uploads """

    class Meta:
        fields = ('credit', 'display_caption', 'pub_date',)
        model = Image

    def __init__(self, *args, **kwargs):
        super(MultiImageUploadForm, self).__init__(*args, **kwargs)
        self.fields['pub_date'].initial = datetime.datetime.now()

The real work happens in the admin.py:

# admin.py

from django.contrib import admin

from .models import Image

class ImageAdmin(admin.ModelAdmin):

    ...

admin.site.register(Image, ImageAdmin)

At the bottom of the ImageAdmin class, this get_urls() override routes requests to two separate methods:

class ImageAdmin(admin.ModelAdmin):

    ...

    def get_urls(self):
        urls = super(ImageAdmin, self).get_urls()
        upload_urls = patterns('',
            url(r'^upload/$', self.admin_site.admin_view(self.multi_image_upload_view), name='images_admin_upload'),
            url(r'^image_upload/$', self.admin_site.admin_view(self.multi_image_upload_post), name='images_admin_post'),
        )
        return upload_urls + urls

The multi_image_upload_post() method handles the asynchronous image upload (when images are dragged into the drop zone), while multi_image_upload_view() method takes care of the main form display and form save.

Here's the where initial form display is set. Note that there are some context variables that Django requires (app_label through has_add_permission) - we're having to set these here because we're extending the admin change_form (you'll see that in the template below) and Django will complain if those values aren't present:

class ImageAdmin(admin.ModelAdmin):

    ...

    def multi_image_upload_view(self, request):
        """ handles the form display and form save """
        context={
            'app_label': self.model._meta.app_label,
            'opts': self.model._meta,
            'add': True,
            'change': False,
            'is_popup': False,
            'save_as': self.save_as,
            'save_on_top': self.save_on_top,
            'has_delete_permission': False,
            'has_change_permission': True,
            'has_add_permission': True,
    
            "STATIC_URL": getattr(settings, "STATIC_URL"),
        }
    
        template="admin/path/to/multi_image_upload.html"
        form_class = forms.MultiImageUploadForm
    
        if request.method == 'POST':
            ...  ## We'll look at the form submit a little later
        else:  # handle get
            form = form_class()
    
        context['form'] = form
    
        return render_to_response(template, context, context_instance=RequestContext(request))

So a GET request to that 'upload' path drops you into the template multi_image_upload.html - let's take a look at that:

{% extends "admin/change_form.html" %}

{% block content %}
<div id="content-main">

    <!-- opts is set in the context in multi_image_upload_view() -->
    <form action="" method="post" id="{{ opts.module_name }}_form" class="form-horizontal">

    {% csrf_token %}

    <div>
      {{ form }}

      <!-- The elements that handle the drag and drop: -->
      <div id="dropzone" class="dropzone" style="visibility: hidden;">
        <p class="instructions">Drop Images Here</p>
      </div>

      <!-- As each image is loaded, the file name will appear here: -->
      <div class="loaded_images">
        <ul class="images_list" id="images_list"></ul>
      </div>

      <!-- The submit button is disabled until there is at least one image loaded: -->
      <div class="actions">
        <input id="id_submit" type="submit" class="btn btn-success" disabled>
      </div>
    </div>

  </form>

</div>

<link href="{{ STATIC_URL }}css/multi_image_upload.css" rel="stylesheet" type="text/css">
<script src="{{ STATIC_URL }}js/multi_image_upload.js"></script>

{% endblock %}

The CSS is very straightforward - the dropzone is defined and styled, and there are a few classes that are applied to the file names and buttons to handle error states and enable/disable submits: https://gist.github.com/mechanicalgirl/4948666

And here's the Javascript - instead of explaining it as a narrative, I've annotated heavily with comments so that you can read the code and follow what it's doing: https://gist.github.com/mechanicalgirl/4946306

(I owe loads of gratitude to Andrew Dupont - this is the first significant amount of Javascript I've ever written, and he helped me wade through it all.)

Let's look at what happens when images are dragged into that dropzone and the XMLHttpRequest is triggered. The images (and the csrfmiddlewaretoken) are posted to '/admin/images/image/image_upload/', where get_urls() routes the request to multi_image_upload_post():

from boto.s3.connection import S3Connection
from boto.s3.key import Key

class ImageAdmin(admin.ModelAdmin):

    ...

    def multi_image_upload_post(self, request):
        """ handles the asynchronous image upload """

        # Set up the boto connection and retrieve the bucket where your images live
        conn = S3Connection(settings.AWS_ACCESS_KEY_ID, settings.AWS_SECRET_ACCESS_KEY)
        bucket = conn.get_bucket('my_bucket_name')

        image_list = []
        if request.method == 'POST':
           if request.FILES:
               # The argument passed to getlist() here - 'file' - is the name of the
               # element we defined when creating the formData in the javascript above
               # (see: formData.append('file', files[i]);)
               for f in request.FILES.getlist('file'):
                   image_list.append(f.name)
                   # Do the temp upload to S3:
                   with tempfile.NamedTemporaryFile() as tmp:
                       for chunk in f.chunks():
                           tmp.write(chunk)
                       tmp.seek(0)
                       k = Key(bucket)
                       # Create a key with the file name
                       k.key = f.name
                       # Assign the tempfile to that key
                       k.set_contents_from_filename(tmp.name)
                   tmp.close()

        /*
          You could also do something with the key names returned from S3, 
          but in this case I'm just returning image names to the template 
          so that they can be used to create hidden fields in the form
          (this will be the xhr.responseText referred to in the dropHandler function)
        */
        image_string = ';'.join([image for image in image_list])
        return HttpResponse(image_string, content_type="text/plain")

Images are dragged into the dropzone, uploaded to S3, and then the image names are returned to the form as hidden input fields.

We still need to submit the main form and handle that request, so let's take another look at multi_image_upload_view():

from django.core.files.uploadedfile import SimpleUploadedFile

class ImageAdmin(admin.ModelAdmin):

    ...

    def multi_image_upload_view(self, request):
        """ handles the form display and form save """
        context={
            'app_label': self.model._meta.app_label,
            'opts': self.model._meta,
            'add': True,
            'change': False,
            'is_popup': False,
            'save_as': self.save_as,
            'save_on_top': self.save_on_top,
            'has_delete_permission': False,
            'has_change_permission': True,
            'has_add_permission': True,
            "STATIC_URL": getattr(settings, "STATIC_URL"),
        }

        template="admin/content/images/image/multi_image_upload.html"
        form_class = forms.MultiImageUploadForm

        # This time around, we're posting the main form:
        if request.method == 'POST':

            image_fields = []

            for key in request.POST:
                value = request.POST[key]
                # When we created the hidden inputs, we named them all with this 'image_' prefix, 
                # so it's easy to identify them in the request and add their values to a list of images
                if key[0:6] == 'image_':
                    image_fields.append(value)

            # If the form was somehow submitted without hidden image fields:
            if not image_fields:
                # You could redirect to a new page or return an error here
                print "no fields in the post prefixed 'image_', return an error"

            # But if all is well, let's get the images from S3:
            else:
                conn = S3Connection(settings.AWS_ACCESS_KEY_ID, settings.AWS_SECRET_ACCESS_KEY)
                bucket = conn.get_bucket('my_bucket_name')

                id_dict = {}
                # For each image in the image list
                for img in image_fields:
                    # 1. Pull it down from S3
                    # 2. Save it as a tempfile
                    # 3. Pass to the form so that upload_to can save
                    name = img
                    # Create a tempfile
                    fh = tempfile.TemporaryFile()
                    # Get the key from S3 that matches the image name
                    key = bucket.get_key(name)
                    # Save the image object to the tempfile
                    key.get_contents_to_file(fh)
                    fh.seek(0)
                    # Create an upload file object
                    newfile = SimpleUploadedFile(key.name, fh.read())
                    form = form_class(request.POST)
                    if form.is_valid():
                        img = form.save(commit=False)
                        # Apply that new file object to the form and save it
                        # - Django will handle the upload from here
                        img.image = newfile
                        img.save()
                        id_dict[name] = img.pk

                context['image_ids'] = id_dict
                # ^^ This will be a dict of image file names + Django ids, 
                # e.g., {'image_file1.jpg': 19832, 'image_file2.jpg': 19833, 'image_file3.jpg': 19834,}

                # On form save, redirect to a template that displays the list of added files
                template="admin/path/to/image/multi_image_success.html"

        else:  # handle get
            form = form_class()

        context['form'] = form

        return render_to_response(template, context, context_instance=RequestContext(request))

To wrap up, here's the 'success' template, which takes the dict of image file names and Django admin object ids and displays them as a list for the user:

{% extends "admin/change_form.html" %}

{% block content %}
<div id="content-main">

  {% if image_ids %}
    {% for key, value in image_ids.items %}
      <li><a href="/admin/images/image/{{ value }}">{{ key }}</a></li>
    {% endfor %}
  {% endif %}

</div>
{% endblock %}