Skip to content
This repository

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse code

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 authored March 23, 2009

Showing 26 changed files with 881 additions and 108 deletions. Show diff stats Hide diff stats

  1. 2  AUTHORS
  2. 1  django/contrib/admin/__init__.py
  3. 18  django/contrib/admin/helpers.py
  4. 44  django/contrib/admin/media/css/changelists.css
  5. 19  django/contrib/admin/media/js/actions.js
  6. 194  django/contrib/admin/options.py
  7. 125  django/contrib/admin/sites.py
  8. 5  django/contrib/admin/templates/admin/actions.html
  9. 12  django/contrib/admin/templates/admin/change_list.html
  10. 37  django/contrib/admin/templates/admin/delete_selected_confirmation.html
  11. 9  django/contrib/admin/templatetags/admin_list.py
  12. 91  django/contrib/admin/util.py
  13. 15  django/contrib/admin/validation.py
  14. 2  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  docs/ref/contrib/admin/actions.txt
  21. 23  docs/ref/contrib/{admin.txt → admin/index.txt}
  22. 2  docs/ref/contrib/index.txt
  23. 2  tests/regressiontests/admin_registration/models.py
  24. 15  tests/regressiontests/admin_views/fixtures/admin-views-actions.xml
  25. 38  tests/regressiontests/admin_views/models.py
  26. 96  tests/regressiontests/admin_views/tests.py
2  AUTHORS
@@ -56,6 +56,7 @@ answer newbie questions, and generally made Django that much better:
56 56
     Ned Batchelder <http://www.nedbatchelder.com/>
57 57
     batiste@dosimple.ch
58 58
     Batman
  59
+    Brian Beck <http://blog.brianbeck.com/>
59 60
     Shannon -jj Behrens <http://jjinux.blogspot.com/>
60 61
     Esdras Beleza <linux@esdrasbeleza.com>
61 62
     Chris Bennett <chrisrbennett@yahoo.com>
@@ -268,6 +269,7 @@ answer newbie questions, and generally made Django that much better:
268 269
     Daniel Lindsley <polarcowz@gmail.com>
269 270
     Trey Long <trey@ktrl.com>
270 271
     msaelices <msaelices@gmail.com>
  272
+    Martin Mahner <http://www.mahner.org/>
271 273
     Matt McClanahan <http://mmcc.cx/>
272 274
     Frantisek Malina <vizualbod@vizualbod.com>
273 275
     Martin Maney <http://www.chipy.org/Martin_Maney>
1  django/contrib/admin/__init__.py
... ...
@@ -1,3 +1,4 @@
  1
+from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
1 2
 from django.contrib.admin.options import ModelAdmin, HORIZONTAL, VERTICAL
2 3
 from django.contrib.admin.options import StackedInline, TabularInline
3 4
 from django.contrib.admin.sites import AdminSite, site
18  django/contrib/admin/helpers.py
@@ -6,6 +6,14 @@
6 6
 from django.utils.encoding import force_unicode
7 7
 from django.contrib.admin.util import flatten_fieldsets
8 8
 from django.contrib.contenttypes.models import ContentType
  9
+from django.utils.translation import ugettext_lazy as _
  10
+
  11
+ACTION_CHECKBOX_NAME = '_selected_action'
  12
+
  13
+class ActionForm(forms.Form):
  14
+    action = forms.ChoiceField(label=_('Action:'))
  15
+
  16
+checkbox = forms.CheckboxInput({'class': 'action-select'}, lambda value: False)
9 17
 
10 18
 class AdminForm(object):
11 19
     def __init__(self, form, fieldsets, prepopulated_fields):
@@ -132,11 +140,11 @@ def __init__(self, formset, form, fieldsets, prepopulated_fields, original):
132 140
             self.original.content_type_id = ContentType.objects.get_for_model(original).pk
133 141
         self.show_url = original and hasattr(original, 'get_absolute_url')
134 142
         super(InlineAdminForm, self).__init__(form, fieldsets, prepopulated_fields)
135  
-    
  143
+
136 144
     def __iter__(self):
137 145
         for name, options in self.fieldsets:
138 146
             yield InlineFieldset(self.formset, self.form, name, **options)
139  
-    
  147
+
140 148
     def field_count(self):
141 149
         # tabular.html uses this function for colspan value.
142 150
         num_of_fields = 1 # always has at least one field
@@ -149,7 +157,7 @@ def field_count(self):
149 157
 
150 158
     def pk_field(self):
151 159
         return AdminField(self.form, self.formset._pk_field.name, False)
152  
-    
  160
+
153 161
     def fk_field(self):
154 162
         fk = getattr(self.formset, "fk", None)
155 163
         if fk:
@@ -169,14 +177,14 @@ class InlineFieldset(Fieldset):
169 177
     def __init__(self, formset, *args, **kwargs):
170 178
         self.formset = formset
171 179
         super(InlineFieldset, self).__init__(*args, **kwargs)
172  
-        
  180
+
173 181
     def __iter__(self):
174 182
         fk = getattr(self.formset, "fk", None)
175 183
         for field in self.fields:
176 184
             if fk and fk.name == field:
177 185
                 continue
178 186
             yield Fieldline(self.form, field)
179  
-            
  187
+
180 188
 class AdminErrorList(forms.util.ErrorList):
