Django admin template - grid instead of list

What is the easiest / fastest way to override Django admin form to display grid of elements instead of list? My model includes a field for a 100x100px pic, and it would be easier to see them as a grid of pictures, not a list.

This is not terribly difficult, but it requires you to copy several large chunks of code from the Django library and incorporate them into your own application. Here's the overview:

  • You'll need to copy and then modify several template tags from django.contrib.admin.templatetags.admin_list:
    • django.contrib.admin.templatetags.admin_list.items_for_result
    • django.contrib.admin.templatetags.admin_list.result_list
    • django.contrib.admin.templatetags.admin_list.results
    • django.contrib.admin.templatetags.admin_list.result_list_tag
  • You'll need to copy and then modify a couple of templates:
    • django/contrib/admin/templates/admin/change_list.html
    • django/contrib/admin/templates/admin/change_list_results.html

For purposes of illustrating the solution, I have created an app called spam and a model called SpamPhoto, along with a model admin that is customized to display the image tag. I am using Bootstrap 4 for the grid. There are still some issues you might need to track down and figure out, like getting rid of the checkbox that shows up for each item. But this code should get about 90% of the way there.

Here's an overview of the files that I created/modified, and which you'll find below:

  • spam/models.py: contains the SpamPhoto model
  • spam/admin.py: contains the SpamPhotoAdmin
  • spam/templatetags/spamphoto_admin_list.py: contains the template tag / functions that will render the customized change list
  • spam/templates/admin/spam/spamphoto/change_list.html: this is the main changelist template, and only has a few small changes: it loads our custom template tags, it includes Bootstrap CSS, and it calls a customized template tag (defined below)
  • spam/templates/admin/spam/spamphoto/change_list_results.html: heavy modifications here, getting rid of the table elements and replacing with divs.

So again, this is a LOT of code, and most of it is lifted directly without very many changes. Here we go:

# spam/models.py
from django.db import models

class SpamPhoto(models.Model):
    name = models.CharField(max_length=100)
    image = models.ImageField()
# spam/admin.py
from django.contrib import admin
from django.utils.html import mark_safe


class SpamPhotoAdmin(admin.ModelAdmin):
    list_display = ('name', 'image_tag')
    list_display_links = ('name', 'image_tag')

    def image_tag(self, obj):
        return mark_safe(f'<img class="img-fluid" src="{obj.image.url}" />')
# spam/templatetags/spamphoto_admin_list.py
from django import template
# Note: you should import these from their "correct" locations! This is cheap/dirty:
from django.contrib.admin.templatetags.admin_list import (ResultList, result_headers, result_hidden_fields,
                                                          _coerce_field_name, lookup_field, ObjectDoesNotExist,
                                                          display_for_field, datetime, display_for_value, models,
                                                          mark_safe, NoReverseMatch, add_preserved_filters, format_html)
from django.contrib.admin.templatetags.base import InclusionAdminNode

register = template.Library()


