Adding custom actions to the Django Admin
For a project I am working on I had to build a form where people can apply to get educational discounts on all the products in the shop. This form is sent to the Django backend, where it’s stored in the database, but these applications have to be reviewed by someone from the support team.
Here’s the list of requirements going in:
- We’re going to use Django’s built-in admin interface, as the support team is already using this for all other things.
- The model should be read-only in the admin UI: the applications can be denied or approved, but it doesn’t make sense to edit them.
- Instead of the usual delete and save buttons, there should be two other buttons: deny and approve.
- When pressing either of these buttons, another page will be shown with a textarea pre-filled with some text which will be sent to the user who filled in the apply form.
When looking at the Django docs I came across something called Admin actions which sounded perfect, but sadly that’s purely for adding actions to the list-view, to execute some kind of action on one or multiple objects at the same time. That’s not what I need. I’m pretty sure I’m not the only one who ran into the same wish to add a new action (page) to the admin interface and looked at the Admin actions docs in disappointed, so here’s how I managed to fulfill my requirements in three easy steps.
1. Making the form read-only
This was really easy: just override the has_change_permission
and has_delete_permission
methods of your ModelAdmin
subclass.
class EducationalDiscountApplicationAdmin(admin.ModelAdmin):
list_display = ["user", "type", "institute", "program", "created"]
list_filter = ["type", "created"]
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False
admin.site.register(EducationalDiscountApplication, EducationalDiscountApplicationAdmin)
Now when opening an EducationalDiscountApplication
object in the admin, the delete and save buttons are gone, replaced with a close button. The list view now even says “Select educational discount application to view” instead of “to change”. Neat!
2. Adding the deny and approve buttons to the change form
I created a new template called submit_line.html
, in the templates/admin/[app_name]/[model_name]/
folder, with the following code:
{% extends "admin/submit_line.html" %}
{% load i18n admin_urls %}
{% block submit-row %}
<a href="../deny/" class="closelink" style="background:red;">DENY</a>
<a href="../approve/" class="closelink" style="background:green;">APPROVE</a>
{% endblock %}
And already when I open the change form of an EducationalDiscountApplication
object, I can see the new buttons!
Clicking on them doesn’t do anything yet, as these URLs are not recognized by the admin site yet. That’s the third and final part of the puzzle.
3. Adding the new views to the admin site
Add the get_urls
method to the ModelAdmin
subclass:
class EducationalDiscountApplicationAdmin(admin.ModelAdmin):
list_display = ["user", "type", "institute", "program", "created"]
list_filter = ["type", "created"]
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False
def get_urls(self):
urls = super().get_urls()
my_urls = [
path("<int:pk>/deny/", self.admin_site.admin_view(self.deny_view)),
path("<int:pk>/approve/", self.admin_site.admin_view(self.approve_view)),
]
return my_urls + urls
def deny_view(self, request, pk):
application = EducationalDiscountApplication.objects.get(pk=pk)
if request.method == "POST":
# Do something here:
# Send email, delete the application form, etc
return redirect("admin:sr_app_educationaldiscountapplication_changelist")
context = dict(
self.admin_site.each_context(request),
object=application,
opts=EducationalDiscountApplication._meta,
title="Deny Application?",
)
return TemplateResponse(request, "admin/sr_app/educationaldiscountapplication/deny_application.html", context)
def approve_view(self, request, pk):
# ...
And the template:
{% extends "admin/base_site.html" %}
{% load i18n admin_urls static %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
› <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
› <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
› <a href="{% url opts|admin_urlname:'change' object.pk|admin_urlquote %}">{{ object|truncatewords:"18" }}</a>
› {% translate 'Deny' %}
</div>
{% endblock %}
{% block content %}
<form method="post">{% csrf_token %}
<div>
<input type="hidden" name="post" value="yes">
<textarea style="width: 100%; height: 400px; margin-bottom: 20px;" name="text"></textarea>
<input type="submit" value="{% translate 'Deny the application and send the email' %}">
</div>
</form>
{% endblock %}
And with this in place the new page is shown when the DENY button is pressed, and form submits can easily be handled in the view code. Best of all, the page doesn’t look out of place at all, as it’s rendered in the same way as the delete confirmation page for example, with the same sidebar, header, breadcrumbs, the whole shebang.