181 189
     """
182 190
     Stores all errors for the form/formsets in an add/change stage view.
44  django/contrib/admin/media/css/changelists.css
@@ -50,12 +50,24 @@
50 50
 
51 51
 #changelist table thead th {
52 52
     white-space: nowrap;
  53
+    vertical-align: middle;
  54
+}
  55
+
  56
+#changelist table thead th:first-child {
  57
+    width: 1.5em;
  58
+    text-align: center;
53 59
 }
54 60
 
55 61
 #changelist table tbody td {
56 62
     border-left: 1px solid #ddd;
57 63
 }
58 64
 
  65
+#changelist table tbody td:first-child {
  66
+    border-left: 0;
  67
+    border-right: 1px solid #ddd;
  68
+    text-align: center;
  69
+}
  70
+
59 71
 #changelist table tfoot {
60 72
     color: #666;
61 73
 }
@@ -209,3 +221,35 @@
209 221
     border-color: #036;
210 222
 }
211 223
 
  224
+/* ACTIONS */
  225
+
  226
+.filtered .actions {
  227
+    margin-right: 160px !important;
  228
+    border-right: 1px solid #ddd;
  229
+}
  230
+
  231
+#changelist .actions {
  232
+    color: #666;
  233
+    padding: 3px;
  234
+    border-bottom: 1px solid #ddd;
  235
+    background: #e1e1e1 url(../img/admin/nav-bg.gif) top left repeat-x;
  236
+}
  237
+
  238
+#changelist .actions:last-child {
  239
+    border-bottom: none;
  240
+}
  241
+
  242
+#changelist .actions select {
  243
+    border: 1px solid #aaa;
  244
+    margin: 0 0.5em;
  245
+    padding: 1px 2px;
  246
+}
  247
+
  248
+#changelist .actions label {
  249
+    font-size: 11px;
  250
+    margin: 0 0.5em;
  251
+}
  252
+
  253
+#changelist #action-toggle {
  254
+    display: none;
  255
+}
19  django/contrib/admin/media/js/actions.js
... ...
@@ -0,0 +1,19 @@
  1
+var Actions = {
  2
+    init: function() {
  3
+        selectAll = document.getElementById('action-toggle');
  4
+        if (selectAll) {
  5
+            selectAll.style.display = 'inline';
  6
+            addEvent(selectAll, 'change', function() {
  7
+                Actions.checker(this.checked);
  8
+            });
  9
+        }
  10
+    },
  11
+    checker: function(checked) {
  12
+        actionCheckboxes = document.getElementsBySelector('tr input.action-select');
  13
+        for(var i = 0; i < actionCheckboxes.length; i++) {
  14
+            actionCheckboxes[i].checked = checked;
  15
+        }
  16
+    }
  17
+}
  18
+
  19
+addEvent(window, 'load', Actions.init);
194  django/contrib/admin/options.py
@@ -5,9 +5,10 @@
5 5
 from django.contrib.contenttypes.models import ContentType
6 6
 from django.contrib.admin import widgets
7 7
 from django.contrib.admin import helpers
8  
-from django.contrib.admin.util import unquote, flatten_fieldsets, get_deleted_objects
  8
+from django.contrib.admin.util import unquote, flatten_fieldsets, get_deleted_objects, model_ngettext, model_format_dict
9 9
 from django.core.exceptions import PermissionDenied
10 10
 from django.db import models, transaction
  11
+from django.db.models.fields import BLANK_CHOICE_DASH
11 12
 from django.http import Http404, HttpResponse, HttpResponseRedirect
12 13
 from django.shortcuts import get_object_or_404, render_to_response
13 14
 from django.utils.functional import update_wrapper
@@ -16,7 +17,7 @@
16 17
 from django.utils.functional import curry
17 18
 from django.utils.text import capfirst, get_text_list
18 19
 from django.utils.translation import ugettext as _
19  
-from django.utils.translation import ngettext
  20
+from django.utils.translation import ngettext, ugettext_lazy
20 21
 from django.utils.encoding import force_unicode
21 22
 try:
22 23
     set
@@ -192,6 +193,12 @@ class ModelAdmin(BaseModelAdmin):
192 193
     delete_confirmation_template = None
193 194
     object_history_template = None
194 195
 
  196
+    # Actions
  197
+    actions = ['delete_selected']
  198
+    action_form = helpers.ActionForm
  199
+    actions_on_top = True
  200
+    actions_on_bottom = False
  201
+
195 202
     def __init__(self, model, admin_site):
196 203
         self.model = model
197 204
         self.opts = model._meta
@@ -200,6 +207,13 @@ def __init__(self, model, admin_site):
200 207
         for inline_class in self.inlines:
201 208
             inline_instance = inline_class(self.model, self.admin_site)
202 209
             self.inline_instances.append(inline_instance)
  210
+        if 'action_checkbox' not in self.list_display:
  211
+            self.list_display = ['action_checkbox'] +  list(self.list_display)
  212
+        if not self.list_display_links:
  213
+            for name in self.list_display:
  214
+                if name != 'action_checkbox':
  215
+                    self.list_display_links = [name]
  216
+                    break
203 217
         super(ModelAdmin, self).__init__()
204 218
 
205 219
     def get_urls(self):
@@ -239,6 +253,8 @@ def _media(self):
239 253
         from django.conf import settings
240 254
 
241 255
         js = ['js/core.js', 'js/admin/RelatedObjectLookups.js']
  256
+        if self.actions:
  257
+            js.extend(['js/getElementsBySelector.js', 'js/actions.js'])
242 258
         if self.prepopulated_fields:
243 259
             js.append('js/urlify.js')
244 260
         if self.opts.get_ordered_objects():
@@ -390,6 +406,121 @@ def log_deletion(self, request, object, object_repr):
390 406
             action_flag     = DELETION
391 407
         )
392 408
 
  409
+    def action_checkbox(self, obj):
  410
+        """
  411
+        A list_display column containing a checkbox widget.
  412
+        """
  413
+        return helpers.checkbox.render(helpers.ACTION_CHECKBOX_NAME, force_unicode(obj.pk))
  414
+    action_checkbox.short_description = mark_safe('<input type="checkbox" id="action-toggle" />')
  415
+    action_checkbox.allow_tags = True
  416
+
  417
+    def get_actions(self, request=None):
  418
+        """
  419
+        Return a dictionary mapping the names of all actions for this
  420
+        ModelAdmin to a tuple of (callable, name, description) for each action.
  421
