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.

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!