def spamphoto_items_for_result(cl, result, form):
    """
    Cloned from django.contrib.admin.templatetags.admin_list.items_for_result

    The only modification is found at the very end of this function, where the HTML
    tags are changed from `td` to `div`
    """

    def link_in_col(is_first, field_name, cl):
        if cl.list_display_links is None:
            return False
        if is_first and not cl.list_display_links:
            return True
        return field_name in cl.list_display_links

    first = True
    pk = cl.lookup_opts.pk.attname
    for field_index, field_name in enumerate(cl.list_display):
        empty_value_display = cl.model_admin.get_empty_value_display()
        row_classes = ['field-%s' % _coerce_field_name(field_name, field_index)]
        try:
            f, attr, value = lookup_field(field_name, result, cl.model_admin)
        except ObjectDoesNotExist:
            result_repr = empty_value_display
        else:
            empty_value_display = getattr(attr, 'empty_value_display', empty_value_display)
            if f is None or f.auto_created:
                if field_name == 'action_checkbox':
                    row_classes = ['action-checkbox']
                boolean = getattr(attr, 'boolean', False)
                result_repr = display_for_value(value, empty_value_display, boolean)
                if isinstance(value, (datetime.date, datetime.time)):
                    row_classes.append('nowrap')
            else:
                if isinstance(f.remote_field, models.ManyToOneRel):
                    field_val = getattr(result, f.name)
                    if field_val is None:
                        result_repr = empty_value_display
                    else:
                        result_repr = field_val
                else:
                    result_repr = display_for_field(value, f, empty_value_display)
                if isinstance(f, (models.DateField, models.TimeField, models.ForeignKey)):
                    row_classes.append('nowrap')
        row_class = mark_safe(' class="%s"' % ' '.join(row_classes))
        # If list_display_links not defined, add the link tag to the first field
        if link_in_col(first, field_name, cl):
            table_tag = 'th' if first else 'td'
            first = False

            # Display link to the result's change_view if the url exists, else
            # display just the result's representation.
            try:
                url = cl.url_for_result(result)
            except NoReverseMatch:
                link_or_text = result_repr
            else:
                url = add_preserved_filters({'preserved_filters': cl.preserved_filters, 'opts': cl.opts}, url)
                # Convert the pk to something that can be used in Javascript.
                # Problem cases are non-ASCII strings.
                if cl.to_field:
                    attr = str(cl.to_field)
                else:
                    attr = pk
                value = result.serializable_value(attr)
                link_or_text = format_html(
                    '<a href="{}"{}>{}</a>',
                    url,
                    format_html(
                        ' data-popup-opener="{}"', value
                    ) if cl.is_popup else '',
                    result_repr)

            yield format_html('<{}{}>{}</{}>', table_tag, row_class, link_or_text, table_tag)
        else:
            # By default the fields come from ModelAdmin.list_editable, but if we pull
            # the fields out of the form instead of list_editable custom admins
            # can provide fields on a per request basis
            if (form and field_name in form.fields and not (
                    field_name == cl.model._meta.pk.name and
                    form[cl.model._meta.pk.name].is_hidden)):
                bf = form[field_name]
                result_repr = mark_safe(str(bf.errors) + str(bf))
            # THIS LINE HAS BEEN CHANGED:
            yield format_html('<div{}>{}</div>', row_class, result_repr)
    if form and not form[cl.model._meta.pk.name].is_hidden:
        # THIS LINE HAS BEEN CHANGED:
        yield format_html('<div>{}</div>', form[cl.model._meta.pk.name])


def spamphoto_result_list(cl):
    """
    Cloned from django.contrib.admin.templatetags.admin_list.result_list

    The only change is to the `results` value in the returned dict, where we call `spamphoto_results`
    """
    headers = list(result_headers(cl))
    num_sorted_fields = 0
    for h in headers:
        if h['sortable'] and h['sorted']:
            num_sorted_fields += 1
    return {
        'cl': cl,
        'result_hidden_fields': list(result_hidden_fields(cl)),
        'result_headers': headers,
        'num_sorted_fields': num_sorted_fields,
        # THIS LINE HAS BEEN CHANGED:
        'results': list(spamphoto_results(cl)),
    }


def spamphoto_results(cl):
    """
    Cloned from django.contrib.admin.templatetags.admin_list.results

    The only changes are where we call `spamphoto_items_for_result` instead of `items_for_result`
    """
    if cl.formset:
        for res, form in zip(cl.result_list, cl.formset.forms):
            # THIS LINE HAS BEEN CHANGED:
            yield ResultList(form, spamphoto_items_for_result(cl, res, form))
    else:
        for res in cl.result_list:
            # THIS LINE HAS BEEN CHANGED:
            yield ResultList(None, spamphoto_items_for_result(cl, res, None))


@register.tag(name='spamphoto_result_list')
def spamphoto_result_list_tag(parser, token):
    """
    Cloned from django.contrib.admin.templatetags.admin_list.result_list_tag

    The only change is to the `func` param, which now uses out custom `spamphoto_result_list` function
    """
    return InclusionAdminNode(
        parser, token,
        # THIS LINE HAS BEEN CHANGED:
        func=spamphoto_result_list,
        template_name='change_list_results.html',
        takes_context=False,
    )
# spam/templates/admin/spam/spamphoto/change_list.html
{% extends "admin/base_site.html" %}
{% load i18n admin_urls static admin_list %}
<!-- ADDED THIS LINE / INCLUDING OUR CUSTOM TEMPLATE TAGS -->
{% load spamphoto_admin_list %}

