Mimicking Django Admin Change Form Styling in a Custom Form

I needed to create a custom form in Django admin for batch creating record by uploading a CSV file. The example I use here is upload a CSV of product keys for an existing product in the database. I wanted the custom form styling to match the default Django admin form styling. I’m new to Django so there may be a better way to do it, but this worked for me.

The first thing is to create a custom form class. Mine is pretty simple, a CSV file of product keys and a link to the product. We’re deriving from forms.Form since this is a custom form and it won’t match up to the model fields.

# storefront/products/admin.py

from django import forms
from products.models import Product
from products.models import ProductKey

class ProductKeyUploadForm(forms.Form):
    product = forms.ModelChoiceField(queryset=Product.objects.all())
    file = forms.FileField(label='Product Keys')

The next thing we need to do is add a new URL for this page to our ModelAdmin instance.

# storefront/products/admin.py

from django.contrib import admin

class ProductKeyAdmin(admin.ModelAdmin):
    def get_urls(self):
        urls = super(ProductKeyAdmin, self).get_urls()
        add_urls = [
            url(
                r'^upload/$',
                self.admin_site.admin_view(self.upload),
                name='products_productkey_upload'
            )
        ]
        return add_urls + urls

    def upload(self, request):
        # Coming soon
        pass

admin.site.register(ProductKey, ProductKeyAdmin)

When creating the url it’s important to decorate the view with self.admin_site.admin_view() to incorporate admin permissions. The name= parameters provides a name for linking to the page with the url() function. The Django standard is ‘app_model_action’. We’ll see how to link to this in view template.

Now in the view template file we to extend admin/base_site.html, but use the form styling of admin/change_form.html.

<!-- storefront/products/templates/admin/products/productkey/upload.html -->

{% extends "admin/base_site.html" %}
{% load i18n admin_urls admin_static %}

{% 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>
        &rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ app_label|capfirst|escape }}</a>
        &rsaquo; {% if has_change_permission %}<a href="{% url opts|admin_urlname:'changelist' %}">
        {{ opts.verbose_name_plural|capfirst }}</a>{% else %}{{ opts.verbose_name_plural|capfirst }}{% endif %}
        &rsaquo; {% trans 'Upload' %}
    </div>
{% endblock %}

{% block content %}
    <form action="{% url opts|admin_urlname:'upload' %}" method="post" id="productkey_upload_form"
          {% if form.is_multipart %}enctype="multipart/form-data"{% endif %}>
        {% csrf_token %}
        <div>
            {% for fieldset in adminform %}
                {% include "admin/includes/fieldset.html" %}
            {% endfor %}
        </div>
        <div class="submit-row">
            <input type="submit" value="Submit" class="default"/>
        </div>
    </form>
{% endblock %}

This adds the forms CSS that’s used by change_form.html.

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

This block updates the breadcrumbs like change_form.html, but adds ‘Upload’ as the last crumb.

{% block breadcrumbs %}
    <div class="breadcrumbs">
        <a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
        &rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ app_label|capfirst|escape }}</a>
        &rsaquo; {% if has_change_permission %}<a href="{% url opts|admin_urlname:'changelist' %}">
        {{ opts.verbose_name_plural|capfirst }}</a>{% else %}{{ opts.verbose_name_plural|capfirst }}{% endif %}
        &rsaquo; {% trans 'Upload' %}
    </div>
{% endblock %}

This section adds the form. The ‘admin/includes/fieldset.html’ template outputs the form fields in the same format as admin/change_form.html. Notice the admin_urlname:'upload', that translates into ‘admin:products_productkey_upload’ and matches the url we added. The one thing we are missing is the adminform object. We need to create that in our view method.

{% block content %}
    <form action="{% url opts|admin_urlname:'upload' %}" method="post" id="productkey_upload_form"
          {% if form.is_multipart %}enctype="multipart/form-data"{% endif %}>
        {% csrf_token %}
        <div>
            {% for fieldset in adminform %}
                {% include "admin/includes/fieldset.html" %}
            {% endfor %}
        </div>
        <div class="submit-row">
            <input type="submit" value="Submit" class="default"/>
        </div>
    </form>
{% endblock %}

In our view method we need to mimic the same view context as the change admin requests.

# storefront/products/admin.py

from django.shortcuts import render
from django.contrib.admin import helpers

class ProductKeyAdmin(admin.ModelAdmin):
    def upload(self, request):
        context = {
            'title': 'Upload Product Keys',
            'app_label': self.model._meta.app_label,
            'opts': self.model._meta,
            'has_change_permission': self.has_change_permission(request)
        }

        # Handle form request
        if request.method == 'POST':
            form = ProductKeyUploadForm(request.POST, request.FILES)
            if form.is_valid():
                # Do CSV processing and create ProductKey records
        else:
            form = ProductKeyUploadForm()
        context['form'] = form

        context['adminform'] = helpers.AdminForm(form, list([(None, {'fields': form.base_fields})]),
                                                 self.get_prepopulated_fields(request))

        return render(request, 'admin/products/productkey/upload.html', context)

admin.site.register(ProductKey, ProductKeyAdmin)

We need to populate the view context with some data used by admin/base.html and in our template to create the breadcrumbs.

        context = {
            'title': 'Upload Product Keys',
            'app_label': self.model._meta.app_label,
            'opts': self.model._meta,
            'has_change_permission': self.has_change_permission(request)
        }

The key piece in mimicking the admin/change_form.html is recreating the adminform object. To do that we use the AdminForm helper and pass it a custom fieldset argument. The fieldset is created from our upload form’s fields rather than the default model form. Reference django.contrib.admin.options for how the adminform is normally created.

        context['adminform'] = helpers.AdminForm(form, list([(None, {'fields': form.base_fields})]),
                                                 self.get_prepopulated_fields(request))

With all those pieces put together you’ll end up with a custom form that has styling just like the default change form. Here’s what my example looks like:
Django Admin Custom Form Styling