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