Building a Custom Django Admin Form


I recently needed an easy way to bulk upload data to one of my database models from the Django admin.  I didn't want this functionality accessible to users so it didn't make sense to build the CSV file import into the main application.  The only other place on the app where this form could live is the Django admin (at least for now).

There is a lot that can be done with the Django admin but I don't typically believe in building the Django admin as a replacement for advanced functionality.  The Django admin is a huge benefit and saves a ton of time but I believe it shines when you use it as a tool to update your database tables.  It's the perfect admin for things like a blog or for your support team but once you start building in heavy customization, I believe it's time to look for another solution.  When making these decisions it's most important to think about your situation and level of comfort as everyone is different.  You should always weight the strengths and weaknesses of the admin before deciding how much customization you want to build.  

Adding Custom Buttons

The first thing I wanted to do was add a button to the top of the admin page that brings you to a Django form.  This form will allow you to select your file and associate the imported data with a another table.  

To add a button like this to your admin page you need to create a new template and extend the template from admin/change_list.html.  From here create a new block using the object-tools-items template block and you can add the button as a list item like below.

{% extends 'admin/change_list.html' %}

{% block object-tools-items %}
    <li>
        <a href="{% url 'import_scans' %}" class="btn btn-high btn-success">Import From CSV File</a>
    </li>
    {{ block.super }}
{% endblock %}

Once you have your template setup, you can associate the template in your admin view by setting the change_list_template to the name of your new admin page.  You can read more about how to do this in the Django docs.

Since I want to create a new admin page that will display a form to upload the CSV file, I'm going to have the button link go to a view that I built that will load the form page.

Custom Django Admin Form

The view itself is pretty straightforward.  There are a few areas in the view that still need to be cleaned up as I found it a bit challenging to subclass the right Django admin views.  The main challenge here is making sure the links in the breadcrumbs bar show up properly as well as the site header and the permissions.  Because of this I basically hard coded these parts of the view, which isn't the best thing to do as it could cause bugs when changes are made in the future.  For now I felt this was good enough and will update it in the future.

There are two classes that I built to make the view work.  The first is the form that I'll be using to upload my CSV file and process the file itself.  The view I'm using is a FormView and I'm going to put the data processing logic on a method in the form and call this from the views is_valid() method.  The logic is very simple, all we're doing is reading the CSV file and adding the values in the column to a table in the database.  The CSV file is controlled internally so I don't have to worry about different formats or anything like that.  

Below is the form that I'm using for the view.

class CSVImportForm(forms.Form):
    campaign = forms.ModelChoiceField(queryset=Campaign.objects.all())
    csv_file = forms.FileField()

    def import_from_csv(self, campaign, csv_data):
        reader = csv.DictReader(StringIO(csv_data.read().decode('utf-8')), delimiter=';')
        bulk_create_manager = BulkCreateManager(chunk_size=500)
        existing_scans = Scan.objects.by_campaign(campaign.company, campaign.id)

        if existing_scans.exists():
            logger.info('Deleting existing data found for campaign %s', campaign)
            existing_scans.delete()

        for row in reader:
            bulk_create_manager.add(Scan(
                campaign=campaign,
                scan_date=row.get('scandate'),
                timezone=row.get('timezone'),
                anonymized_ip=row.get('anonymizedip'),
                longitude=row['lng'],
                latitude=row['lat']]
            ))
        bulk_create_manager.done()
        logger.info('Successfully imported scan data for campaign %s', campaign)

As you can see the form is pretty straight forward.  I have two fields that I'm using to capture the CSV file.  The first field is called campaign which is a data model I'm using to associate the CSV data with and the second field is the actual FileField that I'll be uploading.  The import_from_csv() method is what I'm using to iterate over the fields in the File and saving them to the database.  The BulkCreateManager allows us to bulk create records instead of inserting them one by one.  It's a great little utility class put together by the CaktusGroup which you can read about here.

The view is going to wire it all together.  

class ImportScansAdminView(SuperUserMixin, FormView):
    form_class = CSVImportForm
    template_name = 'admin/campaigns/import_csv_file.html'
    success_url = '/admin/campaigns/scans/'

    def form_valid(self, form):
        csv_data = form.cleaned_data['csv_file']
        campaign = form.cleaned_data['campaign']

        # Error handling left out for simplicity.
        form.import_from_csv(campaign, csv_data)
        return super().form_valid(form)

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)

        # Manually plugging in context variables needed 
        # to display necessary links and blocks in the 
        # django admin. 
        context['title'] = 'Upload QR Scan CSV File'
        context['site_header'] = 'Corrugated Media Portal Administration'
        context['has_permission'] = True

        return context

There is a little more going on with the view.  I'm associating the above form to the view itself, associating a simple template that will be used to display the form and finally the success_url is redirecting a successful CSV upload back to the admin model list page.  The form_valid() method is calling the import_from_csv() function on the form instance which does the actual data import.

Finally we have the get_context_data() method which I'm using to set the title of the page, site_header and has_permission attribute.  I'm sure there is a cleaner way to do this by inheriting another set of admin views but at this time hard coding has worked for me. Once I have time to clean this up, I'll be sure to update the article.

Template

There are a few minor things we have to do on the template itself.  In order to make the breadcrumbs link show up as they do with the rest of the Django admin, we need to fill in th breadcrumbs block on our template.

{% extends 'admin/base_site.html' %}
{% load i18n admin_urls admin_static admin_modify %}
{% load humanize tz %}

{% block extrastyle %}{{ block.super }}<link rel="stylesheet" type="text/css" href="{% static "admin/css/forms.css" %}">{% endblock %}

{% block breadcrumbs %}
<div class="breadcrumbs">
  <a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
  <a href="{% url 'admin:app_list' app_label='campaigns' %}">Campaigns</a>
  <a href="/_admin_/campaigns/scan/">Scans</a>

  {% trans 'Import scans from CSV' %} {{ original|truncatewords:"18" }}
</div>
{% endblock %}

Once we setup the breadcrumbs we can then setup the rest of our form, just like we would in any other template.  Your template code should look no different than any of the other form templates that you build on your site.

The final step is wiring up the url for this admin view.  Again, we have to stick with admin convention here to ensure things load properly.  I've added the below line to my urls.py file.

path('admin/campaigns/scans/import', ImportScansAdminView.as_view(), name='import_scans'),

And that's it.  I have the button above wired to this URL, which hits the view loading the form and template associated with it.  I now have a custom admin form that allows me to bulk upload data to one of my models from a CSV file.

Conclusion

As I mentioned at the beginning, I don't like to build to many custom Django admin pages, as the admin was really built to help you manage the database tables.  In my experience, customizing the admin cause overly complex code and maintenance headaches.  This is the most customization I'm really comfortable with.  This stays in the paradigm and architecture of the Django admin as it's still interacting with the models themselves.  Since we're really only creating an upload form, there isn't to much logic that's going on behind the scenes which helps keep the maintenance to a minimum.