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

7 Comments

  1. Dante

    I’m also new to Django and have been looking for a solution like this for a while now. I really don’t get why django does everything for us on the admin site and leaves us alone outside admin. It shouldn’t be too hard to create a form with the same looks of the admin site!

    Thank you!

  2. Thanks for sharing this!

    One improvement tough: The upload method should be decorated with @method_decorator(staff_member_required) else anyone can access the upload form.

  3. Marcio

    Hey, thanks for the article. Found many solutions and this one was the only one that worked.

    Note for people that customized the admin title: you can pass “site_title” and “site_header” in the context, or else it will appear “Django management”

Leave a Reply

Your email address will not be published. Required fields are marked *