Skip to Content

Technology Blog

Technology Blog

How to asynchronously refresh elements in the Django admin with HTMX

Recently updated on

The client wanted a dropdown select box that, when changed, would trigger the repopulation of a separate multiselect box. In the mock-up below, the contents of the “Tags” picker changes depending on the value of the “Foo” dropdown. The element is refreshed via AJAX, so we avoid reloading the entire page.

Looking online for answers to the question “how do I dynamically refresh content in the Django admin?” turns up roll-your-own JavaScript solutions, possibly with the help of jQuery. Those solutions were great back in grandad’s day, but nowadays all the cool kids are using HTMX!

And HTMX is a great option for this problem. We can get the functionality we want without a single line of… well, let’s say with just a tiny bit of JavaScript. But HTMX will do all the heavy AJAX lifting for us. Let’s see how we can make it happen.

By the way, this post assumes some slight familiarity with HTMX. For example, I will not go into the function of each of the HTMX attributes we use below. However, we only need to understand the basics of HTMX to accomplish this task.

The post also assumes that you have a working knowledge of Django and that you have access to a Django project that you can make changes to. If you have the former but not the latter, create and activate a virtual environment, use “pip” to install a recent version of Django into it, and finally use $ django-admin startproject <my-project> to start a new Django project. Some other management commands you may or may not want to follow up with include:

$ ./manage.py migrate

to create the initial database tables (we’re assuming the file-based “sqlite3” database)

$ ./manage.py createsuperuser

to create a superuser for yourself, if you don’t have one already,

$ ./manage.py startapp foos
$ ./manage.py startapp bars
$ ./manage.py startapp tags

to create the three apps we’ll use in this demonstration. Make sure all three are included in the INSTALLED_APPS setting of your project. While you’re in there, you should probably also set the ALLOWED_HOSTS setting to ["*"] to allow your project to run on your local machine.

Once you’ve got this basic framework in place, continue reading below.

The Models

Ok, so assume for the sake of this blog post that “Foo” and “Bar” are the names of our base models. A “Bar” instance has a foreign key to a “Foo” instance. Finally, we have a third model named “Tag” that can be related to either Foo or Bar. All models are defined in the “models.py” modules of their respectively named apps, like so:

foos/models.py

from django.db import models
from tags.models import Tag


class Foo(models.Model):
    name = models.CharField(max_length=255)
    tags = models.ManyToManyField(Tag)

    def __str__(self):
        return self.name

bars/models.py

from django.db import models
from foos.models import Foo
from tags.models import Tag


class Bar(models.Model):
    name = models.CharField(max_length=255)
    foo = models.ForeignKey(Foo, on_delete=models.CASCADE)
    tags = models.ManyToManyField(Tag, blank=True)

    def __str__(self):
        return self.name

tags/models.py

from django.db import models

class Tag(models.Model):
    name = models.CharField(max_length=255)

    def __str__(self):
        return self.name

You can now run

$ ./manage.py makemigrations

and

$ ./manage.py migrate

to create the database tables for your new models.

The Admin

Our admins for the “foos” and “tags” apps can be simple too:

foos/admin.py

from django.contrib import admin
from .models import Foo

admin.site.register(Foo)

tags/admin.py

from django.contrib import admin
from .models import Tag

admin.site.register(Tag)

It’s the admin for the “bars” app where things start to get more involved. At a minimum, we know that our admin change view will need to load the HTMX library. To make that happen, we override the base template for the Django admin’s change view. Let’s create some template directories for the “bars” app and put the override there:

bars/templates/admin/base.html

{% extends "admin/base.html" %}
{% block extrahead %}
<script src="https://unpkg.com/htmx.org@1.9.9"></script>
{% endblock %}

That lone <script> tag is all we need for HTMX to work. Magic!

In your settings file, make sure that the APP_DIRS flag is set (“True”) in your TEMPLATES setting, and that your app (“bars” in our case) is listed before 'django.contrib.admin' in your INSTALLED_APPS setting. Without these details, Django won’t find your override template.

Now that our change view knows what HTMX is, we need to attach some HTMX attributes to the “select” dropdown for the “foo” field in the admin. That’s going to require a custom change form, so let’s start there. We’ll define it in the “admin.py” module of the “bars” app:

bars/admin.py

from django import forms
from django.contrib import admin
from django.urls import reverse_lazy
from .models import Bar


class BarAdminForm(forms.ModelForm):

    class Meta:
        htmx_attrs = {
            "hx-get": reverse_lazy("bars:get_tags_for_foo"),
            "hx-swap": "innerHTML",
            "hx-trigger": "change",
            "hx-target": "#id_tags",
        }
        model = Bar
        fields = "__all__"
        widgets = {
            "foo": forms.Select(attrs=htmx_attrs),
        }


class BarAdmin(admin.ModelAdmin):
    form = BarAdminForm

admin.site.register(Bar, BarAdmin)

A View and Another Template