{% block extrastyle %}
  {{ block.super }}
  <link rel="stylesheet" type="text/css" href="{% static "admin/css/changelists.css" %}">
  {% if cl.formset %}
    <link rel="stylesheet" type="text/css" href="{% static "admin/css/forms.css" %}">
  {% endif %}
  {% if cl.formset or action_form %}
    <script src="{% url 'admin:jsi18n' %}"></script>
  {% endif %}
  {{ media.css }}
  {% if not actions_on_top and not actions_on_bottom %}
    <style>
      #changelist table thead th:first-child {width: inherit}
    </style>
  {% endif %}
  <!-- ADDED THIS LINE / INCLUDING BOOTSTRAP -->
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/css/bootstrap.min.css">
{% endblock %}

{% block extrahead %}
{{ block.super }}
{{ media.js }}
{% endblock %}

{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} change-list{% endblock %}

{% if not is_popup %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
&rsaquo; <a href="{% url 'admin:app_list' app_label=cl.opts.app_label %}">{{ cl.opts.app_config.verbose_name }}</a>
&rsaquo; {{ cl.opts.verbose_name_plural|capfirst }}
</div>
{% endblock %}
{% endif %}

{% block coltype %}{% endblock %}

{% block content %}
  <div id="content-main">
    {% block object-tools %}
        <ul class="object-tools">
          {% block object-tools-items %}
            {% change_list_object_tools %}
          {% endblock %}
        </ul>
    {% endblock %}
    {% if cl.formset and cl.formset.errors %}
        <p class="errornote">
        {% if cl.formset.total_error_count == 1 %}{% translate "Please correct the error below." %}{% else %}{% translate "Please correct the errors below." %}{% endif %}
        </p>
        {{ cl.formset.non_form_errors }}
    {% endif %}
    <div class="module{% if cl.has_filters %} filtered{% endif %}" id="changelist">
      <div class="changelist-form-container">
        {% block search %}{% search_form cl %}{% endblock %}
        {% block date_hierarchy %}{% if cl.date_hierarchy %}{% date_hierarchy cl %}{% endif %}{% endblock %}

        <form id="changelist-form" method="post"{% if cl.formset and cl.formset.is_multipart %} enctype="multipart/form-data"{% endif %} novalidate>{% csrf_token %}
        {% if cl.formset %}
          <div>{{ cl.formset.management_form }}</div>
        {% endif %}

        {% block result_list %}
          {% if action_form and actions_on_top and cl.show_admin_actions %}{% admin_actions %}{% endif %}
          <!-- THIS LINE HAS BEEN CHANGED: -->
          {% spamphoto_result_list cl %}
          {% if action_form and actions_on_bottom and cl.show_admin_actions %}{% admin_actions %}{% endif %}
        {% endblock %}
        {% block pagination %}{% pagination cl %}{% endblock %}
        </form>
      </div>
      {% block filters %}
        {% if cl.has_filters %}
          <div id="changelist-filter">
            <h2>{% translate 'Filter' %}</h2>
            {% if cl.has_active_filters %}<h3 id="changelist-filter-clear">
              <a href="{{ cl.clear_all_filters_qs }}">&#10006; {% translate "Clear all filters" %}</a>
            </h3>{% endif %}
            {% for spec in cl.filter_specs %}{% admin_list_filter cl spec %}{% endfor %}
          </div>
        {% endif %}
      {% endblock %}
    </div>
  </div>
{% endblock %}
# spam/templates/admin/spam/spamphoto/change_list_results.html
{% load i18n static %}
{% if result_hidden_fields %}
  <div class="hiddenfields">{# DIV for HTML validation #}
    {% for item in result_hidden_fields %}{{ item }}{% endfor %}
  </div>
{% endif %}
{% if results %}
  <div class="container">
    <div class="row">
      {% for result in results %}
        {% if result.form and result.form.non_field_errors %}
          <div>{{ result.form.non_field_errors }}</div>
        {% endif %}
        <div class="col-sm-2">{% for item in result %}{{ item }}{% endfor %}</div>
      {% endfor %}
    </div>
  </div>
{% endif %}
Back to Top