Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Fixed #10505: added support for bulk admin actions, including a globa…

…lly-available "delete selected" action. See the documentation for details.

This work started life as Brian Beck's "django-batchadmin." It was rewritten for inclusion in Django by Alex Gaynor, Jannis Leidel (jezdez), and Martin Mahner (bartTC). Thanks, guys!

git-svn-id: http://code.djangoproject.com/svn/django/trunk@10121 bcc190cf-cafb-0310-a4f2-bffc1f526a37
  • Loading branch information...
commit 44f3080226888eb709cc6e027321647964ebe64e 1 parent 4e25334
Jacob Kaplan-Moss jacobian authored
Showing with 881 additions and 108 deletions.
  1. +2 −0  AUTHORS
  2. +1 −0  django/contrib/admin/__init__.py
  3. +13 −5 django/contrib/admin/helpers.py
  4. +44 −0 django/contrib/admin/media/css/changelists.css
  5. +19 −0 django/contrib/admin/media/js/actions.js
  6. +191 −3 django/contrib/admin/options.py
  7. +66 −59 django/contrib/admin/sites.py
  8. +5 −0 django/contrib/admin/templates/admin/actions.html
  9. +8 −4 django/contrib/admin/templates/admin/change_list.html
  10. +37 −0 django/contrib/admin/templates/admin/delete_selected_confirmation.html
  11. +9 −0 django/contrib/admin/templatetags/admin_list.py
  12. +78 −13 django/contrib/admin/util.py
  13. +12 −3 django/contrib/admin/validation.py
  14. +1 −1  docs/index.txt
  15. BIN  docs/ref/contrib/admin/_images/article_actions.png
  16. BIN  docs/ref/contrib/admin/_images/article_actions_message.png
  17. 0  docs/ref/contrib/{ → admin}/_images/flatfiles_admin.png
  18. BIN  docs/ref/contrib/admin/_images/user_actions.png
  19. 0  docs/ref/contrib/{ → admin}/_images/users_changelist.png
  20. +239 −0 docs/ref/contrib/admin/actions.txt
  21. +21 −2 docs/ref/contrib/{admin.txt → admin/index.txt}
  22. +1 −1  docs/ref/contrib/index.txt
  23. +1 −1  tests/regressiontests/admin_registration/models.py
  24. +15 −0 tests/regressiontests/admin_views/fixtures/admin-views-actions.xml
  25. +38 −0 tests/regressiontests/admin_views/models.py
  26. +80 −16 tests/regressiontests/admin_views/tests.py
