How to Add Custom Action Buttons to Django Admin


We are big fans of the Django admin interface. It’s a huge selling point for Django as it takes the load off developing a “back office” for support and day to day operations.

In the last post we presented a pattern we use often in our Django models. We used a bank account application with an Account and account Action models to demonstrate the way we handle common issues such as concurrency and validation. The bank account had two operations we wanted to expose in the admin interface — deposit and withdraw.

We are going to add buttons in the Django admin interface to deposit and withdraw from an account, and we are going to do it in less than 100 lines of code!

What Does it Look Like?

Django admin interface with custom action buttons

Our custom actions are the nice looking deposit and withdraw buttons next to each account.

Why Not Use the Existing Admin Actions?

The built-in admin actions operate on a queryset. They are hidden in a dropbox menu in the top toolbar and they are mostly useful for executing bulk operations. A good example is the default delete action — mark as many rows as you like and hit delete — this is not our case.

Django built in actions

Another downside of using Django actions is that the actions are not available in the detail view. To add buttons to the detail view you need to override the template — a huge pain and usually not worth it.

The Forms

First thing first — we need some data from the user to perform the action so naturally, we need a form — one for deposit and one for withdraw.

In addition to performing the action we are going to add a nifty option to send a notification email to the account owner informing him about an action made to his account.

All of our actions have common arguments (comment, send_email) and they handle success and failure in a similar way.

Let’s start with a base form to handle a general action on the account:

# forms.py
from django import forms
from common.utils import send_email
from . import errors
class AccountActionForm(forms.Form):
comment = forms.CharField(
required=False,
widget=forms.Textarea,
)
send_email = forms.BooleanField(
required=False,
)
    @property
def email_subject_template(self):
return 'email/account/notification_subject.txt'
    @property
def email_body_template(self):
raise NotImplementedError()
    def form_action(self, account, user):
raise NotImplementedError()
    def save(self, account, user):
try:
account, action = self.form_action(account, user)
        except errors.Error as e:
error_message = str(e)
self.add_error(None, error_message)
raise
        if self.cleaned_data.get('send_email', False):
send_email(
to=[account.user.email],
subject_template=self.email_subject_template,
body_template=self.email_body_template,
context={
"account": account,
"action": action,
}
)
    return account, action

So what do we have here:

  • Every action has a comment and an option to send a notification if the action completed successfully.
  • Similar to a ModelForm, we execute the operation in the save function.
  • The caller must specify the user executing the action for logging and audit purposes.
  • We raise NotImplementedError for required properties to make sure we get a nice error message if we forget to override them.
  • We used a base exception in our models so we can catch all account related exceptions and handle them appropriately.

Now that we have a simple base class let’s add a form to withdraw from an account. The only additional field is the amount to withdraw:

# forms.py
from django.utils import timezone
from .models import Account, Action
class WithdrawForm(AccountActionForm):
amount = forms.IntegerField(
min_value=Account.MIN_WITHDRAW,
max_value=Account.MAX_WITHDRAW,
required=True,
help_text='How much to withdraw?',
)
    email_body_template = 'email/account/withdraw.txt'
    field_order = (
'amount',
'comment',
'send_email',
)
    def form_action(self, account, user):
return Account.withdraw(
id=account.pk,
user=account.user,
amount=self.cleaned_data['amount'],
withdrawn_by=user,
comment=self.cleaned_data['comment'],
asof=timezone.now(),
)

Pretty straight forward:

  • We added the additional field (amount) with the proper validations.
  • Provide the required attributes (email body template).
  • Implemented the form action using the classmethod from the previous post. The method takes care of locking the record, updating any calculated fields and adding the proper action to the log.

The deposit action has additional fields — reference and reference type:

# forms.py
class DepositForm(AccountActionForm):
amount = forms.IntegerField(
min_value=Account.MIN_DEPOSIT,
max_value=Account.MAX_DEPOSIT,
required=True,
help_text=’How much to deposit?’,
)
reference_type = forms.ChoiceField(
required=True,
choices=Action.REFERENCE_TYPE_CHOICES,
)
reference = forms.CharField(
required=False,
)
    email_body_template = 'email/account/deposit.txt'
    field_order = (
'amount',
'reference_type',
'reference',
'comment',
'send_email',
)
    def form_action(self, account, user):
return Account.deposit(
id=account.pk,
user=account.user,
amount=self.cleaned_data['amount'],
deposited_by=user,
reference=self.cleaned_data['reference'],
reference_type=self.cleaned_data['reference_type'],
comment=self.cleaned_data['comment'],
asof=timezone.now(),
)

Sweet!


The Admin

Before we add fancy buttons we need to set up a basic admin page for our Account model:

# admin.py
from django.contrib import admin
from .models import Account
@admin.register(Account)
class AccountAdmin(admin.ModelAdmin):
date_heirarchy = (
'modified',
)
list_display = (
'id',
'user',
'modified',
'balance',
'account_actions',
)
readonly_fields = (
'id',
'user',
'modified',
'balance',
'account_actions',
)
list_select_related = (
'user',
)

