Django admin actions and intermediate pages Tagging multiple articles at once

The other day I created an "Ireland" tag on this blog and thought, hey, wouldn't it be cool to go back and tag all the posts I wrote about events in Ireland, so I have an easy way to see what's going on locally?

Of course retagging each post manually would suck so I decided to create a new Django admin action, with an intermediate page that would present me with my current list of tags so I could select one, then update.

I also wanted:

  1. To keep the Django admin look and feel
  2. To have the nice admin message "X posts were successfully updated"

Now the Django documentation is very clear at explaining and showing how to do each of those, but not together.

Here's how I ended up doing it, see links at the end for other helpful resources. I'm quite pleased with this solution because it's done the Django way (through the use of forms, etc) rather than by working around Django.

Directly in the ModelAdmin section for blog posts, create an inner class for the new form (tag picker in my case), and a function to process the form like you would in a normal view. Don't forget to add the new method to the actions list.

    actions = ['add_tag']

    class AddTagForm(forms.Form):
        _selected_action = forms.CharField(widget=forms.MultipleHiddenInput)
        tag = forms.ModelChoiceField(Tag.objects)

    def add_tag(self, request, queryset):
        form = None

        if 'apply' in request.POST:
            form = self.AddTagForm(request.POST)

            if form.is_valid():
                tag = form.cleaned_data['tag']

                count = 0
                for article in queryset:
                    article.tags.add(tag)
                    count += 1

                plural = ''
                if count != 1:
                    plural = 's'

                self.message_user(request, "Successfully added tag %s to %d article%s." % (tag, count, plural))
                return HttpResponseRedirect(request.get_full_path())

        if not form:
            form = self.AddTagForm(initial={'_selected_action': request.POST.getlist(admin.ACTION_CHECKBOX_NAME)})

        return render_to_response('admin/add_tag.html', {'articles': queryset,
                                                         'tag_form': form,
                                                        })
    add_tag.short_description = "Add tag to articles"

And here's my intermediate page (quite simple!), which uses the admin look & feel and is located in templates/admin/add_tag.html.

{% extends "admin/base_site.html" %}

{% block content %}

<p>Select tag to apply:</p>

<form action="" method="post">

    {{ tag_form }}

    <p>The tag will be applied to:</p>

    <ul>{{ articles|unordered_list }}</ul>

    <input type="hidden" name="action" value="add_tag" />
    <input type="submit" name="apply" value="Apply tag" />
</form>

{% endblock %}

The hidden field is essential for Django to recognise the form submission as an admin action.

Update: Follow this ticket to learn how to apply an admin action to selected items on multiple pages -- or look at the comments below for tips!

Example of the code in action:

Selecting articles and the new admin action --> Select the tag to apply --> The message is displayed

Resources:


< Back to main >

Thanks.

This is exactly the kind of article I need. The Django docs, while really wonderful, have a tendency to say annoying things like "Writing this view is left as an exercise to the reader." Maybe you can contribute this article back to the docs and replace that sentence!

BTW, perhaps a screenshot or two would also help here.
#1. Posted by Derek on Sat 23 Oct 2010, 7:57

Hi Derek -- thanks for the kind words! Including screenshots is an excellent idea, I've now added a couple. #2. Posted by jpichon (Website) on Mon 25 Oct 2010, 15:10

Excellent posting :) #3. Posted by Kevin Postal (Website) on Wed 17 Nov 2010, 18:46

Thanks - the pictures are useful too.

Just one small thing I have found. I have needed to replace the line:

article.tags.add(tag)

with the lines:

article.tag = tag
article.save()

for a normal model with, say, a field called "tag".
#4. Posted by Derek on Thu 18 Nov 2010, 8:14

OK - some weirdness that I hope you can help with (please feel free to delete this comment when resolved!).

I need to work with multiple fields on my form. So I change your approach to something like:

{{ tag_form.field_a }}
{{ tag_form.field_b }}

etc.

That does not work! I realize I need to add the _selected_action field back in as well. When I do, Django complains that the field name cannot start with an underscore ("_"). So I renname the field to "selected_action". That does not work either as the updates do not take place... please can you test to see what is the correct combination to make this "go"!

Thanks.
#5. Posted by Derek on Thu 18 Nov 2010, 14:05

Thanks, Kevin! #6. Posted by jpichon (Website) on Thu 18 Nov 2010, 20:17

Hi Derek. I like to have multiple tags for any article so my model has a ForeignKey/ManyToMany relation and that explains why the tag field works a bit differently.

I think I'd need to see a bit more code to be able to help you with your other problem. If you want to see more fields you should try to add them to the form in the AddTagForm(forms.Form) class, ideally.
#7. Posted by jpichon (Website) on Thu 18 Nov 2010, 20:27

OK - I can see you have missed the point of the actual problem I was trying to solve. The key issue is not adding new fields to the form.