+        """
  422
+        actions = {}
  423
+        for klass in [self.admin_site] + self.__class__.mro()[::-1]:
  424
+            for action in getattr(klass, 'actions', []):
  425
+                func, name, description = self.get_action(action)
  426
+                actions[name] = (func, name, description)
  427
+        return actions
  428
+
  429
+    def get_action_choices(self, request=None, default_choices=BLANK_CHOICE_DASH):
  430
+        """
  431
+        Return a list of choices for use in a form object.  Each choice is a
  432
+        tuple (name, description).
  433
+        """
  434
+        choices = [] + default_choices
  435
+        for func, name, description in self.get_actions(request).itervalues():
  436
+            choice = (name, description % model_format_dict(self.opts))
  437
+            choices.append(choice)
  438
+        return choices
  439
+
  440
+    def get_action(self, action):
  441
+        """
  442
+        Return a given action from a parameter, which can either be a calable,
  443
+        or the name of a method on the ModelAdmin.  Return is a tuple of
  444
+        (callable, name, description).
  445
+        """
  446
+        if callable(action):
  447
+            func = action
  448
+            action = action.__name__
  449
+        elif hasattr(self, action):
  450
+            func = getattr(self, action)
  451
+        if hasattr(func, 'short_description'):
  452
+            description = func.short_description
  453
+        else:
  454
+            description = capfirst(action.replace('_', ' '))
  455
+        return func, action, description
  456
+
  457
+    def delete_selected(self, request, queryset):
  458
+        """
  459
+        Default action which deletes the selected objects.
  460
+        
  461
+        In the first step, it displays a confirmation page whichs shows all
  462
+        the deleteable objects or, if the user has no permission one of the
  463
+        related childs (foreignkeys) it displays a "permission denied" message.
  464
+        
  465
+        In the second step delete all selected objects and display the change
  466
+        list again.
  467
+        """
  468
+        opts = self.model._meta
  469
+        app_label = opts.app_label
  470
+
  471
+        # Check that the user has delete permission for the actual model
  472
+        if not self.has_delete_permission(request):
  473
+            raise PermissionDenied
  474
+
  475
+        # Populate deletable_objects, a data structure of all related objects that
  476
+        # will also be deleted.
  477
+        
  478
+        # deletable_objects must be a list if we want to use '|unordered_list' in the template
  479
+        deletable_objects = []
  480
+        perms_needed = set()
  481
+        i = 0
  482
+        for obj in queryset:
  483
+            deletable_objects.append([mark_safe(u'%s: <a href="%s/">%s</a>' % (escape(force_unicode(capfirst(opts.verbose_name))), obj.pk, escape(obj))), []])
  484
+            get_deleted_objects(deletable_objects[i], perms_needed, request.user, obj, opts, 1, self.admin_site, levels_to_root=2)
  485
+            i=i+1
  486
+
  487
+        # The user has already confirmed the deletion.
  488
+        # Do the deletion and return a None to display the change list view again.
  489
+        if request.POST.get('post'):
  490
+            if perms_needed:
  491
+                raise PermissionDenied
  492
+            n = queryset.count()
  493
+            if n:
  494
+                for obj in queryset:
  495
+                    obj_display = force_unicode(obj)
  496
+                    self.log_deletion(request, obj, obj_display)
  497
+                queryset.delete()
  498
+                self.message_user(request, _("Successfully deleted %d %s.") % (
  499
+                    n, model_ngettext(self.opts, n)
  500
+                ))
  501
+            # Return None to display the change list page again.
  502
+            return None
  503
+
  504
+        context = {
  505
+            "title": _("Are you sure?"),
  506
+            "object_name": force_unicode(opts.verbose_name),
  507
+            "deletable_objects": deletable_objects,
  508
+            'queryset': queryset,
  509
+            "perms_lacking": perms_needed,
  510
+            "opts": opts,
  511
+            "root_path": self.admin_site.root_path,
  512
+            "app_label": app_label,
  513
+            'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME,
  514
+        }
  515
+        
  516
+        # Display the confirmation page
  517
+        return render_to_response(self.delete_confirmation_template or [
  518
+            "admin/%s/%s/delete_selected_confirmation.html" % (app_label, opts.object_name.lower()),
  519
+            "admin/%s/delete_selected_confirmation.html" % app_label,
  520
+            "admin/delete_selected_confirmation.html"
  521
+        ], context, context_instance=template.RequestContext(request))
  522
+
  523
+    delete_selected.short_description = ugettext_lazy("Delete selected %(verbose_name_plural)s")
393 524
 
394 525
     def construct_change_message(self, request, form, formsets):
395 526
         """
@@ -529,6 +660,48 @@ def response_change(self, request, obj):
529 660
             self.message_user(request, msg)
530 661
             return HttpResponseRedirect("../")
531 662
 
  663
+    def response_action(self, request, queryset):
  664
+        """
  665
+        Handle an admin action. This is called if a request is POSTed to the
  666
+        changelist; it returns an HttpResponse if the action was handled, and
  667
+        None otherwise.
  668
+        """
  669
+        # There can be multiple action forms on the page (at the top
  670
+        # and bottom of the change list, for example). Get the action
  671
+        # whose button was pushed.
  672
+        try:
  673
+            action_index = int(request.POST.get('index', 0))
  674
+        except ValueError:
  675
+            action_index = 0
  676
+
  677
+        # Construct the action form.
  678
+        data = request.POST.copy()
  679
+        data.pop(helpers.ACTION_CHECKBOX_NAME, None)
  680
+        data.pop("index", None)
  681
+        action_form = self.action_form(data, auto_id=None)
  682
+        action_form.fields['action'].choices = self.get_action_choices(request)
  683
+
  684
+        # If the form's valid we can handle the action.
  685
+        if action_form.is_valid():
  686
+            action = action_form.cleaned_data['action']
  687
+            func, name, description = self.get_actions(request)[action]
  688
+            
  689
+            # Get the list of selected PKs. If nothing's selected, we can't 
  690
+            # perform an action on it, so bail.
  691
+            selected = request.POST.getlist(helpers.ACTION_CHECKBOX_NAME)
  692
