Author: jacob
Date: 2009-03-23 15:22:56 -0500 (Mon, 23 Mar 2009)
New Revision: 10121

Added:
   django/trunk/django/contrib/admin/media/js/actions.js
   django/trunk/django/contrib/admin/templates/admin/actions.html
   
django/trunk/django/contrib/admin/templates/admin/delete_selected_confirmation.html
   django/trunk/docs/ref/contrib/admin/
   django/trunk/docs/ref/contrib/admin/_images/
   django/trunk/docs/ref/contrib/admin/_images/article_actions.png
   django/trunk/docs/ref/contrib/admin/_images/article_actions_message.png
   django/trunk/docs/ref/contrib/admin/_images/flatfiles_admin.png
   django/trunk/docs/ref/contrib/admin/_images/user_actions.png
   django/trunk/docs/ref/contrib/admin/_images/users_changelist.png
   django/trunk/docs/ref/contrib/admin/actions.txt
   django/trunk/docs/ref/contrib/admin/index.txt
   
django/trunk/tests/regressiontests/admin_views/fixtures/admin-views-actions.xml
Removed:
   django/trunk/docs/ref/contrib/_images/flatfiles_admin.png
   django/trunk/docs/ref/contrib/_images/users_changelist.png
   django/trunk/docs/ref/contrib/admin.txt
Modified:
   django/trunk/AUTHORS
   django/trunk/django/contrib/admin/__init__.py
   django/trunk/django/contrib/admin/helpers.py
   django/trunk/django/contrib/admin/media/css/changelists.css
   django/trunk/django/contrib/admin/options.py
   django/trunk/django/contrib/admin/sites.py
   django/trunk/django/contrib/admin/templates/admin/change_list.html
   django/trunk/django/contrib/admin/templatetags/admin_list.py
   django/trunk/django/contrib/admin/util.py
   django/trunk/django/contrib/admin/validation.py
   django/trunk/docs/index.txt
   django/trunk/docs/ref/contrib/index.txt
   django/trunk/tests/regressiontests/admin_registration/models.py
   django/trunk/tests/regressiontests/admin_views/models.py
   django/trunk/tests/regressiontests/admin_views/tests.py
Log:
Fixed #10505: added support for bulk admin actions, including a 
globally-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!

Modified: django/trunk/AUTHORS
===================================================================
--- django/trunk/AUTHORS        2009-03-23 16:37:25 UTC (rev 10120)
+++ django/trunk/AUTHORS        2009-03-23 20:22:56 UTC (rev 10121)
@@ -56,6 +56,7 @@
     Ned Batchelder <http://www.nedbatchelder.com/>
     [email protected]
     Batman
+    Brian Beck <http://blog.brianbeck.com/>
     Shannon -jj Behrens <http://jjinux.blogspot.com/>
     Esdras Beleza <[email protected]>
     Chris Bennett <[email protected]>
@@ -268,6 +269,7 @@
     Daniel Lindsley <[email protected]>
     Trey Long <[email protected]>
     msaelices <[email protected]>
+    Martin Mahner <http://www.mahner.org/>
     Matt McClanahan <http://mmcc.cx/>
     Frantisek Malina <[email protected]>
     Martin Maney <http://www.chipy.org/Martin_Maney>

Modified: django/trunk/django/contrib/admin/__init__.py
===================================================================
--- django/trunk/django/contrib/admin/__init__.py       2009-03-23 16:37:25 UTC 
(rev 10120)
+++ django/trunk/django/contrib/admin/__init__.py       2009-03-23 20:22:56 UTC 
(rev 10121)
@@ -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

Modified: django/trunk/django/contrib/admin/helpers.py
===================================================================
--- django/trunk/django/contrib/admin/helpers.py        2009-03-23 16:37:25 UTC 
(rev 10120)
+++ django/trunk/django/contrib/admin/helpers.py        2009-03-23 20:22:56 UTC 
(rev 10121)
@@ -6,7 +6,15 @@
 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):
         self.form, self.fieldsets = form, fieldsets
@@ -132,11 +140,11 @@
             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 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 @@
     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.

Modified: django/trunk/django/contrib/admin/media/css/changelists.css
===================================================================
--- django/trunk/django/contrib/admin/media/css/changelists.css 2009-03-23 
16:37:25 UTC (rev 10120)
+++ django/trunk/django/contrib/admin/media/css/changelists.css 2009-03-23 
20:22:56 UTC (rev 10121)
@@ -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;
+}

Added: django/trunk/django/contrib/admin/media/js/actions.js
===================================================================
--- django/trunk/django/contrib/admin/media/js/actions.js                       
        (rev 0)
+++ django/trunk/django/contrib/admin/media/js/actions.js       2009-03-23 
20:22:56 UTC (rev 10121)
@@ -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);

