Development

Development

A Custom Django Widget

Writing a custom admin widget can be a little tricky, due to the way that form data is handled. In order to minimize your trouble, I would highly recommend extending an existing widget if at all possible. It may save you a lot of trouble, since Django has a lot of custom labeling and logic to create, populate, validate, and submit admin forms. Once you have a functional baseline, any custom behavior can overwrite the defaults.

This example comes from a project with a purchase request model. The admin site had a model changeform, which contained information about the requester, the requested item, and so on. We also had a textfield which was a human-readable phrase describing the purchase request. This field needed to have a button near it which would pull information from other parts of the form, and could then be manually edited and submitted to the database normally.

In this example, I will walk you through the creation of a custom formfield widget and how to get it properly plugged into the admin. The widget itself is just a textarea with a button, so all we need to do is take the HTML output from the textarea and append it. The render() method accepts the currently instantiated widget object (self), the name of the form field using the widget (name), the current contents of the html textarea (value), and any attributes passed to it by the widget class; it is responsible for returning a valid HTML string describing the widget, so that is what we will construct.

One thing you need to know is that each purchase request can be associated with an asset. We're going to want to know which asset the purchase request is linked to, so we start off by creating a purchase request/asset dictionary. Thus:

# In apps/purchaserequest/widgets.py
class PRNotesWidget(forms.widgets.Textarea):

 def render(self, name, value, attrs=None):
   # Build a dictionary linking purchase requests
   # with their corresponding assets
   pr_asset_dict = {int(asset.purchaserequest_id):
                    int(asset.asset_number) for asset in
                    Asset.objects.all()
                    .exclude(purchaserequest_id=None)}
        
   # Start with the textarea; and wrap it in a script
   # containing the logic to populate it, and the
   # button to trigger the script.
   html = super(PRNotesWidget, self).render(name, value,
                                            attrs)
   html = """
     <script type="text/javascript">
       var populatePRNotes = function() {
         # Use jQuery to select the fields that will
         # populate this field
         var qty = document
                        .getElementById('id_qty').value;
         var item = document
                    .getElementById('id_name').value;
         var who = document
                   .getElementById('id_who').value;
                    
         # Get the id of the purchase request
         # from the form
         var pr_id = document
           .getElementsByClassName('#field-request_id')
           .value;
                    
         # Careful here: we're ending the string,
         # inserting the dictionary we built earlier,
         # and then continuing our string.
         var pr_asset_dict = """ +
                             str(pr_asset_dict) + """;
                    
         # Now access the dictionary using the purchase
         # request id as a key to get the corresponding
         # asset (if there is one)
         var pr_asset = pr_asset_dict[pr_id];
                    
         # Build the text to display in the form field.
         var display_text = qty + ' ' + item +
                            ' for ' + who
         if (pr_asset) {
           display_text += ' (Asset #' + pr_asset + ')'
         }
         document.getElementById('id_accounting_memo')
                 .innerHTML = display_text;
       } 
     </script>
     """ + html + """
     # This button will trigger the script's function
     # and fill in the field.
     <button type="button" onclick="populatePRNotes()">
       Create PR Notes
     </button>
     """;
        
   # Since we are using string concatenation, we need to
   # mark it as safe in order for it to be treated as
   # html code.
   return mark_safe(html);

Now that our widget is defined, all we need to do is link it to an admin field. We do this by setting the formfield widget to PRNotesWidget like so:

# In apps/purchaserequest/fields.py
from purchaserequest.widgets import PRNotesWidget

class PRNotesField(models.TextField):

  def formfield(self, **kwargs):
    kwargs['widget'] = PRNotesWidget
    return super(PRNotesField, self).formfield(**kwargs)

The field needs to be explicitly specified in a form:

# In apps/purchaserequest/forms.py
from purchaserequest.fields import PRNotesField

class PRAdminForm(forms.ModelForm): 
  # The form for the purchase request model should
  # use our custom field
  accounting_notes = PRNotesField()

And then, of course, we need to make sure we're using that form in the admin:

# In apps/purchaserequest/admin.py
from purchaserequest.forms import PRAdminForm

class PRAdmin(admin.ModelAdmin):
  # The purchase request admin should be using the
  # custom admin form
  form = PRAdminForm

You'll note that I've split the admin, form, field, and widget each into files with their respective names. This is only really necessary if you have a large project with lots of custom widgets and fields. However, this structure is preferable both for being prepared for the future, as well as to understand the hierarchy and flow of the app.

This was my first attempt at a custom widget, but a number of improvements could easily be made from here. For example, it is not necessary to create a custom field as I did, since Django provides a shortcut in a form's Meta class to define a "widgets" dictionary with field names as keys and widgets as values. You'll also notice the fact that I make a query to the database with pr_asset_dict, and dump the entire dictionary to Javascript. A better way to do this would be to make an AJAX call to the database and retrieve only the asset that I want. While the example presented here might be the most easily understood implementation, there is always room for optimization.

comments powered by Disqus