+            if not selected:
  693
+                return None
  694
+
  695
+            response = func(request, queryset.filter(pk__in=selected))
  696
+                        
  697
+            # Actions may return an HttpResponse, which will be used as the
  698
+            # response from the POST. If not, we'll be a good little HTTP
  699
+            # citizen and redirect back to the changelist page.
  700
+            if isinstance(response, HttpResponse):
  701
+                return response
  702
+            else:
  703
+                return HttpResponseRedirect(".")
  704
+
532 705
     def add_view(self, request, form_url='', extra_context=None):
533 706
         "The 'add' admin view for this model."
534 707
         model = self.model
@@ -721,6 +894,14 @@ def changelist_view(self, request, extra_context=None):
721 894
                 return render_to_response('admin/invalid_setup.html', {'title': _('Database error')})
722 895
             return HttpResponseRedirect(request.path + '?' + ERROR_FLAG + '=1')
723 896
 
  897
+        # If the request was POSTed, this might be a bulk action or a bulk edit.
  898
+        # Try to look up an action first, but if this isn't an action the POST
  899
+        # will fall through to the bulk edit check, below.
  900
+        if request.method == 'POST':
  901
+            response = self.response_action(request, queryset=cl.get_query_set())
  902
+            if response:
  903
+                return response
  904
+                
724 905
         # If we're allowing changelist editing, we need to construct a formset
725 906
         # for the changelist given all the fields to be edited. Then we'll
726 907
         # use the formset to validate/process POSTed data.
@@ -764,7 +945,11 @@ def changelist_view(self, request, extra_context=None):
764 945
         if formset:
765 946
             media = self.media + formset.media
766 947
         else:
767  
-            media = None
  948
+            media = self.media
  949
+            
  950
+        # Build the action form and populate it with available actions.
  951
+        action_form = self.action_form(auto_id=None)
  952
+        action_form.fields['action'].choices = self.get_action_choices(request)        
768 953
 
769 954
         context = {
770 955
             'title': cl.title,
@@ -774,6 +959,9 @@ def changelist_view(self, request, extra_context=None):
774 959
             'has_add_permission': self.has_add_permission(request),
775 960
             'root_path': self.admin_site.root_path,
776 961
             'app_label': app_label,
  962
+            'action_form': action_form,
  963
+            'actions_on_top': self.actions_on_top,
  964
+            'actions_on_bottom': self.actions_on_bottom,
777 965
         }
778 966
         context.update(extra_context or {})