2  AUTHORS
View
@@ -56,6 +56,7 @@ answer newbie questions, and generally made Django that much better:
Ned Batchelder <http://www.nedbatchelder.com/>
batiste@dosimple.ch
Batman
+ Brian Beck <http://blog.brianbeck.com/>
Shannon -jj Behrens <http://jjinux.blogspot.com/>
Esdras Beleza <linux@esdrasbeleza.com>
Chris Bennett <chrisrbennett@yahoo.com>
@@ -268,6 +269,7 @@ answer newbie questions, and generally made Django that much better:
Daniel Lindsley <polarcowz@gmail.com>
Trey Long <trey@ktrl.com>
msaelices <msaelices@gmail.com>
+ Martin Mahner <http://www.mahner.org/>
Matt McClanahan <http://mmcc.cx/>
Frantisek Malina <vizualbod@vizualbod.com>
Martin Maney <http://www.chipy.org/Martin_Maney>
1  django/contrib/admin/__init__.py
View
@@ -1,3 +1,4 @@
+from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
from django.contrib.admin.options import ModelAdmin, HORIZONTAL, VERTICAL
from django.contrib.admin.options import StackedInline, TabularInline
from django.contrib.admin.sites import AdminSite, site
18 django/contrib/admin/helpers.py
View
@@ -6,6 +6,14 @@
from django.utils.encoding import force_unicode
from django.contrib.admin.util import flatten_fieldsets
from django.contrib.contenttypes.models import ContentType
+from django.utils.translation import ugettext_lazy as _
+
+ACTION_CHECKBOX_NAME = '_selected_action'
+
+class ActionForm(forms.Form):
+ action = forms.ChoiceField(label=_('Action:'))
+
+checkbox = forms.CheckboxInput({'class': 'action-select'}, lambda value: False)
class AdminForm(object):
def __init__(self, form, fieldsets, prepopulated_fields):
@@ -132,11 +140,11 @@ def __init__(self, formset, form, fieldsets, prepopulated_fields, original):
self.original.content_type_id = ContentType.objects.get_for_model(original).pk
self.show_url = original and hasattr(original, 'get_absolute_url')
super(InlineAdminForm, self).__init__(form, fieldsets, prepopulated_fields)
-
+
def __iter__(self):
for name, options in self.fieldsets:
yield InlineFieldset(self.formset, self.form, name, **options)
-
+
def field_count(self):
# tabular.html uses this function for colspan value.
num_of_fields = 1 # always has at least one field
@@ -149,7 +157,7 @@ def field_count(self):
def pk_field(self):
return AdminField(self.form, self.formset._pk_field.name, False)
-
+
def fk_field(self):
fk = getattr(self.formset, "fk", None)
if fk:
@@ -169,14 +177,14 @@ class InlineFieldset(Fieldset):
def __init__(self, formset, *args, **kwargs):
self.formset = formset
super(InlineFieldset, self).__init__(*args, **kwargs)
-
+
def __iter__(self):
fk = getattr(self.formset, "fk", None)
for field in self.fields:
if fk and fk.name == field:
continue
yield Fieldline(self.form, field)
-
+
class AdminErrorList(forms.util.ErrorList):
"""
Stores all errors for the form/formsets in an add/change stage view.
44 django/contrib/admin/media/css/changelists.css
View
@@ -50,12 +50,24 @@
#changelist table thead th {
white-space: nowrap;
+ vertical-align: middle;
+}
+
+#changelist table thead th:first-child {
+ width: 1.5em;
+ text-align: center;
}
#changelist table tbody td {
border-left: 1px solid #ddd;
}
+#changelist table tbody td:first-child {
+ border-left: 0;
+ border-right: 1px solid #ddd;
+ text-align: center;
+}
+
#changelist table tfoot {
color: #666;
}
@@ -209,3 +221,35 @@
border-color: #036;
}
+/* ACTIONS */
+
+.filtered .actions {
+ margin-right: 160px !important;
+ border-right: 1px solid #ddd;
+}
+
+#changelist .actions {
+ color: #666;
+ padding: 3px;
+ border-bottom: 1px solid #ddd;
+ background: #e1e1e1 url(../img/admin/nav-bg.gif) top left repeat-x;
+}
+
+#changelist .actions:last-child {
+ border-bottom: none;
+}
+
+#changelist .actions select {
+ border: 1px solid #aaa;
+ margin: 0 0.5em;
+ padding: 1px 2px;
+}
+
+#changelist .actions label {
+ font-size: 11px;
+ margin: 0 0.5em;
+}
+
+#changelist #action-toggle {
+ display: none;
+}
19 django/contrib/admin/media/js/actions.js
View
@@ -0,0 +1,19 @@
+var Actions = {
+ init: function() {
+ selectAll = document.getElementById('action-toggle');
+ if (selectAll) {
+ selectAll.style.display = 'inline';
+ addEvent(selectAll, 'change', function() {
+ Actions.checker(this.checked);
+ });
+ }
+ },
+ checker: function(checked) {
+ actionCheckboxes = document.getElementsBySelector('tr input.action-select');
+ for(var i = 0; i < actionCheckboxes.length; i++) {
+ actionCheckboxes[i].checked = checked;
+ }
+ }
+}
+
+addEvent(window, 'load', Actions.init);
194 django/contrib/admin/options.py
View
@@ -5,9 +5,10 @@
from django.contrib.contenttypes.models import ContentType
from django.contrib.admin import widgets
from django.contrib.admin import helpers
-from django.contrib.admin.util import unquote, flatten_fieldsets, get_deleted_objects
+from django.contrib.admin.util import unquote, flatten_fieldsets, get_deleted_objects, model_ngettext, model_format_dict
from django.core.exceptions import PermissionDenied
from django.db import models, transaction
+from django.db.models.fields import BLANK_CHOICE_DASH
from django.http import Http404, HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404, render_to_response
from django.utils.functional import update_wrapper
@@ -16,7 +17,7 @@
from django.utils.functional import curry
from django.utils.text import capfirst, get_text_list
from django.utils.translation import ugettext as _
-from django.utils.translation import ngettext
+from django.utils.translation import ngettext, ugettext_lazy
from django.utils.encoding import force_unicode
try:
set
@@ -192,6 +193,12 @@ class ModelAdmin(BaseModelAdmin):
delete_confirmation_template = None
object_history_template = None
+ # Actions
+ actions = ['delete_selected']
+ action_form = helpers.ActionForm
+ actions_on_top = True
+ actions_on_bottom = False
+
def __init__(self, model, admin_site):
self.model = model
self.opts = model._meta
@@ -200,6 +207,13 @@ def __init__(self, model, admin_site):
for inline_class in self.inlines:
inline_instance = inline_class(self.model, self.admin_site)
self.inline_instances.append(inline_instance)
+ if 'action_checkbox' not in self.list_display:
+ self.list_display = ['action_checkbox'] + list(self.list_display)
+ if not self.list_display_links:
+ for name in self.list_display:
+ if name != 'action_checkbox':
+ self.list_display_links = [name]
+ break
super(ModelAdmin, self).__init__()
def get_urls(self):
@@ -239,6 +253,8 @@ def _media(self):
from django.conf import settings
js = ['js/core.js', 'js/admin/RelatedObjectLookups.js']
+ if self.actions:
+ js.extend(['js/getElementsBySelector.js', 'js/actions.js'])
if self.prepopulated_fields:
js.append('js/urlify.js')
if self.opts.get_ordered_objects():
@@ -390,6 +406,121 @@ def log_deletion(self, request, object, object_repr):
action_flag = DELETION
)
+ def action_checkbox(self, obj):
+ """
+ A list_display column containing a checkbox widget.
+ """
+ return helpers.checkbox.render(helpers.ACTION_CHECKBOX_NAME, force_unicode(obj.pk))
+ action_checkbox.short_description = mark_safe('<input type="checkbox" id="action-toggle" />')
+ action_checkbox.allow_tags = True
+
+ def get_actions(self, request=None):
+ """
+ Return a dictionary mapping the names of all actions for this
+ ModelAdmin to a tuple of (callable, name, description) for each action.
+ """
+ actions = {}
+ for klass in [self.admin_site] + self.__class__.mro()[::-1]:
+ for action in getattr(klass, 'actions', []):
+ func, name, description = self.get_action(action)
+ actions[name] = (func, name, description)
+ return actions
+
+ def get_action_choices(self, request=None, default_choices=BLANK_CHOICE_DASH):
+ """
+ Return a list of choices for use in a form object. Each choice is a
+ tuple (name, description).
+ """
+ choices = [] + default_choices
+ for func, name, description in self.get_actions(request).itervalues():
+ choice = (name, description % model_format_dict(self.opts))
+ choices.append(choice)
+ return choices
+
+ def get_action(self, action):
+ """
+ Return a given action from a parameter, which can either be a calable,
+ or the name of a method on the ModelAdmin. Return is a tuple of
+ (callable, name, description).
+ """
+ if callable(action):
+ func = action
+ action = action.__name__
+ elif hasattr(self, action):
+ func = getattr(self, action)
+ if hasattr(func, 'short_description'):
+ description = func.short_description
+ else:
+ description = capfirst(action.replace('_', ' '))
+ return func, action, description
+
+ def delete_selected(self, request, queryset):
+ """
+ Default action which deletes the selected objects.
+
+ In the first step, it displays a confirmation page whichs shows all
+ the deleteable objects or, if the user has no permission one of the
+ related childs (foreignkeys) it displays a "permission denied" message.
+
+ In the second step delete all selected objects and display the change
+ list again.
+ """
+ opts = self.model._meta
+ app_label = opts.app_label
+
+ # Check that the user has delete permission for the actual model
+ if not self.has_delete_permission(request):
+ raise PermissionDenied
+
+ # Populate deletable_objects, a data structure of all related objects that
+ # will also be deleted.
+
+ # deletable_objects must be a list if we want to use '|unordered_list' in the template
+ deletable_objects = []
+ perms_needed = set()
+ i = 0
+ for obj in queryset:
+ deletable_objects.append([mark_safe(u'%s: <a href="%s/">%s</a>' % (escape(force_unicode(capfirst(opts.verbose_name))), obj.pk, escape(obj))), []])
+ get_deleted_objects(deletable_objects[i], perms_needed, request.user, obj, opts, 1, self.admin_site, levels_to_root=2)
+ i=i+1
+
+ # The user has already confirmed the deletion.
+ # Do the deletion and return a None to display the change list view again.
+ if request.POST.get('post'):
+ if perms_needed:
+ raise PermissionDenied
+ n = queryset.count()
+ if n:
+ for obj in queryset:
+ obj_display = force_unicode(obj)
+ self.log_deletion(request, obj, obj_display)
+ queryset.delete()
+ self.message_user(request, _("Successfully deleted %d %s.") % (
+ n, model_ngettext(self.opts, n)
+ ))
+ # Return None to display the change list page again.
+ return None
+
+ context = {
+ "title": _("Are you sure?"),
+ "object_name": force_unicode(opts.verbose_name),
+ "deletable_objects": deletable_objects,
+ 'queryset': queryset,
+ "perms_lacking": perms_needed,
+ "opts": opts,
+ "root_path": self.admin_site.root_path,
+ "app_label": app_label,
+ 'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME,
+ }
+
+ # Display the confirmation page
+ return render_to_response(self.delete_confirmation_template or [
+ "admin/%s/%s/delete_selected_confirmation.html" % (app_label, opts.object_name.lower()),
+ "admin/%s/delete_selected_confirmation.html" % app_label,
+ "admin/delete_selected_confirmation.html"
+ ], context, context_instance=template.RequestContext(request))
+
+ delete_selected.short_description = ugettext_lazy("Delete selected %(verbose_name_plural)s")
def construct_change_message(self, request, form, formsets):
"""
@@ -529,6 +660,48 @@ def response_change(self, request, obj):
self.message_user(request, msg)
return HttpResponseRedirect("../")
+ def response_action(self, request, queryset):
+ """
+ Handle an admin action. This is called if a request is POSTed to the
+ changelist; it returns an HttpResponse if the action was handled, and
+ None otherwise.
+ """
+ # There can be multiple action forms on the page (at the top
+ # and bottom of the change list, for example). Get the action
+ # whose button was pushed.
+ try:
+ action_index = int(request.POST.get('index', 0))
+ except ValueError:
+ action_index = 0
+
+ # Construct the action form.
+ data = request.POST.copy()
+ data.pop(helpers.ACTION_CHECKBOX_NAME, None)
+ data.pop("index", None)
+ action_form = self.action_form(data, auto_id=None)
+ action_form.fields['action'].choices = self.get_action_choices(request)
+
+ # If the form's valid we can handle the action.
+ if action_form.is_valid():
+ action = action_form.cleaned_data['action']
+ func, name, description = self.get_actions(request)[action]
+
+ # Get the list of selected PKs. If nothing's selected, we can't
+ # perform an action on it, so bail.
+ selected = request.POST.getlist(helpers.ACTION_CHECKBOX_NAME)
+ if not selected:
+ return None
+
+ response = func(request, queryset.filter(pk__in=selected))
+
+ # Actions may return an HttpResponse, which will be used as the
+ # response from the POST. If not, we'll be a good little HTTP
+ # citizen and redirect back to the changelist page.
+ if isinstance(response, HttpResponse):
+ return response
+ else:
+ return HttpResponseRedirect(".")
+
def add_view(self, request, form_url='', extra_context=None):
"The 'add' admin view for this model."
model = self.model
@@ -721,6 +894,14 @@ def changelist_view(self, request, extra_context=None):
return render_to_response('admin/invalid_setup.html', {'title': _('Database error')})
return HttpResponseRedirect(request.path + '?' + ERROR_FLAG + '=1')
+ # If the request was POSTed, this might be a bulk action or a bulk edit.
+ # Try to look up an action first, but if this isn't an action the POST
+ # will fall through to the bulk edit check, below.
+ if request.method == 'POST':
+ response = self.response_action(request, queryset=cl.get_query_set())
+ if response:
+ return response
+
# If we're allowing changelist editing, we need to construct a formset
# for the changelist given all the fields to be edited. Then we'll
# use the formset to validate/process POSTed data.
@@ -764,7 +945,11 @@ def changelist_view(self, request, extra_context=None):
if formset:
media = self.media + formset.media
else:
- media = None
+ media = self.media
+
+ # Build the action form and populate it with available actions.
+ action_form = self.action_form(auto_id=None)
+ action_form.fields['action'].choices = self.get_action_choices(request)
context = {
'title': cl.title,
@@ -774,6 +959,9 @@ def changelist_view(self, request, extra_context=None):
'has_add_permission': self.has_add_permission(request),
'root_path': self.admin_site.root_path,
'app_label': app_label,
+ 'action_form': action_form,
+ 'actions_on_top': self.actions_on_top,
+ 'actions_on_bottom': self.actions_on_bottom,
}
context.update(extra_context or {})
return render_to_response(self.change_list_template or [
125 django/contrib/admin/sites.py
View
@@ -28,11 +28,11 @@ class AdminSite(object):
register() method, and the root() method can then be used as a Django view function
that presents a full admin interface for the collection of registered models.
"""
-
+
index_template = None
login_template = None
app_index_template = None
-
+
def __init__(self, name=None):
self._registry = {} # model_class class -> admin_class instance
# TODO Root path is used to calculate urls under the old root() method
@@ -44,17 +44,19 @@ def __init__(self, name=None):
else:
name += '_'
self.name = name
-
+
+ self.actions = []
+
def register(self, model_or_iterable, admin_class=None, **options):
"""
Registers the given model(s) with the given admin class.
-
+
The model(s) should be Model classes, not instances.
-
+
If an admin class isn't given, it will use ModelAdmin (the default
admin options). If keyword arguments are given -- e.g., list_display --
they'll be applied as options to the admin class.
-
+
If a model is already registered, this will raise AlreadyRegistered.
"""
if not admin_class:
@@ -65,13 +67,13 @@ def register(self, model_or_iterable, admin_class=None, **options):
from django.contrib.admin.validation import validate
else:
validate = lambda model, adminclass: None
-
+
if isinstance(model_or_iterable, ModelBase):
model_or_iterable = [model_or_iterable]
for model in model_or_iterable:
if model in self._registry:
raise AlreadyRegistered('The model %s is already registered' % model.__name__)
-
+
# If we got **options then dynamically construct a subclass of
# admin_class with those **options.
if options:
@@ -80,17 +82,17 @@ def register(self, model_or_iterable, admin_class=None, **options):
# which causes issues later on.
options['__module__'] = __name__
admin_class = type("%sAdmin" % model.__name__, (admin_class,), options)
-
+
# Validate (which might be a no-op)
validate(admin_class, model)
-
+
# Instantiate the admin class to save in the registry
self._registry[model] = admin_class(model, self)
-
+
def unregister(self, model_or_iterable):
"""
Unregisters the given model(s).
-
+
If a model isn't already registered, this will raise NotRegistered.
"""
if isinstance(model_or_iterable, ModelBase):
@@ -99,44 +101,49 @@ def unregister(self, model_or_iterable):
if model not in self._registry:
raise NotRegistered('The model %s is not registered' % model.__name__)
del self._registry[model]
-
+
+ def add_action(self, action):
+ if not callable(action):
+ raise TypeError("You can only register callable actions through an admin site")
+ self.actions.append(action)
+
def has_permission(self, request):
"""
Returns True if the given HttpRequest has permission to view
*at least one* page in the admin site.
"""
return request.user.is_authenticated() and request.user.is_staff
-
+
def check_dependencies(self):
"""
Check that all things needed to run the admin have been correctly installed.
-
+
The default implementation checks that LogEntry, ContentType and the
auth context processor are installed.
"""
from django.contrib.admin.models import LogEntry
from django.contrib.contenttypes.models import ContentType
-
+
if not LogEntry._meta.installed:
raise ImproperlyConfigured("Put 'django.contrib.admin' in your INSTALLED_APPS setting in order to use the admin application.")
if not ContentType._meta.installed:
raise ImproperlyConfigured("Put 'django.contrib.contenttypes' in your INSTALLED_APPS setting in order to use the admin application.")
if 'django.core.context_processors.auth' not in settings.TEMPLATE_CONTEXT_PROCESSORS:
raise ImproperlyConfigured("Put 'django.core.context_processors.auth' in your TEMPLATE_CONTEXT_PROCESSORS setting in order to use the admin application.")
-
+
def admin_view(self, view):
"""
Decorator to create an "admin view attached to this ``AdminSite``. This
wraps the view and provides permission checking by calling
``self.has_permission``.
-
+
You'll want to use this from within ``AdminSite.get_urls()``:
-
+
class MyAdminSite(AdminSite):
-
+
def get_urls(self):
from django.conf.urls.defaults import patterns, url
-
+
urls = super(MyAdminSite, self).get_urls()
urls += patterns('',
url(r'^my_view/$', self.protected_view(some_view))
@@ -148,15 +155,15 @@ def inner(request, *args, **kwargs):
return self.login(request)
return view(request, *args, **kwargs)
return update_wrapper(inner, view)
-
+
def get_urls(self):
from django.conf.urls.defaults import patterns, url, include
-
+
def wrap(view):
def wrapper(*args, **kwargs):
return self.admin_view(view)(*args, **kwargs)
return update_wrapper(wrapper, view)
-
+
# Admin-site-wide views.
urlpatterns = patterns('',
url(r'^$',
@@ -180,7 +187,7 @@ def wrapper(*args, **kwargs):
wrap(self.app_index),
name='%sadmin_app_list' % self.name),
)
-
+
# Add in each model's views.
for model, model_admin in self._registry.iteritems():
urlpatterns += patterns('',
@@ -188,11 +195,11 @@ def wrapper(*args, **kwargs):
include(model_admin.urls))
)
return urlpatterns
-
+
def urls(self):
return self.get_urls()
urls = property(urls)
-
+
def password_change(self, request):
"""
Handles the "change password" task -- both form display and validation.
@@ -200,18 +207,18 @@ def password_change(self, request):
from django.contrib.auth.views import password_change
return password_change(request,
post_change_redirect='%spassword_change/done/' % self.root_path)
-
+
def password_change_done(self, request):
"""
Displays the "success" page after a password change.
"""
from django.contrib.auth.views import password_change_done
return password_change_done(request)
-
+
def i18n_javascript(self, request):
"""
Displays the i18n JavaScript that the Django admin requires.
-
+
This takes into account the USE_I18N setting. If it's set to False, the
generated JavaScript will be leaner and faster.
"""
@@ -220,23 +227,23 @@ def i18n_javascript(self, request):
else:
from django.views.i18n import null_javascript_catalog as javascript_catalog
return javascript_catalog(request, packages='django.conf')
-
+
def logout(self, request):
"""
Logs out the user for the given HttpRequest.
-
+
This should *not* assume the user is already logged in.
"""
from django.contrib.auth.views import logout
return logout(request)
logout = never_cache(logout)
-
+
def login(self, request):
"""
Displays the login form for the given HttpRequest.
"""
from django.contrib.auth.models import User
-
+
# If this isn't already the login page, display it.
if not request.POST.has_key(LOGIN_FORM_KEY):
if request.POST:
@@ -244,14 +251,14 @@ def login(self, request):
else:
message = ""
return self.display_login_form(request, message)
-
+
# Check that the user accepts cookies.
if not request.session.test_cookie_worked():
message = _("Looks like your browser isn't configured to accept cookies. Please enable cookies, reload this page, and try again.")
return self.display_login_form(request, message)
else:
request.session.delete_test_cookie()
-
+
# Check the password.
username = request.POST.get('username', None)
password = request.POST.get('password', None)
@@ -271,7 +278,7 @@ def login(self, request):
else:
message = _("Usernames cannot contain the '@' character.")
return self.display_login_form(request, message)
-
+
# The user data is correct; log in the user in and continue.
else:
if user.is_active and user.is_staff:
@@ -280,7 +287,7 @@ def login(self, request):
else:
return self.display_login_form(request, ERROR_MESSAGE)
login = never_cache(login)
-
+
def index(self, request, extra_context=None):
"""
Displays the main admin index page, which lists all of the installed
@@ -291,14 +298,14 @@ def index(self, request, extra_context=None):
for model, model_admin in self._registry.items():
app_label = model._meta.app_label
has_module_perms = user.has_module_perms(app_label)
-
+
if has_module_perms:
perms = {
'add': model_admin.has_add_permission(request),
'change': model_admin.has_change_permission(request),
'delete': model_admin.has_delete_permission(request),
}
-
+
# Check whether user has any perm for this module.
# If so, add the module to the model_list.
if True in perms.values():
@@ -316,15 +323,15 @@ def index(self, request, extra_context=None):
'has_module_perms': has_module_perms,
'models': [model_dict],
}
-
+
# Sort the apps alphabetically.
app_list = app_dict.values()
app_list.sort(lambda x, y: cmp(x['name'], y['name']))
-
+
# Sort the models alphabetically within each app.
for app in app_list:
app['models'].sort(lambda x, y: cmp(x['name'], y['name']))
-
+
context = {
'title': _('Site administration'),
'app_list': app_list,
@@ -335,7 +342,7 @@ def index(self, request, extra_context=None):
context_instance=template.RequestContext(request)
)
index = never_cache(index)
-
+
def display_login_form(self, request, error_message='', extra_context=None):
request.session.set_test_cookie()
context = {
@@ -348,7 +355,7 @@ def display_login_form(self, request, error_message='', extra_context=None):
return render_to_response(self.login_template or 'admin/login.html', context,
context_instance=template.RequestContext(request)
)
-
+
def app_index(self, request, app_label, extra_context=None):
user = request.user
has_module_perms = user.has_module_perms(app_label)
@@ -394,46 +401,46 @@ def app_index(self, request, app_label, extra_context=None):
return render_to_response(self.app_index_template or 'admin/app_index.html', context,
context_instance=template.RequestContext(request)
)
-
+
def root(self, request, url):
"""
DEPRECATED. This function is the old way of handling URL resolution, and
is deprecated in favor of real URL resolution -- see ``get_urls()``.
-
+
This function still exists for backwards-compatibility; it will be
removed in Django 1.3.
"""
import warnings
warnings.warn(
- "AdminSite.root() is deprecated; use include(admin.site.urls) instead.",
+ "AdminSite.root() is deprecated; use include(admin.site.urls) instead.",
PendingDeprecationWarning
)
-
+
#
# Again, remember that the following only exists for
# backwards-compatibility. Any new URLs, changes to existing URLs, or
# whatever need to be done up in get_urls(), above!
#
-
+
if request.method == 'GET' and not request.path.endswith('/'):
return http.HttpResponseRedirect(request.path + '/')
-
+
if settings.DEBUG:
self.check_dependencies()
-
+
# Figure out the admin base URL path and stash it for later use
self.root_path = re.sub(re.escape(url) + '$', '', request.path)
-
+
url = url.rstrip('/') # Trim trailing slash, if it exists.
-
+
# The 'logout' view doesn't require that the person is logged in.
if url == 'logout':
return self.logout(request)
-
+
# Check permission to continue or display login form.
if not self.has_permission(request):
return self.login(request)
-
+
if url == '':
return self.index(request)
elif url == 'password_change':
@@ -451,9 +458,9 @@ def root(self, request, url):
return self.model_page(request, *url.split('/', 2))
else:
return self.app_index(request, url)
-
+
raise http.Http404('The requested admin page does not exist.')
-
+
def model_page(self, request, app_label, model_name, rest_of_url=None):
"""
DEPRECATED. This is the old way of handling a model view on the admin
@@ -468,7 +475,7 @@ def model_page(self, request, app_label, model_name, rest_of_url=None):
except KeyError:
raise http.Http404("This model exists but has not been registered with the admin site.")
return admin_obj(request, rest_of_url)
- model_page = never_cache(model_page)
+ model_page = never_cache(model_page)
# This global object represents the default admin site, for the common case.
# You can instantiate AdminSite in your own code to create a custom admin site.
5 django/contrib/admin/templates/admin/actions.html
View
@@ -0,0 +1,5 @@
+{% load i18n %}
+<div class="actions">
+ {% for field in action_form %}<label>{{ field.label }} {{ field }}</label>{% endfor %}
+ <button type="submit" class="button" title="{% trans "Run the selected action" %}" name="index" value="{{ action_index|default:0 }}">{% trans "Go" %}</button>
+</div>
12 django/contrib/admin/templates/admin/change_list.html
View
@@ -7,8 +7,8 @@
{% if cl.formset %}
<link rel="stylesheet" type="text/css" href="{% admin_media_prefix %}css/forms.css" />
<script type="text/javascript" src="../../jsi18n/"></script>
- {{ media }}
{% endif %}
+ {{ media }}
{% endblock %}
{% block bodyclass %}change-list{% endblock %}
@@ -63,14 +63,18 @@
{% endif %}
{% endblock %}
+ <form action="" method="post"{% if cl.formset.is_multipart %} enctype="multipart/form-data"{% endif %}>
{% if cl.formset %}
- <form action="" method="post"{% if cl.formset.is_multipart %} enctype="multipart/form-data"{% endif %}>
{{ cl.formset.management_form }}
{% endif %}
- {% block result_list %}{% result_list cl %}{% endblock %}
+ {% block result_list %}
+ {% if actions_on_top and cl.full_result_count %}{% admin_actions %}{% endif %}
+ {% result_list cl %}
+ {% if actions_on_bottom and cl.full_result_count %}{% admin_actions %}{% endif %}
+ {% endblock %}
{% block pagination %}{% pagination cl %}{% endblock %}
- {% if cl.formset %}</form>{% endif %}
+ </form>
</div>
</div>
{% endblock %}
37 django/contrib/admin/templates/admin/delete_selected_confirmation.html
View
@@ -0,0 +1,37 @@
+{% extends "admin/base_site.html" %}
+{% load i18n %}
+
+{% block breadcrumbs %}
+<div class="breadcrumbs">
+ <a href="../../">{% trans "Home" %}</a> &rsaquo;
+ <a href="../">{{ app_label|capfirst }}</a> &rsaquo;
+ <a href="./">{{ opts.verbose_name_plural|capfirst }}</a> &rsaquo;
+ {% trans 'Delete multiple objects' %}
+</div>
+{% endblock %}
+
+{% block content %}
+{% if perms_lacking %}
+ <p>{% blocktrans %}Deleting the {{ object_name }} would result in deleting related objects, but your account doesn't have permission to delete the following types of objects:{% endblocktrans %}</p>
+ <ul>
+ {% for obj in perms_lacking %}
+ <li>{{ obj }}</li>
+ {% endfor %}
+ </ul>
+{% else %}
+ <p>{% blocktrans %}Are you sure you want to delete the selected {{ object_name }} objects? All of the following objects and it's related items will be deleted:{% endblocktrans %}</p>
+ {% for deleteable_object in deletable_objects %}
+ <ul>{{ deleteable_object|unordered_list }}</ul>
+ {% endfor %}
+ <form action="" method="post">
+ <div>
+ {% for obj in queryset %}
+ <input type="hidden" name="{{ action_checkbox_name }}" value="{{ obj.pk }}" />
+ {% endfor %}
+ <input type="hidden" name="action" value="delete_selected" />
+ <input type="hidden" name="post" value="yes" />
+ <input type="submit" value="{% trans "Yes, I'm sure" %}" />
+ </div>
+ </form>
+{% endif %}
+{% endblock %}
9 django/contrib/admin/templatetags/admin_list.py
View
@@ -325,3 +325,12 @@ def search_form(cl):
def admin_list_filter(cl, spec):
return {'title': spec.title(), 'choices' : list(spec.choices(cl))}
admin_list_filter = register.inclusion_tag('admin/filter.html')(admin_list_filter)
+
+def admin_actions(context):
+ """
+ Track the number of times the action field has been rendered on the page,
+ so we know which value to use.
+ """
+ context['action_index'] = context.get('action_index', -1) + 1
+ return context
+admin_actions = register.inclusion_tag("admin/actions.html", takes_context=True)(admin_actions)
91 django/contrib/admin/util.py
View
@@ -4,7 +4,8 @@
from django.utils.safestring import mark_safe
from django.utils.text import capfirst
from django.utils.encoding import force_unicode
-from django.utils.translation import ugettext as _
+from django.utils.translation import ungettext, ugettext as _
+from django.core.urlresolvers import reverse, NoReverseMatch
def quote(s):
"""
@@ -60,8 +61,27 @@ def _nest_help(obj, depth, val):
current = current[-1]
current.append(val)
-def get_deleted_objects(deleted_objects, perms_needed, user, obj, opts, current_depth, admin_site):
- "Helper function that recursively populates deleted_objects."
+def get_change_view_url(app_label, module_name, pk, admin_site, levels_to_root):
+ """
+ Returns the url to the admin change view for the given app_label,
+ module_name and primary key.
+ """
+ try:
+ return reverse('%sadmin_%s_%s_change' % (admin_site.name, app_label, module_name), None, (pk,))
+ except NoReverseMatch:
+ return '%s%s/%s/%s/' % ('../'*levels_to_root, app_label, module_name, pk)
+
+def get_deleted_objects(deleted_objects, perms_needed, user, obj, opts, current_depth, admin_site, levels_to_root=4):
+ """
+ Helper function that recursively populates deleted_objects.
+
+ `levels_to_root` defines the number of directories (../) to reach the
+ admin root path. In a change_view this is 4, in a change_list view 2.
+
+ This is for backwards compatibility since the options.delete_selected
+ method uses this function also from a change_list view.
+ This will not be used if we can reverse the URL.
+ """
nh = _nest_help # Bind to local variable for performance
if current_depth > 16:
return # Avoid recursing too deep.
@@ -91,11 +111,13 @@ def get_deleted_objects(deleted_objects, perms_needed, user, obj, opts, current_
[u'%s: %s' % (capfirst(related.opts.verbose_name), force_unicode(sub_obj)), []])
else:
# Display a link to the admin page.
- nh(deleted_objects, current_depth, [mark_safe(u'%s: <a href="../../../../%s/%s/%s/">%s</a>' %
+ nh(deleted_objects, current_depth, [mark_safe(u'%s: <a href="%s">%s</a>' %
(escape(capfirst(related.opts.verbose_name)),
- related.opts.app_label,
- related.opts.object_name.lower(),
- sub_obj._get_pk_val(),
+ get_change_view_url(related.opts.app_label,
+ related.opts.object_name.lower(),
+ sub_obj._get_pk_val(),
+ admin_site,
+ levels_to_root),
escape(sub_obj))), []])
get_deleted_objects(deleted_objects, perms_needed, user, sub_obj, related.opts, current_depth+2, admin_site)
else:
@@ -109,11 +131,13 @@ def get_deleted_objects(deleted_objects, perms_needed, user, obj, opts, current_
[u'%s: %s' % (capfirst(related.opts.verbose_name), force_unicode(sub_obj)), []])
else:
# Display a link to the admin page.
- nh(deleted_objects, current_depth, [mark_safe(u'%s: <a href="../../../../%s/%s/%s/">%s</a>' %
+ nh(deleted_objects, current_depth, [mark_safe(u'%s: <a href="%s">%s</a>' %
(escape(capfirst(related.opts.verbose_name)),
- related.opts.app_label,
- related.opts.object_name.lower(),
- sub_obj._get_pk_val(),
+ get_change_view_url(related.opts.app_label,
+ related.opts.object_name.lower(),
+ sub_obj._get_pk_val(),
+ admin_site,
+ levels_to_root),
escape(sub_obj))), []])
get_deleted_objects(deleted_objects, perms_needed, user, sub_obj, related.opts, current_depth+2, admin_site)
# If there were related objects, and the user doesn't have
@@ -147,11 +171,52 @@ def get_deleted_objects(deleted_objects, perms_needed, user, obj, opts, current_
# Display a link to the admin page.
nh(deleted_objects, current_depth, [
mark_safe((_('One or more %(fieldname)s in %(name)s:') % {'fieldname': escape(force_unicode(related.field.verbose_name)), 'name': escape(force_unicode(related.opts.verbose_name))}) + \
- (u' <a href="../../../../%s/%s/%s/">%s</a>' % \
- (related.opts.app_label, related.opts.module_name, sub_obj._get_pk_val(), escape(sub_obj)))), []])
+ (u' <a href="%s">%s</a>' % \
+ (get_change_view_url(related.opts.app_label,
+ related.opts.object_name.lower(),
+ sub_obj._get_pk_val(),
+ admin_site,
+ levels_to_root),
+ escape(sub_obj)))), []])
# If there were related objects, and the user doesn't have
# permission to change them, add the missing perm to perms_needed.
if has_admin and has_related_objs:
p = u'%s.%s' % (related.opts.app_label, related.opts.get_change_permission())
if not user.has_perm(p):
perms_needed.add(related.opts.verbose_name)
+
+def model_format_dict(obj):
+ """
+ Return a `dict` with keys 'verbose_name' and 'verbose_name_plural',
+ typically for use with string formatting.
+
+ `obj` may be a `Model` instance, `Model` subclass, or `QuerySet` instance.
+
+ """
+ if isinstance(obj, (models.Model, models.base.ModelBase)):
+ opts = obj._meta
+ elif isinstance(obj, models.query.QuerySet):
+ opts = obj.model._meta
+ else:
+ opts = obj
+ return {
+ 'verbose_name': force_unicode(opts.verbose_name),
+ 'verbose_name_plural': force_unicode(opts.verbose_name_plural)
+ }
+
+def model_ngettext(obj, n=None):
+ """
+ Return the appropriate `verbose_name` or `verbose_name_plural` for `obj`
+ depending on the count `n`.
+
+ `obj` may be a `Model` instance, `Model` subclass, or `QuerySet` instance.
+ If `obj` is a `QuerySet` instance, `n` is optional and the length of the
+ `QuerySet` is used.
+
+ """
+ if isinstance(obj, models.query.QuerySet):
+ if n is None:
+ n = obj.count()
+ obj = obj.model
+ d = model_format_dict(obj)
+ return ungettext(d['verbose_name'], d['verbose_name_plural'], n or 0)
15 django/contrib/admin/validation.py
View
@@ -63,7 +63,7 @@ def validate(cls, model):
if hasattr(cls, 'list_per_page') and not isinstance(cls.list_per_page, int):
raise ImproperlyConfigured("'%s.list_per_page' should be a integer."
% cls.__name__)
-
+
# list_editable
if hasattr(cls, 'list_editable') and cls.list_editable:
check_isseq(cls, 'list_editable', cls.list_editable)
@@ -76,7 +76,7 @@ def validate(cls, model):
field = opts.get_field_by_name(field_name)[0]
except models.FieldDoesNotExist:
raise ImproperlyConfigured("'%s.list_editable[%d]' refers to a "
- "field, '%s', not defiend on %s."
+ "field, '%s', not defiend on %s."
% (cls.__name__, idx, field_name, model.__name__))
if field_name not in cls.list_display:
raise ImproperlyConfigured("'%s.list_editable[%d]' refers to "
@@ -89,7 +89,7 @@ def validate(cls, model):
if not cls.list_display_links and cls.list_display[0] in cls.list_editable:
raise ImproperlyConfigured("'%s.list_editable[%d]' refers to"
" the first field in list_display, '%s', which can't be"
- " used unless list_display_links is set."
+ " used unless list_display_links is set."
% (cls.__name__, idx, cls.list_display[0]))
if not field.editable:
raise ImproperlyConfigured("'%s.list_editable[%d]' refers to a "
@@ -127,6 +127,14 @@ def validate(cls, model):
continue
get_field(cls, model, opts, 'ordering[%d]' % idx, field)
+ if cls.actions:
+ check_isseq(cls, 'actions', cls.actions)
+ for idx, item in enumerate(cls.actions):
+ if (not callable(item)) and (not hasattr(cls, item)):
+ raise ImproperlyConfigured("'%s.actions[%d]' is neither a "
+ "callable nor a method on %s" % (cls.__name__, idx, cls.__name__))
+
+
# list_select_related = False
# save_as = False
# save_on_top = False
@@ -135,6 +143,7 @@ def validate(cls, model):
raise ImproperlyConfigured("'%s.%s' should be a boolean."
% (cls.__name__, attr))
+
# inlines = []
if hasattr(cls, 'inlines'):
check_isseq(cls, 'inlines', cls.inlines)
2  docs/index.txt
View
@@ -78,7 +78,7 @@ The development process
Other batteries included
========================
- * :ref:`Admin site <ref-contrib-admin>`
+ * :ref:`Admin site <ref-contrib-admin>` | :ref:`Admin actions <ref-contrib-admin-actions>`
* :ref:`Authentication <topics-auth>`
* :ref:`Cache system <topics-cache>`
* :ref:`Conditional content processing <topics-conditional-processing>`
BIN  docs/ref/contrib/admin/_images/article_actions.png
View
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
BIN  docs/ref/contrib/admin/_images/article_actions_message.png
View
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
0  docs/ref/contrib/_images/flatfiles_admin.png → docs/ref/contrib/admin/_images/flatfiles_admin.png
View
File renamed without changes
BIN  docs/ref/contrib/admin/_images/user_actions.png
View
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
0  docs/ref/contrib/_images/users_changelist.png → docs/ref/contrib/admin/_images/users_changelist.png
View
File renamed without changes
239 docs/ref/contrib/admin/actions.txt
View
@@ -0,0 +1,239 @@
+.. _ref-contrib-admin-actions:
+
+=============
+Admin actions
+=============
+
+.. versionadded:: 1.1
+
+.. currentmodule:: django.contrib.admin
+
+The basic workflow of Django's admin is, in a nutshell, "select an object,
+then change it." This works well for a majority of use cases. However, if you
+need to make the same change to many objects at once, this workflow can be
+quite tedious.
+
+In these cases, Django's admin lets you write and register "actions" -- simple
+functions that get called with a list of objects selected on the change list
+page.
+
+If you look at any change list in the admin, you'll see this feature in
+action; Django ships with a "delete selected objects" action available to all
+models. For example, here's the user module from Django's built-in
+:mod:`django.contrib.auth` app:
+
+.. image:: _images/user_actions.png
+
+Read on to find out how to add your own actions to this list.
+
+Writing actions
+===============
+
+The easiest way to explain actions is by example, so let's dive in.
+
+A common use case for admin actions is the bulk updating of a model. Imagine a simple
+news application with an ``Article`` model::
+
+ from django.db import models
+
+ STATUS_CHOICES = (
+ ('d', 'Draft'),
+ ('p', 'Published'),
+ ('w', 'Withdrawn'),
+ )
+
+ class Article(models.Model):
+ title = models.CharField(max_length=100)
+ body = models.TextField()
+ status = models.CharField(max_length=1, choices=STATUS_CHOICES)
+
+ def __unicode__(self):
+ return self.title
+
+A common task we might perform with a model like this is to update an
+article's status from "draft" to "published". We could easily do this in the
+admin one article at a time, but if we wanted to bulk-publish a group of
+articles, it'd be tedious. So, let's write an action that lets us change an
+article's status to "published."
+
+Writing action functions
+------------------------
+
+First, we'll need to write a function that gets called when the action is
+trigged from the admin. Action functions are just regular functions that take
+two arguments: an :class:`~django.http.HttpRequest` representing the current
+request, and a :class:`~django.db.models.QuerySet` containing the set of
+objects selected by the user. Our publish-these-articles function won't need
+the request object, but we will use the queryset::
+
+ def make_published(request, queryset):
+ queryset.update(status='p')
+
+.. note::
+
+ For the best performance, we're using the queryset's :ref:`update method
+ <topics-db-queries-update>`. Other types of actions might need to deal
+ with each object individually; in these cases we'd just iterate over the
+ queryset::
+
+ for obj in queryset:
+ do_something_with(obj)
+
+That's actually all there is to writing an action! However, we'll take one
+more optional-but-useful step and give the action a "nice" title in the admin.
+By default, this action would appear in the action list as "Make published" --
+the function name, with underscores replaced by spaces. That's fine, but we
+can provide a better, more human-friendly name by giving the
+``make_published`` function a ``short_description`` attribute::
+
+ def make_published(request, queryset):
+ queryset.update(status='p')
+ make_published.short_description = "Mark selected stories as published"
+
+.. note::
+
+ This might look familiar; the admin's ``list_display`` option uses the
+ same technique to provide human-readable descriptions for callback
+ functions registered there, too.
+
+Adding actions to the :class:`ModelAdmin`
+-----------------------------------------
+
+Next, we'll need to inform our :class:`ModelAdmin` of the action. 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 Article
+
+ def make_published(request, queryset):
+ queryset.update(status='p')
+ make_published.short_description = "Mark selected stories as published"
+
+ class ArticleAdmin(admin.ModelAdmin):
+ list_display = ['title', 'status']
+ ordering = ['title']
+ actions = [make_published]
+
+ admin.site.register(Article, ArticleAdmin)
+
+That code will give us an admin change list that looks something like this:
+
+.. image:: _images/article_actions.png
+
+That's really all there is to it! If you're itching to write your own actions,
+you now know enough to get started. The rest of this document just covers more
+advanced techniques.
+
+Advanced action techniques
+==========================
+
+There's a couple of extra options and possibilities you can exploit for more
+advanced options.
+
+Actions as :class:`ModelAdmin` methods
+--------------------------------------
+
+The example above shows the ``make_published`` action defined as a simple
+function. That's perfectly fine, but it's not perfect from a code design point
+of view: since the action is tightly coupled to the ``Article`` object, it
+makes sense to hook the action to the ``ArticleAdmin`` object itself.
+
+That's easy enough to do::
+
+ class ArticleAdmin(admin.ModelAdmin):
+ ...
+
+ actions = ['make_published']
+
+ def make_published(self, request, queryset):
+ queryset.update(status='p')
+ make_published.short_description = "Mark selected stories as published"
+
+Notice first that we've moved ``make_published`` into a method (remembering to
+add the ``self`` argument!), and second that we've now put the string
+``'make_published'`` in ``actions`` instead of a direct function reference.
+This tells the :class:`ModelAdmin` to look up the action as a method.
+
+Defining actions as methods is especially nice because it gives the action
+access to the :class:`ModelAdmin` itself, allowing the action to call any of
+the methods provided by the admin.
+
+For example, we can use ``self`` to flash a message to the user informing her
+that the action was successful::
+
+ class ArticleAdmin(admin.ModelAdmin):
+ ...
+
+ def make_published(self, request, queryset):
+ rows_updated = queryset.update(status='p')
+ if rows_updated == 1:
+ message_bit = "1 story was"
+ else:
+ message_bit = "%s stories were" % rows_updated
+ self.message_user(request, "%s successfully marked as published." % message_bit)
+
+This make the action match what the admin itself does after successfully
+performing an action:
+
+.. image:: _images/article_actions_message.png
+
+Actions that provide intermediate pages
+---------------------------------------
+
+By default, after an action is performed the user is simply redirected back
+the the original change list page. However, some actions, especially more
+complex ones, will need to return intermediate pages. For example, the
+built-in delete action asks for confirmation before deleting the selected
+objects.
+
+To provide an intermediary page, simply return an
+:class:`~django.http.HttpResponse` (or subclass) from your action. For
+example, you might write a simple export function that uses Django's
+:ref:`serialization functions <topics-serialization>` to dump some selected
+objects as JSON::
+
+ from django.http import HttpResponse
+ from django.core import serializers
+
+ def export_as_json(request, queryset):
+ response = HttpResponse(mimetype="text/javascript")
+ serialize.serialize(queryset, stream=response)
+ return response
+
+Generally, something like the above isn't considered a great idea. Most of the
+time, the best practice will be to return an
+:class:`~django.http.HttpResponseRedirect` and redirect the user to a view
+you've written, passing the list of selected objects in the GET query string.
+This allows you to provide complex interaction logic on the intermediary
+pages. For example, if you wanted to provide a more complete export function,
+you'd want to let the user choose a format, and possibly a list of fields to
+include in the export. The best thing to do would be to write a small action that simply redirects
+to your custom export view::
+
+ from django.contrib import admin
+ from django.contrib.contenttypes.models import ContentType
+ from django.http import HttpResponseRedirect
+
+ def export_selected_objects(request, queryset):
+ selected = request.POST.getlist(admin.ACTION_CHECKBOX_NAME)
+ ct = ContentType.objects.get_for_model(queryset.model)
+ return HttpResponseRedirect("/export/?ct=%s&ids=%s" % (ct.pk, ",".join(selected)))
+
+As you can see, the action is the simple part; all the complex logic would
+belong in your export view. This would need to deal with objects of any type,
+hence the business with the ``ContentType``.
+
+Writing this view is left as an exercise to the reader.
+
+Making actions available globally
+---------------------------------
+
+Some actions are best if they're made available to *any* object in the admin
+-- the export action defined above would be a good candidate. You can make an
+action globally available using :meth:`AdminSite.add_action()`::
+
+ from django.contrib import admin
+
+ admin.site.add_action(export_selected_objects)
+
23 docs/ref/contrib/admin.txt → docs/ref/contrib/admin/index.txt
View
@@ -38,6 +38,14 @@ There are five steps in activating the Django admin site:
``ModelAdmin`` classes.
5. Hook the ``AdminSite`` instance into your URLconf.
+
+Other topics
+------------
+
+.. toctree::
+ :maxdepth: 1
+
+ actions
``ModelAdmin`` objects
======================
@@ -664,6 +672,19 @@ The value is another dictionary; these arguments will be passed to
that have ``raw_id_fields`` or ``radio_fields`` set. That's because
``raw_id_fields`` and ``radio_fields`` imply custom widgets of their own.
+``actions``
+~~~~~~~~~~~
+
+A list of actions to make available on the change list page. See
+:ref:`ref-contrib-admin-actions` for details.
+
+``actions_on_top``, ``actions_on_buttom``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Controls where on the page the actions bar appears. By default, the admin
+changelist displays actions at the top of the page (``actions_on_top = True;
+actions_on_bottom = False``).
+
``ModelAdmin`` methods
----------------------
@@ -1138,7 +1159,6 @@ or add anything you like. Then, simply create an instance of your
Python class), and register your models and ``ModelAdmin`` subclasses
with it instead of using the default.
-
Hooking ``AdminSite`` instances into your URLconf
-------------------------------------------------
@@ -1177,7 +1197,6 @@ There is really no need to use autodiscover when using your own ``AdminSite``
instance since you will likely be importing all the per-app admin.py modules
in your ``myproject.admin`` module.
-
Multiple admin sites in the same URLconf
----------------------------------------
2  docs/ref/contrib/index.txt
View
@@ -24,7 +24,7 @@ those packages have.
.. toctree::
:maxdepth: 1
- admin
+ admin/index
auth
comments/index
contenttypes
2  tests/regressiontests/admin_registration/models.py
View
@@ -49,7 +49,7 @@ class Place(models.Model):
>>> site._registry[Person].search_fields
['name']
>>> site._registry[Person].list_display
-['__str__']
+['action_checkbox', '__str__']
>>> site._registry[Person].save_on_top
True
15 tests/regressiontests/admin_views/fixtures/admin-views-actions.xml
View
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<django-objects version="1.0">
+ <object pk="1" model="admin_views.subscriber">
+ <field type="CharField" name="name">John Doe</field>
+ <field type="CharField" name="email">john@example.org</field>
+ </object>
+ <object pk="2" model="admin_views.subscriber">
+ <field type="CharField" name="name">Max Mustermann</field>
+ <field type="CharField" name="email">max@example.org</field>
+ </object>
+ <object pk="1" model="admin_views.externalsubscriber">
+ <field type="CharField" name="name">John Doe</field>
+ <field type="CharField" name="email">john@example.org</field>
+ </object>
+</django-objects>
38 tests/regressiontests/admin_views/models.py
View
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
from django.db import models
from django.contrib import admin
+from django.core.mail import EmailMessage
class Section(models.Model):
"""
@@ -199,6 +200,41 @@ class PersonaAdmin(admin.ModelAdmin):
BarAccountAdmin
)
+class Subscriber(models.Model):
+ name = models.CharField(blank=False, max_length=80)
+ email = models.EmailField(blank=False, max_length=175)
+
+ def __unicode__(self):
+ return "%s (%s)" % (self.name, self.email)
+
+class SubscriberAdmin(admin.ModelAdmin):
+ actions = ['delete_selected', 'mail_admin']
+
+ def mail_admin(self, request, selected):
+ EmailMessage(
+ 'Greetings from a ModelAdmin action',
+ 'This is the test email from a admin action',
+ 'from@example.com',
+ ['to@example.com']
+ ).send()
+
+class ExternalSubscriber(Subscriber):
+ pass
+
+def external_mail(request, selected):
+ EmailMessage(
+ 'Greetings from a function action',
+ 'This is the test email from a function action',
+ 'from@example.com',
+ ['to@example.com']
+ ).send()
+
+def redirect_to(request, selected):
+ from django.http import HttpResponseRedirect
+ return HttpResponseRedirect('/some-where-else/')
+
+class ExternalSubscriberAdmin(admin.ModelAdmin):
+ actions = [external_mail, redirect_to]
admin.site.register(Article, ArticleAdmin)
admin.site.register(CustomArticle, CustomArticleAdmin)
@@ -208,6 +244,8 @@ class PersonaAdmin(admin.ModelAdmin):
admin.site.register(Thing, ThingAdmin)
admin.site.register(Person, PersonAdmin)
admin.site.register(Persona, PersonaAdmin)
+admin.site.register(Subscriber, SubscriberAdmin)
+admin.site.register(ExternalSubscriber, ExternalSubscriberAdmin)
# We intentionally register Promo and ChapterXtra1 but not Chapter nor ChapterXtra2.
# That way we cover all four cases:
96 tests/regressiontests/admin_views/tests.py
View
@@ -8,10 +8,11 @@
from django.contrib.admin.models import LogEntry
from django.contrib.admin.sites import LOGIN_FORM_KEY
from django.contrib.admin.util import quote
+from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
from django.utils.html import escape
# local test models
-from models import Article, CustomArticle, Section, ModelWithStringPrimaryKey, Person, Persona, FooAccount, BarAccount
+from models import Article, CustomArticle, Section, ModelWithStringPrimaryKey, Person, Persona, FooAccount, BarAccount, Subscriber, ExternalSubscriber
try:
set
@@ -516,7 +517,7 @@ def test_get_change_view(self):
def test_changelist_to_changeform_link(self):
"The link from the changelist referring to the changeform of the object should be quoted"
response = self.client.get('/test_admin/admin/admin_views/modelwithstringprimarykey/')
- should_contain = """<tr class="row1"><th><a href="%s/">%s</a></th></tr>""" % (quote(self.pk), escape(self.pk))
+ should_contain = """<th><a href="%s/">%s</a></th></tr>""" % (quote(self.pk), escape(self.pk))
self.assertContains(response, should_contain)
def test_recentactions_link(self):
@@ -738,29 +739,30 @@ def setUp(self):
def tearDown(self):
self.client.logout()
-
+
def test_changelist_input_html(self):
response = self.client.get('/test_admin/admin/admin_views/person/')
# 2 inputs per object(the field and the hidden id field) = 6
# 2 management hidden fields = 2
+ # 4 action inputs (3 regular checkboxes, 1 checkbox to select all)
# main form submit button = 1
# search field and search submit button = 2
# 6 + 2 + 1 + 2 = 11 inputs
- self.failUnlessEqual(response.content.count("<input"), 11)
+ self.failUnlessEqual(response.content.count("<input"), 15)
# 1 select per object = 3 selects
- self.failUnlessEqual(response.content.count("<select"), 3)
-
+ self.failUnlessEqual(response.content.count("<select"), 4)
+
def test_post_submission(self):
data = {
"form-TOTAL_FORMS": "3",
"form-INITIAL_FORMS": "3",
-
+
"form-0-gender": "1",
"form-0-id": "1",
-
+
"form-1-gender": "2",
"form-1-id": "2",
-
+
"form-2-alive": "checked",
"form-2-gender": "1",
"form-2-id": "3",
@@ -769,34 +771,34 @@ def test_post_submission(self):
self.failUnlessEqual(Person.objects.get(name="John Mauchly").alive, False)
self.failUnlessEqual(Person.objects.get(name="Grace Hooper").gender, 2)
-
+
# test a filtered page
data = {
"form-TOTAL_FORMS": "2",
"form-INITIAL_FORMS": "2",
-
+
"form-0-id": "1",
"form-0-gender": "1",
"form-0-alive": "checked",
-
+
"form-1-id": "3",
"form-1-gender": "1",
"form-1-alive": "checked",
}
self.client.post('/test_admin/admin/admin_views/person/?gender__exact=1', data)
-
+
self.failUnlessEqual(Person.objects.get(name="John Mauchly").alive, True)
-
+
# test a searched page
data = {
"form-TOTAL_FORMS": "1",
"form-INITIAL_FORMS": "1",
-
+
"form-0-id": "1",
"form-0-gender": "1"
}
self.client.post('/test_admin/admin/admin_views/person/?q=mauchly', data)
-
+
self.failUnlessEqual(Person.objects.get(name="John Mauchly").alive, False)
class AdminInheritedInlinesTest(TestCase):
@@ -875,3 +877,65 @@ def testInline(self):
self.failUnlessEqual(FooAccount.objects.all()[0].username, "%s-1" % foo_user)
self.failUnlessEqual(BarAccount.objects.all()[0].username, "%s-1" % bar_user)
self.failUnlessEqual(Persona.objects.all()[0].accounts.count(), 2)
+
+from django.core import mail
+
+class AdminActionsTest(TestCase):
+ fixtures = ['admin-views-users.xml', 'admin-views-actions.xml']
+
+ def setUp(self):
+ self.client.login(username='super', password='secret')
+
+ def tearDown(self):
+ self.client.logout()
+
+ def test_model_admin_custom_action(self):
+ "Tests a custom action defined in a ModelAdmin method"
+ action_data = {
+ ACTION_CHECKBOX_NAME: [1],
+ 'action' : 'mail_admin',
+ 'index': 0,
+ }
+ response = self.client.post('/test_admin/admin/admin_views/subscriber/', action_data)
+ self.assertEquals(len(mail.outbox), 1)
+ self.assertEquals(mail.outbox[0].subject, 'Greetings from a ModelAdmin action')
+
+ def test_model_admin_default_delete_action(self):
+ "Tests the default delete action defined as a ModelAdmin method"
+ action_data = {
+ ACTION_CHECKBOX_NAME: [1, 2],
+ 'action' : 'delete_selected',
+ 'index': 0,
+ }
+ delete_confirmation_data = {
+ ACTION_CHECKBOX_NAME: [1, 2],
+ 'action' : 'delete_selected',
+ 'index': 0,
+ 'post': 'yes',
+ }
+ confirmation = self.client.post('/test_admin/admin/admin_views/subscriber/', action_data)
+ self.assertContains(confirmation, "Are you sure you want to delete the selected subscriber objects")
+ self.failUnless(confirmation.content.count(ACTION_CHECKBOX_NAME) == 2)
+ response = self.client.post('/test_admin/admin/admin_views/subscriber/', delete_confirmation_data)
+ self.failUnlessEqual(Subscriber.objects.count(), 0)
+
+ def test_custom_function_mail_action(self):
+ "Tests a custom action defined in a function"
+ action_data = {
+ ACTION_CHECKBOX_NAME: [1],
+ 'action' : 'external_mail',
+ 'index': 0,
+ }
+ response = self.client.post('/test_admin/admin/admin_views/externalsubscriber/', action_data)
+ self.assertEquals(len(mail.outbox), 1)
+ self.assertEquals(mail.outbox[0].subject, 'Greetings from a function action')
+
+ def test_custom_function_action_with_redirect(self):
+ "Tests a custom action defined in a function"
+ action_data = {
+ ACTION_CHECKBOX_NAME: [1],
+ 'action' : 'redirect_to',
+ 'index': 0,
+ }
+ response = self.client.post('/test_admin/admin/admin_views/externalsubscriber/', action_data)
+ self.failUnlessEqual(response.status_code, 302)
Please sign in to comment.
Something went wrong with that request. Please try again.