Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP

Loading…

Ticket #18914 - Object tool customization #328

Closed
wants to merge 4 commits into from

3 participants

@mcrute

This branch adds the ability to easily customize object tools within the admin in much the same way that actions can be added. It solves the use case where it would be nice to have an admin tool (such as Import Spreadsheet) but doesn't require a user to subclass ModelAdmin and maintain additional templates in their project.

@mjtamlyn
Collaborator

+1 to this feature, I've wanted it before a few times. Does this have a ticket?

@mcrute

I added ticket 18914 to cover this.

@timgraham
Owner

This looks like in introduces quite a bit of backwards incompatibility (for example, moving add_view to a new class/file). As noted in the ticket it also needs tests. Please open a new PR if you can address these concerns. Thanks!

@timgraham timgraham closed this
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
This page is out of date. Refresh to see the latest.
View
157 django/contrib/admin/list_tools.py
@@ -0,0 +1,157 @@
+from django.contrib.admin import helpers
+from django.core.exceptions import PermissionDenied
+from django.core.urlresolvers import reverse
+from django.db import models, transaction, router
+from django.forms.formsets import all_valid
+from django.http import Http404, HttpResponse, HttpResponseRedirect
+from django.utils.decorators import method_decorator
+from django.utils.encoding import force_text
+from django.utils.html import escape, escapejs
+from django.utils.translation import ugettext as _
+from django.utils.translation import ugettext_lazy
+from django.views.decorators.csrf import csrf_protect
+
+
+csrf_protect_m = method_decorator(csrf_protect)
+
+
+class AddItem(object):
+ """
+ The 'add' admin view for a model.
+ """
+
+ short_description = ugettext_lazy("Add %(verbose_name)s")
+ css_class = "addlink"
+ url = r'^add/$'
+ name = 'add'
+
+ def __call__(self, admin, request, *args, **kwargs):
+ self.admin = admin
+ return self.add_view(request, *args, **kwargs)
+
+ def response_add(self, request, obj, post_url_continue='../%s/'):
+ """
+ Determines the HttpResponse for the add_view stage.
+ """
+ opts = obj._meta
+ pk_value = obj._get_pk_val()
+
+ msg = _('The %(name)s "%(obj)s" was added successfully.') % {'name': force_text(opts.verbose_name), 'obj': force_text(obj)}
+ # Here, we distinguish between different save types by checking for
+ # the presence of keys in request.POST.
+ if "_continue" in request.POST:
+ self.admin.message_user(request, msg + ' ' + _("You may edit it again below."))
+ if "_popup" in request.POST:
+ post_url_continue += "?_popup=1"
+ return HttpResponseRedirect(post_url_continue % pk_value)
+
+ if "_popup" in request.POST:
+ return HttpResponse(
+ '<!DOCTYPE html><html><head><title></title></head><body>'
+ '<script type="text/javascript">opener.dismissAddAnotherPopup(window, "%s", "%s");</script></body></html>' % \
+ # escape() calls force_text.
+ (escape(pk_value), escapejs(obj)))
+ elif "_addanother" in request.POST:
+ self.admin.message_user(request, msg + ' ' + (_("You may add another %s below.") % force_text(opts.verbose_name)))
+ return HttpResponseRedirect(request.path)
+ else:
+ self.admin.message_user(request, msg)
+
+ # Figure out where to redirect. If the user has change permission,
+ # redirect to the change-list page for this object. Otherwise,
+ # redirect to the admin index.
+ if self.admin.has_change_permission(request, None):
+ post_url = reverse('admin:%s_%s_changelist' %
+ (opts.app_label, opts.module_name),
+ current_app=self.admin.admin_site.name)
+ else:
+ post_url = reverse('admin:index',
+ current_app=self.admin.admin_site.name)
+ return HttpResponseRedirect(post_url)
+
+ @csrf_protect_m
+ @transaction.commit_on_success
+ def add_view(self, request, form_url='', extra_context=None):
+ "The 'add' admin view for this model."
+ model = self.admin.model
+ opts = model._meta
+
+ if not self.admin.has_add_permission(request):
+ raise PermissionDenied
+
+ ModelForm = self.admin.get_form(request)
+ formsets = []
+ inline_instances = self.admin.get_inline_instances(request)
+ if request.method == 'POST':
+ form = ModelForm(request.POST, request.FILES)
+ if form.is_valid():
+ new_object = self.admin.save_form(request, form, change=False)
+ form_validated = True
+ else:
+ form_validated = False
+ new_object = self.admin.model()
+ prefixes = {}
+ for FormSet, inline in zip(self.admin.get_formsets(request), inline_instances):
+ prefix = FormSet.get_default_prefix()
+ prefixes[prefix] = prefixes.get(prefix, 0) + 1
+ if prefixes[prefix] != 1 or not prefix:
+ prefix = "%s-%s" % (prefix, prefixes[prefix])
+ formset = FormSet(data=request.POST, files=request.FILES,
+ instance=new_object,
+ save_as_new="_saveasnew" in request.POST,
+ prefix=prefix, queryset=inline.queryset(request))
+ formsets.append(formset)
+ if all_valid(formsets) and form_validated:
+ self.admin.save_model(request, new_object, form, False)
+ self.admin.save_related(request, form, formsets, False)
+ self.admin.log_addition(request, new_object)
+ return self.response_add(request, new_object)
+ else:
+ # Prepare the dict of initial data from the request.
+ # We have to special-case M2Ms as a list of comma-separated PKs.
+ initial = dict(request.GET.items())
+ for k in initial:
+ try:
+ f = opts.get_field(k)
+ except models.FieldDoesNotExist:
+ continue
+ if isinstance(f, models.ManyToManyField):
+ initial[k] = initial[k].split(",")
+ form = ModelForm(initial=initial)
+ prefixes = {}
+ for FormSet, inline in zip(self.admin.get_formsets(request), inline_instances):
+ prefix = FormSet.get_default_prefix()
+ prefixes[prefix] = prefixes.get(prefix, 0) + 1
+ if prefixes[prefix] != 1 or not prefix:
+ prefix = "%s-%s" % (prefix, prefixes[prefix])
+ formset = FormSet(instance=self.admin.model(), prefix=prefix,
+ queryset=inline.queryset(request))
+ formsets.append(formset)
+
+ adminForm = helpers.AdminForm(form, list(self.admin.get_fieldsets(request)),
+ self.admin.get_prepopulated_fields(request),
+ self.admin.get_readonly_fields(request),
+ model_admin=self.admin)
+ media = self.admin.media + adminForm.media
+
+ inline_admin_formsets = []
+ for inline, formset in zip(inline_instances, formsets):
+ fieldsets = list(inline.get_fieldsets(request))
+ readonly = list(inline.get_readonly_fields(request))
+ prepopulated = dict(inline.get_prepopulated_fields(request))
+ inline_admin_formset = helpers.InlineAdminFormSet(inline, formset,
+ fieldsets, prepopulated, readonly, model_admin=self.admin)
+ inline_admin_formsets.append(inline_admin_formset)
+ media = media + inline_admin_formset.media
+
+ context = {
+ 'title': _('Add %s') % force_text(opts.verbose_name),
+ 'adminform': adminForm,
+ 'is_popup': "_popup" in request.REQUEST,
+ 'media': media,
+ 'inline_admin_formsets': inline_admin_formsets,
+ 'errors': helpers.AdminErrorList(form, formsets),
+ 'app_label': opts.app_label,
+ }
+ context.update(extra_context or {})
+ return self.admin.render_change_form(request, context, form_url=form_url, add=True)
View
238 django/contrib/admin/options.py
@@ -5,7 +5,7 @@
from django.forms.models import (modelform_factory, modelformset_factory,
inlineformset_factory, BaseInlineFormSet)
from django.contrib.contenttypes.models import ContentType
-from django.contrib.admin import widgets, helpers
+from django.contrib.admin import widgets, helpers, list_tools
from django.contrib.admin.util import unquote, flatten_fieldsets, get_deleted_objects, model_format_dict
from django.contrib.admin.templatetags.admin_static import static
from django.contrib import messages
@@ -337,6 +337,9 @@ class ModelAdmin(BaseModelAdmin):
actions_on_bottom = False
actions_selection_counter = True
+ # Object Tools
+ list_tools = []
+
def __init__(self, model, admin_site):
self.model = model
self.opts = model._meta
@@ -358,6 +361,28 @@ def get_inline_instances(self, request):
return inline_instances
+ def get_object_tool_urls(self):
+ """
+ Generates URLs for each object tool that is registered and wraps the
+ view so that it gets called with the admin instance as its first
+ parameter.
+ """
+ from django.conf.urls import patterns, url
+
+ def wrap(view):
+ def wrapper(*args, **kwargs):
+ return view(self, *args, **kwargs)
+ return self.admin_site.admin_view(wrapper)
+
+ info = self.model._meta.app_label, self.model._meta.module_name
+
+ return patterns('', *(
+ url(object_tool[4],
+ wrap(object_tool[0]),
+ name='%s_%s_%s' % (info + (name,)))
+ for name, object_tool in self.get_list_tools().items()
+ ))
+
def get_urls(self):
from django.conf.urls import patterns, url
@@ -372,19 +397,21 @@ def wrapper(*args, **kwargs):
url(r'^$',
wrap(self.changelist_view),
name='%s_%s_changelist' % info),
- url(r'^add/$',
- wrap(self.add_view),
- name='%s_%s_add' % info),
url(r'^(.+)/history/$',
wrap(self.history_view),
name='%s_%s_history' % info),
url(r'^(.+)/delete/$',
wrap(self.delete_view),
name='%s_%s_delete' % info),
+ )
+
+ urlpatterns += self.get_object_tool_urls()
+ urlpatterns += [
url(r'^(.+)/$',
wrap(self.change_view),
name='%s_%s_change' % info),
- )
+ ]
+
return urlpatterns
def urls(self):
@@ -564,6 +591,69 @@ def action_checkbox(self, obj):
action_checkbox.short_description = mark_safe('<input type="checkbox" id="action-toggle" />')
action_checkbox.allow_tags = True
+ def get_list_tools(self, request=None):
+ """
+ Return a dictionary mapping the names of all list object tools for this
+ ModelAdmin to a tuple of (callable, name, description, css_class, url)
+ for each object tool.
+
+ This gets called once when the URLs get setup so it may not always be
+ given a request.
+ """
+ list_tools = []
+
+ # Then gather them from the model admin and all parent classes,
+ # starting with self and working back up.
+ for klass in self.__class__.mro()[::-1]:
+ class_tools = getattr(klass, 'list_tools', [])
+ # Avoid trying to iterate over None
+ if not class_tools:
+ continue
+ list_tools.extend(self.get_list_tool(tool) for tool in class_tools)
+
+ for (name, func) in self.admin_site.list_tools:
+ list_tools.append(self.get_list_tool(func))
+
+ list_tools = filter(None, list_tools)
+
+ return SortedDict([
+ (name, (func, name, desc, css, url))
+ for func, name, desc, css, url in list_tools
+ ])
+
+ def get_list_tool(self, tool):
+ """
+ Return a given list object tool from a parameter, which can either be a
+ callable, or the name of a method on the ModelAdmin. Return is a tuple
+ of (callable, name, description, css class, url).
+ """
+ if callable(tool):
+ func = tool
+ name = getattr(tool, 'name')
+
+ if not name:
+ if hasattr(tool, '__name__'):
+ name = tool.__name__
+ elif hasattr(tool, '__class__'):
+ name = tool.__class__.__name__
+
+ elif hasattr(self.__class__, tool):
+ name = tool
+ func = getattr(self.__class__, tool)
+ else:
+ try:
+ func = self.admin_site.get_list_tool(tool)
+ except KeyError:
+ return None
+
+ description = (
+ getattr(func, 'short_description', name.replace('_', ' ')) %
+ model_format_dict(self.opts))
+ css_class = getattr(func, 'css_class', '')
+ url = getattr(func, 'url', "^{0}/$".format(name))
+
+ return func, name, description, css_class, url
+
def get_actions(self, request):
"""
Return a dictionary mapping the names of all actions for this
@@ -762,46 +852,6 @@ def render_change_form(self, request, context, add=False, change=False, form_url
"admin/change_form.html"
], context, current_app=self.admin_site.name)
- def response_add(self, request, obj, post_url_continue='../%s/'):
- """
- Determines the HttpResponse for the add_view stage.
- """
- opts = obj._meta
- pk_value = obj._get_pk_val()
-
- msg = _('The %(name)s "%(obj)s" was added successfully.') % {'name': force_text(opts.verbose_name), 'obj': force_text(obj)}
- # Here, we distinguish between different save types by checking for
- # the presence of keys in request.POST.
- if "_continue" in request.POST:
- self.message_user(request, msg + ' ' + _("You may edit it again below."))
- if "_popup" in request.POST:
- post_url_continue += "?_popup=1"
- return HttpResponseRedirect(post_url_continue % pk_value)
-
- if "_popup" in request.POST:
- return HttpResponse(
- '<!DOCTYPE html><html><head><title></title></head><body>'
- '<script type="text/javascript">opener.dismissAddAnotherPopup(window, "%s", "%s");</script></body></html>' % \
- # escape() calls force_text.
- (escape(pk_value), escapejs(obj)))
- elif "_addanother" in request.POST:
- self.message_user(request, msg + ' ' + (_("You may add another %s below.") % force_text(opts.verbose_name)))
- return HttpResponseRedirect(request.path)
- else:
- self.message_user(request, msg)
-
- # Figure out where to redirect. If the user has change permission,
- # redirect to the change-list page for this object. Otherwise,
- # redirect to the admin index.
- if self.has_change_permission(request, None):
- post_url = reverse('admin:%s_%s_changelist' %
- (opts.app_label, opts.module_name),
- current_app=self.admin_site.name)
- else:
- post_url = reverse('admin:index',
- current_app=self.admin_site.name)
- return HttpResponseRedirect(post_url)
-
def response_change(self, request, obj):
"""
Determines the HttpResponse for the change_view stage.
@@ -921,93 +971,6 @@ def response_action(self, request, queryset):
@csrf_protect_m
@transaction.commit_on_success
- def add_view(self, request, form_url='', extra_context=None):
- "The 'add' admin view for this model."
- model = self.model
- opts = model._meta
-
- if not self.has_add_permission(request):
- raise PermissionDenied
-
- ModelForm = self.get_form(request)
- formsets = []
- inline_instances = self.get_inline_instances(request)
- if request.method == 'POST':
- form = ModelForm(request.POST, request.FILES)
- if form.is_valid():
- new_object = self.save_form(request, form, change=False)
- form_validated = True
- else:
- form_validated = False
- new_object = self.model()
- prefixes = {}
- for FormSet, inline in zip(self.get_formsets(request), inline_instances):
- prefix = FormSet.get_default_prefix()
- prefixes[prefix] = prefixes.get(prefix, 0) + 1
- if prefixes[prefix] != 1 or not prefix:
- prefix = "%s-%s" % (prefix, prefixes[prefix])
- formset = FormSet(data=request.POST, files=request.FILES,
- instance=new_object,
- save_as_new="_saveasnew" in request.POST,
- prefix=prefix, queryset=inline.queryset(request))
- formsets.append(formset)
- if all_valid(formsets) and form_validated:
- self.save_model(request, new_object, form, False)
- self.save_related(request, form, formsets, False)
- self.log_addition(request, new_object)
- return self.response_add(request, new_object)
- else:
- # Prepare the dict of initial data from the request.
- # We have to special-case M2Ms as a list of comma-separated PKs.
- initial = dict(request.GET.items())
- for k in initial:
- try:
- f = opts.get_field(k)
- except models.FieldDoesNotExist:
- continue
- if isinstance(f, models.ManyToManyField):
- initial[k] = initial[k].split(",")
- form = ModelForm(initial=initial)
- prefixes = {}
- for FormSet, inline in zip(self.get_formsets(request), inline_instances):
- prefix = FormSet.get_default_prefix()
- prefixes[prefix] = prefixes.get(prefix, 0) + 1
- if prefixes[prefix] != 1 or not prefix:
- prefix = "%s-%s" % (prefix, prefixes[prefix])
- formset = FormSet(instance=self.model(), prefix=prefix,
- queryset=inline.queryset(request))
- formsets.append(formset)
-
- adminForm = helpers.AdminForm(form, list(self.get_fieldsets(request)),
- self.get_prepopulated_fields(request),
- self.get_readonly_fields(request),
- model_admin=self)
- media = self.media + adminForm.media
-
- inline_admin_formsets = []
- for inline, formset in zip(inline_instances, formsets):
- fieldsets = list(inline.get_fieldsets(request))
- readonly = list(inline.get_readonly_fields(request))
- prepopulated = dict(inline.get_prepopulated_fields(request))
- inline_admin_formset = helpers.InlineAdminFormSet(inline, formset,
- fieldsets, prepopulated, readonly, model_admin=self)
- inline_admin_formsets.append(inline_admin_formset)
- media = media + inline_admin_formset.media
-
- context = {
- 'title': _('Add %s') % force_text(opts.verbose_name),
- 'adminform': adminForm,
- 'is_popup': "_popup" in request.REQUEST,
- 'media': media,
- 'inline_admin_formsets': inline_admin_formsets,
- 'errors': helpers.AdminErrorList(form, formsets),
- 'app_label': opts.app_label,
- }
- context.update(extra_context or {})
- return self.render_change_form(request, context, form_url=form_url, add=True)
-
- @csrf_protect_m
- @transaction.commit_on_success
def change_view(self, request, object_id, form_url='', extra_context=None):
"The 'change' admin view for this model."
model = self.model
@@ -1022,9 +985,10 @@ def change_view(self, request, object_id, form_url='', extra_context=None):
raise Http404(_('%(name)s object with primary key %(key)r does not exist.') % {'name': force_text(opts.verbose_name), 'key': escape(object_id)})
if request.method == 'POST' and "_saveasnew" in request.POST:
- return self.add_view(request, form_url=reverse('admin:%s_%s_add' %
- (opts.app_label, opts.module_name),
- current_app=self.admin_site.name))
+ return list_tools.AddItem()(self, request,
+ form_url=reverse('admin:%s_%s_add' %
+ (opts.app_label, opts.module_name),
+ current_app=self.admin_site.name))
ModelForm = self.get_form(request, obj)
formsets = []
@@ -1118,6 +1082,9 @@ def changelist_view(self, request, extra_context=None):
# Add the action checkboxes if there are any actions available.
list_display = ['action_checkbox'] + list(list_display)
+ # Only give the view the bits it needs
+ list_tools = [t[1:4] for t in self.get_list_tools(request).values()]
+
ChangeList = self.get_changelist(request)
try:
cl = ChangeList(request, self.model, list_display,
@@ -1234,6 +1201,7 @@ def changelist_view(self, request, extra_context=None):
'title': cl.title,
'is_popup': cl.is_popup,
'cl': cl,
+ 'object_tools': list_tools,
'media': media,
'has_add_permission': self.has_add_permission(request),
'app_label': app_label,
View
32 django/contrib/admin/sites.py
@@ -1,6 +1,6 @@
from functools import update_wrapper
from django.http import Http404, HttpResponseRedirect
-from django.contrib.admin import ModelAdmin, actions
+from django.contrib.admin import ModelAdmin, actions, list_tools
from django.contrib.admin.forms import AdminAuthenticationForm
from django.contrib.auth import REDIRECT_FIELD_NAME
from django.contrib.contenttypes import views as contenttype_views
@@ -46,6 +46,8 @@ def __init__(self, name='admin', app_name='admin'):
self.app_name = app_name
self._actions = {'delete_selected': actions.delete_selected}
self._global_actions = self._actions.copy()
+ self._list_tools = { 'add': list_tools.AddItem() }
+ self._global_list_tools = self._list_tools.copy()
def register(self, model_or_iterable, admin_class=None, **options):
"""
@@ -136,6 +138,34 @@ def actions(self):
"""
return six.iteritems(self._actions)
+ def add_list_tool(self, tool, name=None):
+ """
+ Register a list tool to be available globally.
+ """
+ name = name or tool.__name__
+ self._list_tools[name] = tool
+
+ def disable_list_tool(self, name):
+ """
+ Disable a globally-registered list tool. Raises KeyError for invalid
+ names.
+ """
+ del self._list_tools[name]
+
+ def get_list_tool(self, name):
+ """
+ Explicitally get a registered global list too wheather it's enabled or
+ not. Raises KeyError for invalid names.
+ """
+ return self._global_list_tools[name]
+
+ @property
+ def list_tools(self):
+ """
+ Get all the enabled list tools as an iterable of (name, func).
+ """
+ return six.iteritems(self._list_tools)
+
def has_permission(self, request):
"""
Returns True if the given HttpRequest has permission to view
View
6 django/contrib/admin/templates/admin/change_list.html
@@ -53,11 +53,11 @@
{% if has_add_permission %}
<ul class="object-tools">
{% block object-tools-items %}
+ {% for name, desc, css_class in object_tools %}
<li>
- <a href="{% url cl.opts|admin_urlname:'add' %}{% if is_popup %}?_popup=1{% endif %}" class="addlink">
- {% blocktrans with cl.opts.verbose_name as name %}Add {{ name }}{% endblocktrans %}
- </a>
+ <a href="{% url cl.opts|admin_urlname:name %}{% if is_popup %}?_popup=1{% endif %}" {% if css_class %}class="{{ css_class }}"{% endif %}>{{ desc }}</a>
</li>
+ {% endfor %}
{% endblock %}
</ul>
{% endif %}
View
1  docs/index.txt
@@ -193,6 +193,7 @@ most popular features:
* :doc:`Admin site <ref/contrib/admin/index>`
* :doc:`Admin actions <ref/contrib/admin/actions>`
+* :doc:`Admin object tools <ref/contrib/admin/object-tools>`
* :doc:`Admin documentation generator<ref/contrib/admin/admindocs>`
Security
View
33 docs/obsolete/admin-css.txt
@@ -114,39 +114,6 @@ float-right
clear
clears all
-Object Tools
-============
-
-Certain actions which apply directly to an object are used in form and
-changelist pages. These appear in a "toolbar" row above the form or changelist,
-to the right of the page. The tools are wrapped in a ``ul`` with the class
-``object-tools``. There are two custom tool types which can be defined with an
-additional class on the ``a`` for that tool. These are ``.addlink`` and
-``.viewsitelink``.
-
-Example from a changelist page:
-
-.. code-block:: html+django
-
- <ul class="object-tools">
- <li><a href="/stories/add/" class="addlink">Add redirect</a></li>
- </ul>
-
-.. image:: _images/objecttools_01.png
- :alt: Object tools on a changelist page
-
-and from a form page:
-
-.. code-block:: html+django
-
- <ul class="object-tools">
- <li><a href="/history/303/152383/">History</a></li>
- <li><a href="/r/303/152383/" class="viewsitelink">View on site</a></li>
- </ul>
-
-.. image:: _images/objecttools_02.png
- :alt: Object tools on a form page
-
Form Styles
===========
View
0  docs/obsolete/_images/objecttools_01.png → .../contrib/admin/_images/objecttools_01.png
File renamed without changes
View
0  docs/obsolete/_images/objecttools_02.png → .../contrib/admin/_images/objecttools_02.png
File renamed without changes
View
1  docs/ref/contrib/admin/index.txt
@@ -53,6 +53,7 @@ Other topics
:maxdepth: 1
actions
+ object-tools
admindocs
.. seealso::
View
108 docs/ref/contrib/admin/object-tools.txt
@@ -0,0 +1,108 @@
+============
+Object Tools
+============
+
+.. currentmodule:: django.contrib.admin
+
+Certain actions apply directly to a model but not individual models or
+collections of models. These appear in a "toolbar" row above the form or
+changelist, to the right of the page. These buttons are called object tools.
+
+In the change list object tools are very similar to :doc:`Admin actions
+<actions>` but are intended to be used for creation of objects or manipulations
+that don't require individual model objects or querysets.
+
+Writing object tools
+====================
+
+Object tools are just Django views that get automatically wrapped in permission
+checks and added to the admin urlconfs for a model. Object tools are just
+regular functions that take two arguments:
+
+* The current :class:`ModelAdmin`
+* An :class:`~django.http.HttpRequest` representing the current request,
+
+A simple object tool that displays a template in the admin would look like
+this::
+
+ def polls_by_user(modeladmin, request, **kwargs):
+ return render(request, "admin/reports/polls_by_user.html", {
+ 'polls': Poll.objects.all(),
+ })
+
+This will show up as "polls by user" in the object tools bar which works but we
+should probably provide a more human-friendly name by giving the function a
+``short_description`` attribute::
+
+ def polls_by_user(modeladmin, request, **kwargs):
+ return render(request, "admin/reports/polls_by_user.html", {
+ 'polls': Poll.objects.all(),
+ })
+ polls_by_user.short_description = "User Poll Report"
+
+Adding object tools to the :class:`ModelAdmin`
+----------------------------------------------
+
+Next, we'll need to inform our :class:`ModelAdmin` of the object tool. This
+works just like any other configuration option. So, the complete ``admin.py``
+with the action and its registration would look like::
+
+ from django.contrib import admin
+ from myapp.models import Poll
+
+ def polls_by_user(modeladmin, request, **kwargs):
+ return render(request, "admin/reports/polls_by_user.html", {
+ 'polls': Poll.objects.all(),
+ })
+ polls_by_user.short_description = "User Poll Report"
+
+ class PollAdmin(admin.ModelAdmin):
+ actions = [polls_by_user]
+
+ admin.site.register(Poll, PollAdmin)
+
+CSS classes
+-----------
+
+By default an admin tool is added to the template with no CSS class, which will
+render a simple object tool with no additional decoration. However, the default
+admin CSS provides two decorations for object tools. The add button has a CSS
+class of ``.addlink`` and looks like this:
+
+.. image:: _images/objecttools_01.png
+ :alt: Object tools on a changelist page
+
+The view button has a class of ``.viewsitelink`` and looks like this:
+
+.. image:: _images/objecttools_02.png
+ :alt: Object tools on a form page
+
+To add custom CSS classes to object too buttons just add a ``css_class``
+attribute to the method like this::
+
+ def add(modeladmin, request, **kwargs):
+ return render(request, "admin/redirect/add_form.html", {
+ 'form': RedirectForm(),
+ })
+ add.short_description = "Add redirect"
+ add.css_class = "addlink"
+
+Customizing urlconf
+-------------------
+
+Unless specifically specified a urlconf will be generated for the object tool
+method under the current model's admin urlconfs. The name of the urlconf is
+``admin_{model}_{method_name}`` and the URL for the will be the current
+URL for the change list plus the method's name. In our reporting example this
+would be http://localhost:8000/admin/polls/poll/polls_by_user/
+
+It is possible to specify a custom urlconf by adding a ``url`` method to the
+object tool method::
+
+ def add(modeladmin, request, **kwargs):
+ return render(request, "admin/redirect/add_form.html", {
+ 'form': RedirectForm(),
+ })
+ add.short_description = "Add redirect"
+ add.css_class = "addlink"
+ add.url = r"^add_poll/$"
Something went wrong with that request. Please try again.