779 967
         return render_to_response(self.change_list_template or [
125  django/contrib/admin/sites.py
@@ -28,11 +28,11 @@ class AdminSite(object):
28 28
     register() method, and the root() method can then be used as a Django view function
29 29
     that presents a full admin interface for the collection of registered models.
30 30
     """
31  
-    
  31
+
32 32
     index_template = None
33 33
     login_template = None
34 34
     app_index_template = None
35  
-    
  35
+
36 36
     def __init__(self, name=None):
37 37
         self._registry = {} # model_class class -> admin_class instance
38 38
         # TODO Root path is used to calculate urls under the old root() method
@@ -44,17 +44,19 @@ def __init__(self, name=None):
44 44
         else:
45 45
             name += '_'
46 46
         self.name = name
47  
-    
  47
+
  48
+        self.actions = []
  49
+
48 50
     def register(self, model_or_iterable, admin_class=None, **options):
49 51
         """
50 52
         Registers the given model(s) with the given admin class.
51  
-        
  53
+
52 54
         The model(s) should be Model classes, not instances.
53  
-        
  55
+
54 56
         If an admin class isn't given, it will use ModelAdmin (the default
55 57
         admin options). If keyword arguments are given -- e.g., list_display --
56 58
         they'll be applied as options to the admin class.
57  
-        
  59
+
58 60
         If a model is already registered, this will raise AlreadyRegistered.
59 61
         """
60 62
         if not admin_class:
@@ -65,13 +67,13 @@ def register(self, model_or_iterable, admin_class=None, **options):
65 67
             from django.contrib.admin.validation import validate
66 68
         else:
67 69
             validate = lambda model, adminclass: None
68  
-        
  70
+
69 71
         if isinstance(model_or_iterable, ModelBase):
70 72
             model_or_iterable = [model_or_iterable]
71 73
         for model in model_or_iterable:
72 74
             if model in self._registry:
73 75
                 raise AlreadyRegistered('The model %s is already registered' % model.__name__)
74  
-            
  76
+
75 77
             # If we got **options then dynamically construct a subclass of
76 78
             # admin_class with those **options.
77 79
             if options:
@@ -80,17 +82,17 @@ def register(self, model_or_iterable, admin_class=None, **options):
80 82
                 # which causes issues later on.
81 83
                 options['__module__'] = __name__
82 84
                 admin_class = type("%sAdmin" % model.__name__, (admin_class,), options)
83  
-            
  85
+
84 86
             # Validate (which might be a no-op)
85 87
             validate(admin_class, model)
86  
-            
  88
+
87 89
             # Instantiate the admin class to save in the registry
88 90
             self._registry[model] = admin_class(model, self)
89  
-    
  91
+
90 92
     def unregister(self, model_or_iterable):
91 93
         """
92 94
         Unregisters the given model(s).
93  
-        
  95
+
94 96
         If a model isn't already registered, this will raise NotRegistered.
95 97
         """
96 98
         if isinstance(model_or_iterable, ModelBase):
@@ -99,44 +101,49 @@ def unregister(self, model_or_iterable):
99 101
             if model not in self._registry:
100 102
                 raise NotRegistered('The model %s is not registered' % model.__name__)
101 103
             del self._registry[model]
102  
-    
  104
+
  105
+    def add_action(self, action):
  106
+        if not callable(action):
  107
+            raise TypeError("You can only register callable actions through an admin site")
  108
+        self.actions.append(action)
  109
+
103 110
     def has_permission(self, request):
104 111
         """
105 112
         Returns True if the given HttpRequest has permission to view
106 113
         *at least one* page in the admin site.
107 114
         """
108 115
         return request.user.is_authenticated() and request.user.is_staff
109  
-    
  116
+
110 117
     def check_dependencies(self):
111 118
         """
112 119
         Check that all things needed to run the admin have been correctly installed.
113  
-        
  120
+
114 121
         The default implementation checks that LogEntry, ContentType and the
115 122
         auth context processor are installed.
116 123
         """
117 124
         from django.contrib.admin.models import LogEntry
118 125
         from django.contrib.contenttypes.models import ContentType
119  
-        
  126
+
120 127
         if not LogEntry._meta.installed:
121 128
             raise ImproperlyConfigured("Put 'django.contrib.admin' in your INSTALLED_APPS setting in order to use the admin application.")
122 129
         if not ContentType._meta.installed:
123 130
             raise ImproperlyConfigured("Put 'django.contrib.contenttypes' in your INSTALLED_APPS setting in order to use the admin application.")
124 131
         if 'django.core.context_processors.auth' not in settings.TEMPLATE_CONTEXT_PROCESSORS:
125 132
             raise ImproperlyConfigured("Put 'django.core.context_processors.auth' in your TEMPLATE_CONTEXT_PROCESSORS setting in order to use the admin application.")
126  
-        
  133
+
127 134
     def admin_view(self, view):
128 135
         """
129 136
         Decorator to create an "admin view attached to this ``AdminSite``. This
130 137
         wraps the view and provides permission checking by calling
131 138
         ``self.has_permission``.
132  
-        
  139
+
133 140
         You'll want to use this from within ``AdminSite.get_urls()``:
134  
-            
  141
+
135 142
             class MyAdminSite(AdminSite):
136  
-                
  143
+
137 144
                 def get_urls(self):
138 145
                     from django.conf.urls.defaults import patterns, url
139  
-                    
  146
+
140 147
                     urls = super(MyAdminSite, self).get_urls()
141 148
                     urls += patterns('',
142 149
                         url(r'^my_view/$', self.protected_view(some_view))
@@ -148,15 +155,15 @@ def inner(request, *args, **kwargs):
148 155
                 return self.login(request)
149 156
             return view(request, *args, **kwargs)
150 157
         return update_wrapper(inner, view)
151  
-    
  158
+
152 159
     def get_urls(self):
153 160
         from django.conf.urls.defaults import patterns, url, include
154  
-        
  161
+
155 162
         def wrap(view):
156 163
             def wrapper(*args, **kwargs):
157 164
                 return self.admin_view(view)(*args, **kwargs)
158 165
             return update_wrapper(wrapper, view)
159  
-        
  166
+
160 167
         # Admin-site-wide views.
161 168
         urlpatterns = patterns('',
162 169
             url(r'^$',
@@ -180,7 +187,7 @@ def wrapper(*args, **kwargs):
180 187
                 wrap(self.app_index),
181 188
                 name='%sadmin_app_list' % self.name),
182 189
         )
183  
-        
  190
+
184 191
         # Add in each model's views.
185 192
         for model, model_admin in self._registry.iteritems():
186 193
             urlpatterns += patterns('',
@@ -188,11 +195,11 @@ def wrapper(*args, **kwargs):
188 195
                     include(model_admin.urls))
189 196
             )
190 197
         return urlpatterns
191  
-    
  198
+
192 199
     def urls(self):
193 200
         return self.get_urls()
194 201
     urls = property(urls)
195  
-        
  202
+
196 203
     def password_change(self, request):
197 204
         """
198 205
         Handles the "change password" task -- both form display and validation.
@@ -200,18 +207,18 @@ def password_change(self, request):
200 207
         from django.contrib.auth.views import password_change
201 208
         return password_change(request,
202 209
             post_change_redirect='%spassword_change/done/' % self.root_path)
203  
-    
  210
+
204 211
     def password_change_done(self, request):
205 212
         """
206 213
         Displays the "success" page after a password change.
207 214
         """
208 215
         from django.contrib.auth.views import password_change_done
209 216
         return password_change_done(request)
210  
-    
  217
+
211 218
     def i18n_javascript(self, request):
212 219
         """
213 220
         Displays the i18n JavaScript that the Django admin requires.
214  
-        
  221
+
215 222
         This takes into account the USE_I18N setting. If it's set to False, the
216 223
         generated JavaScript will be leaner and faster.
217 224
         """
@@ -220,23 +227,23 @@ def i18n_javascript(self, request):
220 227
         else:
221 228
             from django.views.i18n import null_javascript_catalog as javascript_catalog
222 229
         return javascript_catalog(request, packages='django.conf')
223  
-    
  230
+
224 231
     def logout(self, request):
225 232
         """
226 233
         Logs out the user for the given HttpRequest.
227  
-        
  234
+
228 235
         This should *not* assume the user is already logged in.
229 236
         """
230 237
         from django.contrib.auth.views import logout
231 238
         return logout(request)
232 239
     logout = never_cache(logout)
233  
-    
  240
+
234 241
     def login(self, request):
235 242
         """
236 243
         Displays the login form for the given HttpRequest.
237 244
         """
238 245
         from django.contrib.auth.models import User
239  
-        
  246
+
240 247
         # If this isn't already the login page, display it.
241 248
         if not request.POST.has_key(LOGIN_FORM_KEY):
242 249
             if request.POST:
@@ -244,14 +251,14 @@ def login(self, request):
244 251
             else:
245 252
                 message = ""
246 253
             return self.display_login_form(request, message)
247  
-        
  254
+
248 255
         # Check that the user accepts cookies.
249 256
         if not request.session.test_cookie_worked():
250 257
             message = _("Looks like your browser isn't configured to accept cookies. Please enable cookies, reload this page, and try again.")
251 258
             return self.display_login_form(request, message)
252 259
         else:
253 260
             request.session.delete_test_cookie()
254  
-        
  261
+
255 262
         # Check the password.
256 263
         username = request.POST.get('username', None)
257 264
         password = request.POST.get('password', None)
@@ -271,7 +278,7 @@ def login(self, request):
271 278
                     else:
272 279
                         message = _("Usernames cannot contain the '@' character.")
273 280
             return self.display_login_form(request, message)
274  
-        
  281
+
275 282
         # The user data is correct; log in the user in and continue.
276 283
         else:
277 284
             if user.is_active and user.is_staff:
@@ -280,7 +287,7 @@ def login(self, request):
280 287
             else:
281 288
                 return self.display_login_form(request, ERROR_MESSAGE)
282 289
     login = never_cache(login)
283  
-    
  290
+
284 291
     def index(self, request, extra_context=None):
285 292
         """
286 293
         Displays the main admin index page, which lists all of the installed
@@ -291,14 +298,14 @@ def index(self, request, extra_context=None):
291 298
         for model, model_admin in self._registry.items():
292 299
             app_label = model._meta.app_label
293 300
             has_module_perms = user.has_module_perms(app_label)
294  
-            
  301
+
295 302
             if has_module_perms:
296 303
                 perms = {
297 304
                     'add': model_admin.has_add_permission(request),
298 305
                     'change': model_admin.has_change_permission(request),
299 306
                     'delete': model_admin.has_delete_permission(request),
300 307
                 }
301  
-                
  308
+
302 309
                 # Check whether user has any perm for this module.
303 310
                 # If so, add the module to the model_list.
304 311
                 if True in perms.values():
@@ -316,15 +323,15 @@ def index(self, request, extra_context=None):
316 323
                             'has_module_perms': has_module_perms,
317 324
                             'models': [model_dict],
318 325
                         }
