HTMX, Django list_editable, and ManyToMany relationships in Django Admin

A quick demonstration of how I use HTMX in the Django Admin to hack in a ManyToMany relationship, kind of like using list_editable

HTMX, Django list_editable, and ManyToMany relationships in Django Admin

Before we start, I assume you are familiar with HTMX and the Django Admin. Let's not waste anymore time. It does not specifically use list_editable, but it offers the same functionality.

Show, don't tell

Django's list_editable is a cool feature of the admin. It lets you make the grid of results editable, but it does not work with ManyToMany relationships.

But I want ManyToMany in list_editable!

Turns out that we can enable a ManyToMany feature in a lazy/hackish way. Keep in mind – this is a proof of concept. It does not consier too many things like security, but you can firm that up on your own.

To unlock this hack you need the following:

  • HTMX loaded in your admin
  • A column in your list_display that will hold the content and interaction
  • A view to handle the custom processing logic
  • A small template to hold the display logic
  • A route defined in your urls.py

Loading custom javascript into the Admin.

First, put your HTMX somewhere in your static path:

Edit your ModelAdmin to load that file in.

class EntityAdmin(admin.ModelAdmin):

	...all you other code defined

    class Media:
        js = ('js/htmx.min.js',)

Reload your page and you will see it is pulled in.

Create a column in the display that will hold this interaction

We revisit the ModelAdmin and add some things:

  • add a calculated column to list_display. This holds the ManyToMany Relationship. With HTMX, it will lazy-load the ManyToMany content when clicked. Notice 'custom_url' text and the method with the same name.
class EntityAdmin(admin.ModelAdmin):
    list_display = ('name', 'custom_url',...others defined)

    def custom_url(self, obj):
        return format_html(f'''
            <div hx-target="this" hx-swap="innerHTML">
            <span hx-get="/entity/admin_tag_editor?id={obj.id}" class="btn btn-primary">Click To Edit</span>
            </div>
        ''')

    custom_url.short_description = 'Custom edit poup'
    custom_url.allow_tags = True

    class Media:
        js = ('js/htmx.min.js',)

All we want to do above is inject some HTML with HTMX tags into each row of our displayed data. Clicking that cell labeled Click to edit will trigger HTMX to load in the ManyToMany relationships as checkboxes.

Make a view to handle the custom logic

Below is the view in its entierty. It handles get and post. Yes, I plan to firm up security and best practices :-)

Lets talk about some of the variables

  • mode tells the template to show the form or show the Click to edit part
  • id is the PrimaryKey of the Item we want to update
  • category_id is a list of Categories we want to connnect via ManyToMany. These are passed in via the input checkboxes in the view.

views.py

def admin_tag_editor(request):

    mode = request.GET.get('mode', 'edit')
    id = request.GET['id']
    e = Entity.objects.get(id=id)
    all_user_cats = e.categories.all()

    if request.method == 'GET':
        user_cat_ids = [_.id for _ in all_user_cats]
        all_cats = Category.objects.all().order_by('name')

        data = {
            'mode': mode,
            'id': id,
            'cats': all_cats,
            'user_cat_ids': user_cat_ids,
        }
        return render(request, 'entity/admin_tag_editor.html', data)

    if request.method == 'POST':
        category_ids = request.POST.getlist('category_id')
        categories = Category.objects.filter(id__in=category_ids).all()
        e.categories.set(categories)

    return HttpResponse(status=201)

This method handles GET and POST. It may be more code, but I prefer this method to managing too many routes in urls.py

Every good view deserves a template, so lets do that next.

Our view template, entity/admin_tag_editor.html

{% if mode == 'edit' %}
<span hx-get="/entity/admin_tag_editor?id={{ id }}&mode=close" class="btn btn-primary">Close</span>
<ul style="list-style-type: none;padding: 0;margin: 0">
    <form>
        {% csrf_token %}
        {% for c in cats.all %}
    <li style="list-style-type: none">
        <label>
                <input hx-post="/entity/admin_tag_editor?id={{ id }}"
                       hx-swap="none"
                       type="checkbox" name="category_id" value="{{ c.id }}"
                       {% if c.id in user_cat_ids %}checked{% endif %}
                /> {{ c.name }}

        </label>
    </li>
    {% endfor %}
    </form>

</ul>
{% else %}
<div hx-target="this" hx-swap="innerHTML">
<span hx-get="/entity/admin_tag_editor?id={{ id }}&mode=edit" class="btn btn-primary">Click To Edit</span>
</div>
{% endif %}

This is kind of like two views in one. See how I use mode to show the form or show the Click to edit ?

All that you see here is a simple form with checkboxes. Each checkbox is a category available to link in a ManyToMany fashion.

Checking/Unchecking a checkbox triggers a POST to the backend where the view will save the entire set of relations, NOT one at a time.

Wire it up in urls.py

from django.urls import path

from . import views

app_name = 'entity'
urlpatterns = [
    path('', views.index, name='index'),
    path('admin_tag_editor', views.admin_tag_editor, name='admin_tag_editor'),
]

This was the easiest part. Just provide whats needed so that Django knows how to handle the new route.

Conclusion

This was just a fast-and-loose demonstration of how to you can hack a ManyToMany editor into your Django Admin list views.

Subscribe to Candid and colorful thoughts on enterprise readiness

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe