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
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 theManyToMany
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.