319  
-        
  326
+
320 327
         # Sort the apps alphabetically.
321 328
         app_list = app_dict.values()
322 329
         app_list.sort(lambda x, y: cmp(x['name'], y['name']))
323  
-        
  330
+
324 331
         # Sort the models alphabetically within each app.
325 332
         for app in app_list:
326 333
             app['models'].sort(lambda x, y: cmp(x['name'], y['name']))
327  
-        
  334
+
328 335
         context = {
329 336
             'title': _('Site administration'),
330 337
             'app_list': app_list,
@@ -335,7 +342,7 @@ def index(self, request, extra_context=None):
335 342
             context_instance=template.RequestContext(request)
336 343
         )
337 344
     index = never_cache(index)
338  
-    
  345
+
339 346
     def display_login_form(self, request, error_message='', extra_context=None):
340 347
         request.session.set_test_cookie()
341 348
         context = {
@@ -348,7 +355,7 @@ def display_login_form(self, request, error_message='', extra_context=None):
348 355
         return render_to_response(self.login_template or 'admin/login.html', context,
349 356
             context_instance=template.RequestContext(request)
350 357
         )
351  
-    
  358
+
352 359
     def app_index(self, request, app_label, extra_context=None):
353 360
         user = request.user
354 361
         has_module_perms = user.has_module_perms(app_label)
@@ -394,46 +401,46 @@ def app_index(self, request, app_label, extra_context=None):
394 401
         return render_to_response(self.app_index_template or 'admin/app_index.html', context,
395 402
             context_instance=template.RequestContext(request)
396 403
         )
397  
-        
  404
+
398 405
     def root(self, request, url):
399 406
         """
400 407
         DEPRECATED. This function is the old way of handling URL resolution, and
401 408
         is deprecated in favor of real URL resolution -- see ``get_urls()``.
402  
-        
  409
+
403 410
         This function still exists for backwards-compatibility; it will be
404 411
         removed in Django 1.3.
405 412
         """
406 413
         import warnings
407 414
         warnings.warn(
408  
-            "AdminSite.root() is deprecated; use include(admin.site.urls) instead.", 
  415
+            "AdminSite.root() is deprecated; use include(admin.site.urls) instead.",
409 416
             PendingDeprecationWarning
410 417
         )
411  
-        
  418
+
412 419
         #
413 420
         # Again, remember that the following only exists for
414 421
         # backwards-compatibility. Any new URLs, changes to existing URLs, or
415 422
         # whatever need to be done up in get_urls(), above!
416 423
         #
417  
-        
  424
+
418 425
         if request.method == 'GET' and not request.path.endswith('/'):
419 426
             return http.HttpResponseRedirect(request.path + '/')
420  
-        
  427
+
421 428
         if settings.DEBUG:
422 429
             self.check_dependencies()
423  
-        
  430
+
424 431
         # Figure out the admin base URL path and stash it for later use
425 432
         self.root_path = re.sub(re.escape(url) + '$', '', request.path)
426  
-        
  433
+
427 434
         url = url.rstrip('/') # Trim trailing slash, if it exists.
428  
-        
  435
+
429 436
         # The 'logout' view doesn't require that the person is logged in.
430 437
         if url == 'logout':
431 438
             return self.logout(request)
432  
-        
  439
+
433 440
         # Check permission to continue or display login form.
434 441
         if not self.has_permission(request):
435 442
             return self.login(request)
436  
-        
  443
+
437 444
         if url == '':
438 445
             return self.index(request)
439 446
         elif url == 'password_change':
@@ -451,9 +458,9 @@ def root(self, request, url):
451 458
                 return self.model_page(request, *url.split('/', 2))
452 459
             else:
453 460
                 return self.app_index(request, url)
454  
-        
  461
+
455 462
         raise http.Http404('The requested admin page does not exist.')
456  
-        
  463
+
457 464
     def model_page(self, request, app_label, model_name, rest_of_url=None):
458 465
         """
459 466
         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):
468 475
         except KeyError:
469 476
             raise http.Http404("This model exists but has not been registered with the admin site.")
470 477
         return admin_obj(request, rest_of_url)
471  
-    model_page = never_cache(model_page)    
  478
+    model_page = never_cache(model_page)
472 479
 
473 480
 # This global object represents the default admin site, for the common case.
474 481
 # You can instantiate AdminSite in your own code to create a custom admin site.
5  django/contrib/admin/templates/admin/actions.html
... ...
@@ -0,0 +1,5 @@
  1
+{% load i18n %}
  2
+<div class="actions">
  3
+    {% for field in action_form %}<label>{{ field.label }} {{ field }}</label>{% endfor %}
  4
+    <button type="submit" class="button" title="{% trans "Run the selected action" %}" name="index" value="{{ action_index|default:0 }}">{% trans "Go" %}</button>
  5
+</div>
12  django/contrib/admin/templates/admin/change_list.html
@@ -7,8 +7,8 @@
7 7
   {% if cl.formset %}
8 8
     <link rel="stylesheet" type="text/css" href="{% admin_media_prefix %}css/forms.css" />
9 9
     <script type="text/javascript" src="../../jsi18n/"></script>
10  
-    {{ media }}
11 10
   {% endif %}
  11
+  {{ media }}
12 12
 {% endblock %}
13 13
 
14 14
 {% block bodyclass %}change-list{% endblock %}
@@ -63,14 +63,18 @@
63 63
         {% endif %}
64 64
       {% endblock %}
65 65
       
  66
+      <form action="" method="post"{% if cl.formset.is_multipart %} enctype="multipart/form-data"{% endif %}>
66 67
       {% if cl.formset %}
67  
-        <form action="" method="post"{% if cl.formset.is_multipart %} enctype="multipart/form-data"{% endif %}>
68 68
         {{ cl.formset.management_form }}
69 69
       {% endif %}
70 70
 
71  
-      {% block result_list %}{% result_list cl %}{% endblock %}
  71
+      {% block result_list %}
  72
+          {% if actions_on_top and cl.full_result_count %}{% admin_actions %}{% endif %}
  73
+          {% result_list cl %}
  74
+          {% if actions_on_bottom and cl.full_result_count %}{% admin_actions %}{% endif %}
  75
+      {% endblock %}
72 76
       {% block pagination %}{% pagination cl %}{% endblock %}
73  
-      {% if cl.formset %}</form>{% endif %}
  77
+      </form>
74 78
     </div>
75 79
   </div>
76 80
 {% endblock %}
37  django/contrib/admin/templates/admin/delete_selected_confirmation.html
... ...
@@ -0,0 +1,37 @@
  1
+{% extends "admin/base_site.html" %}
  2
+{% load i18n %}
  3
+
  4
+{% block breadcrumbs %}
  5
+<div class="breadcrumbs">
  6
+     <a href="../../">{% trans "Home" %}</a> &rsaquo;
  7
+     <a href="../">{{ app_label|capfirst }}</a> &rsaquo;
  8
+     <a href="./">{{ opts.verbose_name_plural|capfirst }}</a> &rsaquo;
  9
+     {% trans 'Delete multiple objects' %}
  10
+</div>
  11
+{% endblock %}
  12
+
  13
+{% block content %}
  14
+{% if perms_lacking %}
  15
+    <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>
  16
+    <ul>
  17
+    {% for obj in perms_lacking %}
  18
+        <li>{{ obj }}</li>
  19
+    {% endfor %}
  20
+    </ul>
  21
+{% else %}
  22
+    <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>
  23
+    {% for deleteable_object in deletable_objects %}
  24
+        <ul>{{ deleteable_object|unordered_list }}</ul>
  25
+    {% endfor %}
  26
+    <form action="" method="post">
  27
+    <div>
  28
+    {% for obj in queryset %}
  29
+    <input type="hidden" name="{{ action_checkbox_name }}" value="{{ obj.pk }}" />
  30
+    {% endfor %}
  31
+    <input type="hidden" name="action" value="delete_selected" />
  32
+    <input type="hidden" name="post" value="yes" />
  33
+    <input type="submit" value="{% trans "Yes, I'm sure" %}" />
  34
+    </div>
  35
+    </form>
  36
+{% endif %}
  37
+{% endblock %}
9  django/contrib/admin/templatetags/admin_list.py
@@ -325,3 +325,12 @@ def search_form(cl):
325 325
 def admin_list_filter(cl, spec):
326 326
     return {'title': spec.title(), 'choices' : list(spec.choices(cl))}
327 327
 admin_list_filter = register.inclusion_tag('admin/filter.html')(admin_list_filter)
  328
+
  329
+def admin_actions(context):
  330
+    """
  331
+    Track the number of times the action field has been rendered on the page,
  332
+    so we know which value to use.
  333
+    """
  334
+    context['action_index'] = context.get('action_index', -1) + 1
  335
+    return context
  336
+admin_actions = register.inclusion_tag("admin/actions.html", takes_context=True)(admin_actions)
91  django/contrib/admin/util.py
@@ -4,7 +4,8 @@
4 4
 from django.utils.safestring import mark_safe
5 5
 from django.utils.text import capfirst
6 6
 from django.utils.encoding import force_unicode
7  
-from django.utils.translation import ugettext as _
  7
+from django.utils.translation import ungettext, ugettext as _
  8
+from django.core.urlresolvers import reverse, NoReverseMatch
8 9
 
9 10
 def quote(s):
10 11
     """
@@ -60,8 +61,27 @@ def _nest_help(obj, depth, val):
60 61
         current = current[-1]
61 62
     current.append(val)
62 63
 
63  
-def get_deleted_objects(deleted_objects, perms_needed, user, obj, opts, current_depth, admin_site):
64  
-    "Helper function that recursively populates deleted_objects."
  64
+def get_change_view_url(app_label, module_name, pk, admin_site, levels_to_root):
  65
+    """
  66
+    Returns the url to the admin change view for the given app_label,
  67
+    module_name and primary key.
  68
+    """
  69
+    try:
  70
+        return reverse('%sadmin_%s_%s_change' % (admin_site.name, app_label, module_name), None, (pk,))
  71
+    except NoReverseMatch:
  72
+        return '%s%s/%s/%s/' % ('../'*levels_to_root, app_label, module_name, pk)
  73
+
  74
+def get_deleted_objects(deleted_objects, perms_needed, user, obj, opts, current_depth, admin_site, levels_to_root=4):
  75
+    """
  76
+    Helper function that recursively populates deleted_objects.
  77
+
  78
+    `levels_to_root` defines the number of directories (../) to reach the
  79
+    admin root path. In a change_view this is 4, in a change_list view 2.
  80
+
  81
+    This is for backwards compatibility since the options.delete_selected
  82
+    method uses this function also from a change_list view.
  83
+    This will not be used if we can reverse the URL.
  84
+    """
65 85
     nh = _nest_help # Bind to local variable for performance
66 86
     if current_depth > 16:
67 87
         return # Avoid recursing too deep.
@@ -91,11 +111,13 @@ def get_deleted_objects(deleted_objects, perms_needed, user, obj, opts, current_
91 111
                         [u'%s: %s' % (capfirst(related.opts.verbose_name), force_unicode(sub_obj)), []])
92 112
                 else:
93 113
                     # Display a link to the admin page.
94  
-                    nh(deleted_objects, current_depth, [mark_safe(u'%s: <a href="../../../../%s/%s/%s/">%s</a>' %
  114
+                    nh(deleted_objects, current_depth, [mark_safe(u'%s: <a href="%s">%s</a>' %
95 115
                         (escape(capfirst(related.opts.verbose_name)),
96  
-                        related.opts.app_label,
97  
-                        related.opts.object_name.lower(),
98  
-                        sub_obj._get_pk_val(),
  116
+                        get_change_view_url(related.opts.app_label,
  117
+                                            related.opts.object_name.lower(),
  118
+                                            sub_obj._get_pk_val(),
  119
+                                            admin_site,
  120
+                                            levels_to_root),
99 121
                         escape(sub_obj))), []])
100 122
                 get_deleted_objects(deleted_objects, perms_needed, user, sub_obj, related.opts, current_depth+2, admin_site)
101 123
         else:
@@ -109,11 +131,13 @@ def get_deleted_objects(deleted_objects, perms_needed, user, obj, opts, current_
109 131
                         [u'%s: %s' % (capfirst(related.opts.verbose_name), force_unicode(sub_obj)), []])
110 132
                 else:
111 133
                     # Display a link to the admin page.
112  
-                    nh(deleted_objects, current_depth, [mark_safe(u'%s: <a href="../../../../%s/%s/%s/">%s</a>' % 
  134
+                    nh(deleted_objects, current_depth, [mark_safe(u'%s: <a href="%s">%s</a>' %
113 135
                         (escape(capfirst(related.opts.verbose_name)),
114  
-                        related.opts.app_label,
115  
-                        related.opts.object_name.lower(),
116  
-                        sub_obj._get_pk_val(),
  136
+                        get_change_view_url(related.opts.app_label,
  137
+                                            related.opts.object_name.lower(),
  138
+                                            sub_obj._get_pk_val(),
  139
+                                            admin_site,
  140
+                                            levels_to_root),
117 141
                         escape(sub_obj))), []])
118 142
                 get_deleted_objects(deleted_objects, perms_needed, user, sub_obj, related.opts, current_depth+2, admin_site)
119 143
             # 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_
147 171
                     # Display a link to the admin page.
148 172
                     nh(deleted_objects, current_depth, [
149 173
                         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))}) + \
150  
-                        (u' <a href="../../../../%s/%s/%s/">%s</a>' % \
151  
-                            (related.opts.app_label, related.opts.module_name, sub_obj._get_pk_val(), escape(sub_obj)))), []])
  174
+                        (u' <a href="%s">%s</a>' % \
  175
+                            (get_change_view_url(related.opts.app_label,
  176
+                                                 related.opts.object_name.lower(),
  177
+                                                 sub_obj._get_pk_val(),
  178
+                                                 admin_site,
  179
+                                                 levels_to_root),
  180
+                            escape(sub_obj)))), []])
152 181
         # If there were related objects, and the user doesn't have