Line 4 of the template is the heart of the problem - Django will not allow a field starting with a "_" in the template. If you drop the underscore there, and also in Line 27 of the logic, then the data is not processed i.e. Django seems to require that the exact term '_selected_action' is used for the field name, but then will not allow you to use that name in a template. I hope that this explanation and the code below helps clarify this.

Thanks!


# code snippets

# Creating the form is simple, e.g.

class UpdateForm(forms.Form):
_selected_action= forms.CharField(widget=forms.MultipleHiddenInput)
status = forms.CharField(choices=choices.STATUS, required=False)
condition = forms.ChoiceField(choices=choices.CONDITION, required=False)


# The template to display this could look like:

{% extends "admin/base_site.html" %}
{% block content %}

<form action="" method="post">
{{ update_form._selected_action }}
{{ update_form.status }}
{{ update_form.condition }}

<input type="hidden" name="action" value="mass_update" />
<input type="submit" name="apply_update" value="Apply Update" class="default" />

The update will be carried out for:
<ul>{{ books|unordered_list }}</ul>
</form>

{% endblock %}


# The logic to process the above:

def mass_update(self, request, queryset):
form = None
if 'apply_update' in request.POST:
form = UpdateForm(request.POST)
if form.is_valid():
print "form valid"
cleaned = form.cleaned_data
count = 0
for book in queryset:
if form.cleaned_data['status']:
book.status = form.cleaned_data['status']
if form.cleaned_data['condition']:
book.condition = form.cleaned_data['condition']
book.save()
count += 1
plural = ''
if count != 1:
plural = 's'
self.message_user(
request,
_('Successfully updated %d book%s.') % (count, plural)
)
return HttpResponseRedirect(request.get_full_path())
if not form:
print "Creating Form ..."
form = UpdateForm(
initial={'_selected_action': request.POST.getlist(admin.ACTION_CHECKBOX_NAME)}
)
return render_to_response(
'admin/book_mass_update.html',
{'books': queryset,
'update_form': form,
'title': _('Book Mass Update')
}
)
mass_update.short_description = _('Mass update for book/s')
#8. Posted by Derek on Fri 19 Nov 2010, 6:43

Re the above code...

Your blog comment field has stripped out all the spaces from the start of the lines. Please email me if you want the original code...
#9. Posted by Derek on Fri 19 Nov 2010, 6:45

Any feedback on this issue? #10. Posted by Derek on Sat 20 Nov 2010, 16:55

This is the way HTML works with whitespace, it looks fine in the HTML source.

I wasn't aware of Django's restrictions with attributes starting with an underscore. If you call {{ update_form }} in your template directly like in the example, things should work fine and every field defined in your Form class would be picked up.

If you're keen on calling out each field individually in the template, the django-users list might be a better place to ask for help with regard to underscore attributes. Good luck!
#11. Posted by jpichon (Website) on Sat 20 Nov 2010, 18:37

"If you call {{ update_form }} in your template directly like in the example, things should work fine and every field defined in your Form class would be picked up. "

Yes, this is true; but then you have no control over form layout. So this solution is not a complete one.
#12. Posted by Derek on Mon 22 Nov 2010, 4:28

OK - I have managed to solve this, based on the code snippet at:

http://djangosnippets.org/snippets/2213/

In the render_to_response() call, you need to add a line:

'ids': request.POST.getlist('_selected_action'),

and then, in the template, instead of the line:

{{ update_form._selected_action }}

use the code loop:
{% for id in ids %}<input type="hidden" name="_selected_action" value="{{ id }}">
{% endfor %}

Hope this might help someone else...
#13. Posted by Derek on Mon 22 Nov 2010, 8:30

Hi, everybody, if I want to use some jQuery function in my intermediate page,how should it do? If I just insert my javascript code into the template file , it return a javascript error like that "$ is not defined". Any Help is appreciated. #14. Posted by Carlos Wong on Thu 25 Nov 2010, 8:42

Hi Carlos -- This really isn't the best place for general questions, you should find a user forum or mailing list where your questions would get much more visibility (and help, likely!)

I would assume you need to make sure to include in your template a link to your jQuery main source. However I don't use jQuery with Django (yet!), so I can't be sure if you're actually hitting another issue.
#15. Posted by jpichon (Website) on Fri 26 Nov 2010, 10:53

Juste ce qu'il me fallait,
Merci
#16. Posted by Franck on Sun 05 Dec 2010, 17:36

(I try in English.)
It is just I need.
You save me a lot of hours.

Thank you
#17. Posted by Franck on Sun 05 Dec 2010, 19:05

I have used this example for a model admin function and, locally, it works pretty fine. Although on the live server, it executes the action function but crashes the server on the response, showing a page not found error, and then I need to clear all cookies or even restart server sometimes. Has anyone experienced that? #18. Posted by Jonatas on Wed 23 Mar 2011, 21:44

Just to be clear: it executes the functions, all the actions are done, the problem is only AFTER this, on the response. #19. Posted by Jonatas on Wed 23 Mar 2011, 21:45

That sounds fairly mysterious. I would compare the Python and Django versions in your dev and prod environment, and also the servers.

