Mimsy Were the Borogoves

Hacks: Articles about programming in Python, Perl, PHP, and whatever else I happen to feel like hacking at.

Django actions as their own intermediate page

Jerry Stratton, October 25, 2009

Simple actions in Django’s admin are well documented. For example, I have a PageLink model that contains a ForeignKey to my blog posts (pages) and another ForeignKey to the URLs I’m using on that page. Most URLs, besides getting auto-linked or hard-linked within the post, are also presented as a list at the bottom of the post. Sometimes, though, I don’t want an URL showing up in the list even though I do want some text within the post linking to that URL. So I have a “displayInList” BooleanField.

Making an action to set displayInList to false is easy:

[toggle code]

  • from django.template import Template, Context
  • hideSuccess = Template('Hid {{ count }} link{{ count|pluralize }}')
  • class PageLinkAdmin(admin.ModelAdmin):
    • list_display = ['url', 'page', 'added', 'displayInList', 'category', 'rank']
    • list_filter = ['displayInList']
    • search_fields = ['page__title', 'url__title']
    • ordering = ['-added']
    • actions = ['hideFromList']
    • def hideFromList(self, request, queryset):
      • for link in queryset:
        • link.displayInList = False
        • link.save()
      • self.message_user(request, hideSuccess.render(Context({'count':queryset.count()})))
    • hideFromList.short_description = "Do not display in list of links on page"

Which is not very useful as an example, since the same thing can be done with one line in Django 1.1:

  • list_editable = ['displayInList']

Actions become more involved when, instead of a simple boolean toggle, we need to be able to make some choices between selecting the items to be affected and applying the change to those items. For example, you can see a “category” in the list_display above. Each PageLink also has a ForeignKey to a KeyWord model. This allows me to divide a longer list of URLs into sections.

Commonly, I don’t even know what the appropriate categories are until I’ve finished writing the post and I’ve already added the URLs. It’d be a lot easier for me to be able to use an action to select all of the PageLinks that I want in a category, rather than have to go to each PageLink one by one. That requires, however, choosing the category.

In cases where a choice must be made (such as choosing from a list of categories), the documentation recommends redirecting to another URL, appending the list of object IDs to the redirect URL as a GET query string and using a view. But besides being a bit wonky, that loses some of the benefits of building the action into the ModelAdmin class.

By not returning anything, the hideFromList action results in Django’s admin just displaying the list of PageLinks again. But we can return a standard Django response object from the action. This is where the wonderful stateless nature of HTTP comes to our rescue. The action menu is just a form. Django doesn’t know anything about the form, it only knows what form data it receives1. As long as the form submission contains a series of “_selected_action” fields containing IDs, and an “action” field containing the action’s name, Django will continue to send those “selected” objects to that action as a query set.

[toggle code]

  • from django.shortcuts import render_to_response
  • import django.forms as forms
  • from django.http import HttpResponseRedirect
  • from django.template import Template, Context
  • class PageLinkAdmin(admin.ModelAdmin):
    • list_display = ['url', 'page', 'added', 'displayInList', 'category', 'rank']
    • list_filter = ['displayInList']
    • search_fields = ['page__title', 'url__title']
    • ordering = ['-added']
    • actions = ['changeCategory']
    • categorySuccess = Template('{% load humanize %}Categorized {{ count|apnumber }} link{{ count|pluralize }} as {{ category.key }}')
    • class CategoryForm(forms.Form):
      • _selected_action = forms.CharField(widget=forms.MultipleHiddenInput)
      • category = forms.ModelChoiceField(KeyWord.objects)
    • def changeCategory(self, request, queryset):
      • form = None
      • if 'cancel' in request.POST:
        • self.message_user(request, 'Canceled link categorization')
        • return
      • elif 'categorize' in request.POST:
        • #do the categorization
        • form = self.CategoryForm(request.POST)
        • if form.is_valid():
          • category = form.cleaned_data['category']
          • for link in queryset:
            • link.category = category
            • link.save()
          • self.message_user(request, self.categorySuccess.render(Context({'count':queryset.count(), 'category':category})))
          • return HttpResponseRedirect(request.get_full_path())
      • if not form:
        • form = self.CategoryForm(initial={'_selected_action': request.POST.getlist(admin.ACTION_CHECKBOX_NAME)})
      • return render_to_response('cms/categorize.html', {'links': queryset, 'form': form, 'path':request.get_full_path()})
    • changeCategory.short_description = 'Set category'

If the cancel button is pressed, it returns nothing; if a category has been chosen, it loops through all of the objects in the queryset and performs the action; then, it returns nothing, just as a simple action would have, including sending a user message. I’m using Django’s Template system for string substitution, because it annoys me to see messages such as “changed 1 links”2.

If no category has been chosen yet, it creates a response object from (in this case) “cms/categorize.html” and returns that. Here’s the categorize.html that I use:

[toggle code]

  • <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
  • <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
    • <head>
      • <title>Categorize Links</title>
      • <meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
      • <meta name="description" content="Link categorization control." />
      • <style type="text/css">
        • h1 {
          • text-align: center;
          • background-color: green;
        • }
        • body {
          • margin: 10%;
          • border: solid .2em black;
        • }
        • p, ul, h2, form {
          • margin-left: 10%;
          • margin-right: 10%;
        • }
      • </style>
    • </head>
    • <body>
      • <h1>Categorize Links</h1>
      • <p>Choose the category for the selected link{{ links|pluralize }}:</p>
      • <form method="post" action="{{ path }}">
        • <table>
          • {{ form }}
        • </table>
        • <p>
          • <input type="hidden" name="action" value="changeCategory" />
          • <input type="submit" name="cancel" value="Cancel" />
          • <input type="submit" name="categorize" value="Categorize" />
        • </p>
      • </form>
      • <h2>This categorization will affect the following:</h2>
      • <ul>
        • {% for link in links %}
          • <li>{{ link.url.linkHTML }} on page {{ link.page.linkHTML }}</li>
        • {% endfor %}
      • </ul>
    • </body>
  • </html>

The only special thing here is that the action field is hard-coded as a hidden field. Without it, Django won’t recognize that an action’s been submitted on submit.

Note, if you’re attempting to use the IDs on the hidden fields or validate the page, forms.MultipleHiddenInput currently gives each input the same ID. It looks like this will be fixed in the next point revision.

  1. Django doesn’t even know what kind of a form input provided that data. That the action menu is a select means nothing: it’s still just a form field on the submission end, and we can emulate it with a hidden input field (or even a text input field) if we need to.

  2. If you aren’t loading django.contrib.humanize in your settings.py file, you don’t have the apnumber filter; remove the “load humanize” tag and the “apnumber” filter from the “categorySuccess” property.

  1. <- Caching DTDs
  2. Django tutorial ->