Modified: django/trunk/django/contrib/admin/options.py
===================================================================
--- django/trunk/django/contrib/admin/options.py        2009-03-23 16:37:25 UTC 
(rev 10120)
+++ django/trunk/django/contrib/admin/options.py        2009-03-23 20:22:56 UTC 
(rev 10121)
@@ -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 @@
     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 @@
         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 @@
         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,7 +406,122 @@
             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):
         """
         Construct a change message from a changed object.
@@ -529,6 +660,48 @@
             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 @@
                 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 @@
         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 @@
             '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 [

Modified: django/trunk/django/contrib/admin/sites.py
===================================================================
--- django/trunk/django/contrib/admin/sites.py  2009-03-23 16:37:25 UTC (rev 
10120)
+++ django/trunk/django/contrib/admin/sites.py  2009-03-23 20:22:56 UTC (rev 
10121)
@@ -28,11 +28,11 @@
     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 @@
         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 @@
             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 @@
                 # 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 @@
             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 @@
                 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 @@
                 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 @@
                     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 @@
         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 @@
         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 @@
             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 @@
                     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 @@
             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 @@
         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 @@
                             '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 @@
             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 @@
         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 @@
         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 @@
                 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 @@
         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.

Added: django/trunk/django/contrib/admin/templates/admin/actions.html
===================================================================
--- django/trunk/django/contrib/admin/templates/admin/actions.html              
                (rev 0)
+++ django/trunk/django/contrib/admin/templates/admin/actions.html      
2009-03-23 20:22:56 UTC (rev 10121)
@@ -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>

Modified: django/trunk/django/contrib/admin/templates/admin/change_list.html
===================================================================
--- django/trunk/django/contrib/admin/templates/admin/change_list.html  
2009-03-23 16:37:25 UTC (rev 10120)
+++ django/trunk/django/contrib/admin/templates/admin/change_list.html  
2009-03-23 20:22:56 UTC (rev 10121)
@@ -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 %}

Added: 
django/trunk/django/contrib/admin/templates/admin/delete_selected_confirmation.html
===================================================================
--- 
django/trunk/django/contrib/admin/templates/admin/delete_selected_confirmation.html
                         (rev 0)
+++ 
django/trunk/django/contrib/admin/templates/admin/delete_selected_confirmation.html
 2009-03-23 20:22:56 UTC (rev 10121)
@@ -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 %}
\ No newline at end of file

Modified: django/trunk/django/contrib/admin/templatetags/admin_list.py
===================================================================
--- django/trunk/django/contrib/admin/templatetags/admin_list.py        
2009-03-23 16:37:25 UTC (rev 10120)
+++ django/trunk/django/contrib/admin/templatetags/admin_list.py        
2009-03-23 20:22:56 UTC (rev 10121)
@@ -325,3 +325,12 @@
 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)

Modified: django/trunk/django/contrib/admin/util.py
===================================================================
--- django/trunk/django/contrib/admin/util.py   2009-03-23 16:37:25 UTC (rev 
10120)
+++ django/trunk/django/contrib/admin/util.py   2009-03-23 20:22:56 UTC (rev 
10121)
@@ -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 @@
         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 @@
                         [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 @@
                         [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 @@
                     # 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)

Modified: django/trunk/django/contrib/admin/validation.py
===================================================================
--- django/trunk/django/contrib/admin/validation.py     2009-03-23 16:37:25 UTC 
(rev 10120)
+++ django/trunk/django/contrib/admin/validation.py     2009-03-23 20:22:56 UTC 
(rev 10121)
@@ -63,7 +63,7 @@
     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 @@
                 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 @@
             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 @@
                 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 @@
             raise ImproperlyConfigured("'%s.%s' should be a boolean."
                     % (cls.__name__, attr))
 
+
     # inlines = []
     if hasattr(cls, 'inlines'):
         check_isseq(cls, 'inlines', cls.inlines)

Modified: django/trunk/docs/index.txt
===================================================================
--- django/trunk/docs/index.txt 2009-03-23 16:37:25 UTC (rev 10120)
+++ django/trunk/docs/index.txt 2009-03-23 20:22:56 UTC (rev 10121)
@@ -78,7 +78,7 @@
 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>`

Deleted: django/trunk/docs/ref/contrib/_images/flatfiles_admin.png
===================================================================
--- django/trunk/docs/ref/contrib/_images/flatfiles_admin.png   2009-03-23 
16:37:25 UTC (rev 10120)
+++ django/trunk/docs/ref/contrib/_images/flatfiles_admin.png   2009-03-23 
20:22:56 UTC (rev 10121)
@@ -1,294 +0,0 @@
-‰PNG
- 
-
--~--~---------~--~----~------------~-------~--~----~
You received this message because you are subscribed to the Google Groups 
"Django updates" group.
To post to this group, send email to [email protected]
To unsubscribe from this group, send email to 
[email protected]
For more options, visit this group at 
http://groups.google.com/group/django-updates?hl=en
-~----------~----~----~----~------~----~------~--~---

Reply via email to