153 182
         # permission to change them, add the missing perm to perms_needed.
154 183
         if has_admin and has_related_objs:
155 184
             p = u'%s.%s' % (related.opts.app_label, related.opts.get_change_permission())
156 185
             if not user.has_perm(p):
157 186
                 perms_needed.add(related.opts.verbose_name)
  187
+
  188
+def model_format_dict(obj):
  189
+    """
  190
+    Return a `dict` with keys 'verbose_name' and 'verbose_name_plural',
  191
+    typically for use with string formatting.
  192
+
  193
+    `obj` may be a `Model` instance, `Model` subclass, or `QuerySet` instance.
  194
+
  195
+    """
  196
+    if isinstance(obj, (models.Model, models.base.ModelBase)):
  197
+        opts = obj._meta
  198
+    elif isinstance(obj, models.query.QuerySet):
  199
+        opts = obj.model._meta
  200
+    else:
  201
+        opts = obj
  202
+    return {
  203
+        'verbose_name': force_unicode(opts.verbose_name),
  204
+        'verbose_name_plural': force_unicode(opts.verbose_name_plural)
  205
+    }
  206
+
  207
+def model_ngettext(obj, n=None):
  208
+    """
  209
+    Return the appropriate `verbose_name` or `verbose_name_plural` for `obj`
  210
+    depending on the count `n`.
  211
+
  212
+    `obj` may be a `Model` instance, `Model` subclass, or `QuerySet` instance.
  213
+    If `obj` is a `QuerySet` instance, `n` is optional and the length of the
  214
+    `QuerySet` is used.
  215
+
  216
+    """
  217
+    if isinstance(obj, models.query.QuerySet):
  218
+        if n is None:
  219
+            n = obj.count()
  220
+        obj = obj.model
  221
+    d = model_format_dict(obj)
  222
+    return ungettext(d['verbose_name'], d['verbose_name_plural'], n or 0)
15  django/contrib/admin/validation.py
@@ -63,7 +63,