However I really have no idea what could be causing a problem like this. Your question would get a lot more visibility (and hopefully answers!) if you asked on IRC or on the django-users mailing list. Best of luck!
#20. Posted by jpichon (Website) on Wed 23 Mar 2011, 22:48

Was able to get past the 100+ limit by doing a variation on Derek's suggestion.

Here's my admin.py:

if not form:
form = self.AddTagForm()

return render_to_response('admin/test_site.html', {'hostnames': queryset,
'tag_form': form,
'ids': queryset,
})



and added did the following in the template:

{% for id in ids %}
<input type="hidden" name="_selected_action" value="{{id.id}}">
{% endfor %}
#21. Posted by Zach on Mon 04 Apr 2011, 22:06

charming explanation, thanks. #22. Posted by Erman Doser (Website) on Sun 01 May 2011, 18:29

Hi! I was interested by the last screen-shot of your article (http://www.jpichon.net/site_media/images/2010/django-admin-action03.png). On the top of the admin site (under search box) is list of days displayed. How you do that? Does it work like a normal filter in django admin or it is customized pagination displayed on the top? thanks #23. Posted by Jazzuell on Thu 14 Jul 2011, 8:53

Hi Jazzuell,

This is a normal Django filter indeed, called date_hierarchy -> https://docs.djangoproject.com/en/1.3/ref/contrib/admin/#modeladmin-options . Really handy!
#24. Posted by jpichon (Website) on Thu 14 Jul 2011, 9:36

Thanks for the very helpful post. #25. Posted by yugen on Fri 15 Jul 2011, 19:11

Thanks for the tips. #26. Posted by Phoebe on Wed 10 Aug 2011, 11:51

Thanks man, this was create starter! #27. Posted by Troyhy on Thu 18 Aug 2011, 19:23

#13, Should be highlighted and included in the article if possible. Helps a lot! #28. Posted by digitalpbk (Website) on Mon 05 Dec 2011, 14:19

When I try to execute this i get a CSRF error:

Forbidden (403)
CSRF verification failed. Request aborted.
Help
Reason given for failure:
CSRF token missing or incorrect.

In general, this can occur when there is a genuine Cross Site Request Forgery, or when Django's CSRF mechanism has not been used correctly. For POST forms, you need to ensure:
The view function uses RequestContext for the template, instead of Context.
In the template, there is a {% csrf_token %} template tag inside each POST form that targets an internal URL.
If you are not using CsrfViewMiddleware, then you must use csrf_protect on any views that use the csrf_token template tag, as well as those that accept the POST data.
You're seeing the help section of this page because you have DEBUG = True in your Django settings file. Change that to False, and only the initial error message will be displayed.
You can customize this page using the CSRF_FAILURE_VIEW setting.


I then inserted the template token inside the form tag and still same error. I am indeed using the csrf middleware.

Anyone else hit this issue, or can throw me a hint.
#29. Posted by Matt on Tue 10 Apr 2012, 5:03

Solved my post #29.

In case anyone else has this issue:
1- insert your template csrf token inside the form tag in the template.
2- do something like this in your action:

from django.core.context_processors import csrf
data = {'students': queryset, 'email_form': form}
data.update(csrf(request))

now you can use that data dictionary in your render to the template.
#30. Posted by Matt on Tue 10 Apr 2012, 5:41

when calling render_to_context you can pass context_instance=RequestContext(request) instead of the csrf hack that Matt suggests above. #31. Posted by Rob on Thu 17 May 2012, 1:20

Adding on to what Zach posted regarding the 100+ limit (https://code.djangoproject.com/ticket/15742), you can do something a little simpler using list comprehensions to keep everything contained within the form.

form = MyForm(inital={'_selected_action': [x.id for x in queryset]})

Then the simple {{form}} in the template will be sufficient to maintain the state and the logic is contained properly within the view.
#32. Posted by OpenID https://me.yahoo.com/a/16.HbEgI0eQhENUhnC2ce_10ZSmR on Wed 30 May 2012, 16:53

form = MyForm(inital={'_selected_action': queryset.values_list('id', flat=True)})

Is more elegant :)
#33. Posted by Bas (Website) on Wed 20 Jun 2012, 14:25

Perfect! you saved my day! :) #34. Posted by Julio on Thu 08 Nov 2012, 1:47

An update...?

Any chance you could update this example to take into account the CRSF needs of Django 1.4. I have added RequestContext(request) as an extra parameter to the render_to_response function.

However (and I am not sure if this is related) the "if 'apply' in request.POST"never returns True. When I print the contents of the request.POST it shows a nunber of fields in the QueryDict, but not 'apply'.
#35. Posted by Derek on Fri 28 Dec 2012, 10:50

Hi Derek,

I'm afraid I don't have time to work on an update to this post, but if you do a write-up for a more recent Django version I'll be happy to link to it.
#36. Posted by jpichon (Website) on Sat 19 Jan 2013, 21:06

Comments have been disabled.

< Back to main | Up >