How to Add Custom Action Buttons to Django Admin

The missing part from Django Admin list view


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 will it look like at the end?

Django Admin interface with custom action buttons
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. You mark several rows and select "delete rows" form the drop down menu. This is not very fleixble, and not suitable for some use cases.

Django built in actions
Django built in actions

Another downside, is that actions are not available in the detail view. To add buttons to the detail view you need to override the template, A huge pain.

The Forms

First thing first, we need some data from the user to perform the action. Naturally, we need a form. We need one form for deposit, and one form 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 like comment and send_email. The actions also handle success and failure in a similar way.

Let's start with a base form to handle a general action:

# 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
  • Every action has a comment, and an option to send a notification if the action completed successfully.
  • The actual operation is executed when the form is saved. This is similar to how ModelForm works.
  • For logging and audit purposes, the caller must provide the user executing the action.
  • Required properties that are not implemented by derived forms, will raise NotImplementedError. This way, we make sure the developer gets an informative error message if she forgets to implement something.
  • Errors are handled at the base exception class level. Our models define a base error class, so we can easily catch all (and only) 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 withdraw we need to add an amount field:

# 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(),
        )
  • We extended the base AccountActionForm and added an amount field with proper validations.
  • We filled in the required attributes, email_body_template.
  • We implemented the form action using the classmethod from the previous post. The model takes care of locking the record, updating any calculated fields, and adding the proper action to the log.

The next step is to add a deposit action. Deposit requires amount, reference and reference type fields:

# 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(),
        )

So far we got the necessary forms to accept, validate and execute deposit and withdraw. The next step is to integrate it into Django Admin list view.


The Admin

Before we can add fancy buttons for actions, 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 by adding a link to the user, and to the account actions. We can add some search fields and many more. I previously wrote about performance considerations in the admin interface when scaling a Django app to hundreds of thousands of users. 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. Django has a function to register URLs in a list view. Let's use that function to add the routes for our custom actions:

# 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

    def process_deposit(self):
        # TODO

    def process_withdraw(self):
        # TODO
  1. We registered two urls, one for deposit and one for withdraw.
  2. We referenced each route to a relevant function, process_deposit and process_withdraw. These function will render an intermediate page with the corresponding form, and will carry out the operation.
  3. We added a custom field account_actions to render the buttons for each action. The benefit of using a "regular" admin field like account_actions, is that it is available in both the detail and the list view.

Let's move on to implement the functions to handle the actions:

# 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,
        )

Deposit and withdraw are executed in a very similar way. To cary out both actions, we want to render a form in an intermediate page, and execute the action when the user submit the form.

process_action handles the form submission for both actions. The function accepts a form, the title of the action, and the account id. process_withdraw and process_deposit, are used to set the relevant context for each operation.

NOTE: 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.

To complete the process, we need a template for the intermediate page that contains the action form. We are going to base our template on an existing template used by Django Admin it self:

<!-- 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!


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. The next step would be to abstract this functionality and put in a mixin, but, we will cross that bridge when we get there.


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.



Similar articles