Notice that our “hx-get” attribute above references a view in the “bars” app (“get_tags_for_foo”). That view will return the tags related to the given “Foo” instance. We can then populate our multiselect widget with those values. Here’s the view:

bars/views.py

from django.views.generic import ListView
from foos.models import Foo, Tag

class GetTagsForFoo(ListView):
    model = Tag
    template_name = "bars/tag_choices.html"

    def get_queryset(self):
        if foo_id := self.request.GET.get("foo"):
            try:
                foo = Foo.objects.get(pk=foo_id)
            except Foo.DoesNotExist:
                pass
            else:
                return foo.tags.all()
        return Tag.objects.none()

The template for this view is just:

bars/templates/bars/tag_choices.html

{% for tag in object_list %}
<option title="{{ tag.name }}" value="{{ tag.id }}">{{ tag.name }}</option>
{% endfor %}

(The generic “ListView” will populate “object_list” for us.)

Wiring Up the URLs

As Django users will know, you must also connect this view to a URL in “bars/urls.py” and connect *that* module to the “urls.py” of your main project.

bars/urls.py

from django.urls import path

from . import views

app_name = "bars"  # url namespace                                                                                                                                                                         
urlpatterns = [
    path("tags-for-foo/", views.GetTagsForFoo.as_view(), name="get_tags_for_foo"),
]

urls.py (main project)

from django.conf.urls import include
from django.contrib import admin
from django.urls import path

urlpatterns = [
    path("bars/", include("bars.urls")),
    path('admin/', admin.site.urls),
]

Go Time

Now with everything in place, you should be able to start runserver on port 8000. To see the dynamic loading in action ala the screenshots at the beginning of this post, go to

http://localhost:8000/admin/

and log in as your superuser. Then:

  1. Create multiple “Tag” instances at http://localhost:8000/admin/tags/tag/add/
  2. Create a “Foo” instance at http://localhost:8000/admin/foos/foo/add/ and associate it with some tags.
  3. Create another “Foo” instance and associate it with different tags.
  4. Create a “Bar” instance at http://localhost:8000/admin/bars/bar/add/ and watch as your “tag” choices magically repopulate when you change the value of the “Foo” dropdown.

Fine Tuning

Hopefully, HTMX is working its magic on the “Foo” dropdown, but our form could use some tweaking. For example, the initial load of the form offers all “Tag” instances as choices, even though it should only offer those tags related to the chosen “Foo” instance. We might also like to prevent the user from changing the selected “Foo” if tags have already been chosen. This will help to ensure that any tags related to a given “Bar” instance are also related to the chosen “Foo” instance.

To add these nice features, you can override the __init__() method of your custom form and the `get_readonly_fields()` method of your custom admin. Now the admin module will look like this:

bars/admin.py

from django import forms
from django.contrib import admin
from django.urls import reverse_lazy
from foos.models import Foo
from .models import Bar


class BarAdminForm(forms.ModelForm):

    class Meta:
        htmx_attrs = {
            "hx-get": reverse_lazy("bars:get_tags_for_foo"),
            "hx-swap": "innerHTML",
            "hx-trigger": "change",
            "hx-target": "#id_tags",
        }
        model = Bar
        fields = "__all__"
        widgets = {
            "foo": forms.Select(attrs=htmx_attrs),
        }

    def __init__(self, *args, **kwargs):
        foo = None
        super().__init__(*args, **kwargs)
        # If this is a POST request (i.e. 'self.data' is populated), then set the queryset
        # for the 'tags' field to the tags associated with the Foo that was selected. This
        # allows the form to validate.  If this is a GET request, then set the queryset to
        # the tags associated with the Bar instance that is being edited, if any. This
        # allows the initial state of the field to be set correctly.
        foo_id = self.data.get("foo") or self.initial.get("foo")
        if foo_id:
            try:
                foo = Foo.objects.get(pk=foo_id)
            except Foo.DoesNotExist:
                pass
        elif self.instance.id:
            foo = self.instance.foo

        if foo:
            self.fields["tags"].queryset = foo.tags.all()
        else:
            self.fields["tags"].queryset = Tag.objects.none()


class BarAdmin(admin.ModelAdmin):
    form = BarAdminForm

    def get_readonly_fields(self, request, obj=None):
        # The user can only change the Foo instance if this Bar instance has no tags.
        if obj and obj.tags.count() > 0:
            return ["foo"]
        return []


admin.site.register(Bar, BarAdmin)

Final Words

Of course there are other tweaks you can make to make your custom form even nicer. A big one would be to use the Django admin’s “filter_horizontal” widget for the tag selector, as this is much more intuitive than the simple multi-select widget in our examples here. That widget comes with its own complications though, as changing its contents dynamically causes lots of problems with its own javascript-based functionality. Still, it can be done! I’ll leave it as an exercise for the reader to figure out how. 

Happy coding!


Share , ,
If you're getting even a smidge of value from this post, would you please take a sec and share it? It really does help.