def account_actions(self, obj):
# TODO: Render action buttons

Side Note: We can make the list view much better — add a link to the user and to the account actions, add search fields and many more but this post is not about that. I previously wrote about performance considerations in the admin interface when scaling a Django app to hundreds of thousands of users and there are some nice tricks there that can make even this simple view much nicer.

Adding the Action Buttons

We want to add action buttons for each account and have them link to a page with a form. Luckily, Django has a function to add URL’s so let’s use it to add the routes and corresponding buttons:

# admin.py
from django.utils.html import format_html
from django.core.urlresolvers import reverse
class AccountAdmin(admin.ModelAdmin):
...
def get_urls(self):
urls = super().get_urls()
custom_urls = [
url(
r'^(?P<account_id>.+)/deposit/$',
self.admin_site.admin_view(self.process_deposit),
name='account-deposit',
),
url(
r'^(?P<account_id>.+)/withdraw/$',
self.admin_site.admin_view(self.process_withdraw),
name='account-withdraw',
),
]
return custom_urls + urls
    def account_actions(self, obj):
return format_html(
'<a class="button" href="{}">Deposit</a>&nbsp;'
'<a class="button" href="{}">Withdraw</a>',
reverse('admin:account-deposit', args=[obj.pk]),
reverse('admin:account-withdraw', args=[obj.pk]),
)
account_actions.short_description = 'Account Actions'
account_actions.allow_tags = True

We render two buttons, each linking to a view that executes a corresponding process_deposit/withdraw function. The two views will render an intermediate page with the relevant form. When the form is submitted the view will redirect back to our detail page or inform the user of an error.

A nice feature of using the account_actions field is that it is available in both the detail and the list view because it’s a regular field in the admin.

The function that handles the actual action:

# admin.py
from django.http import HttpResponseRedirect
from django.template.response import TemplateResponse
from .forms import DepositForm, WithdrawForm
class AccountAdmin(admin.ModelAdmin):
...
def process_deposit(self, request, account_id, *args, **kwargs):
return self.process_action(
request=request,
account_id=account_id,
action_form=DepositForm,
action_title='Deposit'
,
)
   def process_withdraw(self, request, account_id, *args, **kwargs):
return self.process_action(
request=request,
account_id=account_id,
action_form=WithdrawForm,
action_title='Withdraw',
)

def process_action(
self,
request,
account_id,
action_form,
action_title
):
account = self.get_object(request, account_id)
        if request.method != 'POST':
form = action_form()
        else:
form = action_form(request.POST)
if form.is_valid():
try:
form.save(account, request.user)
                except errors.Error as e:
# If save() raised, the form will a have a non
# field error containing an informative message.
pass
                else:
self.message_user(request, 'Success')
url = reverse(
'admin:account_account_change',
args=[account.pk],
current_app=self.admin_site.name,
)
return HttpResponseRedirect(url)
        context = self.admin_site.each_context(request)
context['opts'] = self.model._meta
context['form'] = form
context['account'] = account
context['title'] = action_title
        return TemplateResponse(
request,
'admin/account/account_action.html',
context,
)

We wrote a function called process_action that accepts the form, the title of the action and the account id, and handles the form submission. The two functions, process_withdraw and process_deposit, are used to set the relevant context for each operation.

There is some Django admin boilerplate here that is required by the Django admin site. No point in digging too deep into it because it’s not relevant to us at this point.

Only thing left to do is to add the template of the intermediate page containing the form. Once again, no need to work too hard — Django already has a detail page template we can extend:

# templates/admin/account/account_action.html
{% extends "admin/change_form.html" %}
{% load i18n admin_static admin_modify %}
{% block content %}
<div id="content-main">
  <form action="" method="POST">
{% csrf_token %}
    {% if form.non_field_errors|length > 0 %}
<p class="errornote">
"Please correct the errors below."
</p>
{{ form.non_field_errors }}
{% endif %}
    <fieldset class="module aligned">
{% for field in form %}
<div class="form-row">
{{ field.errors }}
{{ field.label_tag }}
{{ field }}
{% if field.field.help_text %}
<p class="help">
{{ field.field.help_text|safe }}
</p>
{% endif %}
</div>
{% endfor %}
</fieldset>
    <div class="submit-row">
<input type="submit" class="default" value="Submit”>
</div>
  </form>
</div>
{% endblock %}

This is it!

Staff members can now easily deposit and withdraw directly from the admin interface. No need to create an expensive dashboard or ssh to the server.

I promised we will do it in 100 lines and we did it in less!

Profit!


Credits

Big parts of the implementation are taken from the excellent (excellent!) package django-import-export. It saved us hours of “Can you just send me the data in Excel?” and we love it for it. If you are not familiar with it you should definitely check it out.

Where Can We Take It From Here

Now that we nailed this technique we can pretty much do whatever we want with it — we have total control over the route and what’s being rendered. It’s also possible to add buttons for action that don’t require additional data without the intermediate page.

The next step would be to abstract this functionality and put in a mixin, but, we will cross that bridge when we get there.