Преглед на файлове

HUE-835 [fb] Bulk operations

- Removed unnecessary options
- Changed generic_op to be more generic
- Updated test cases to include bulk operations
- Improved formset usage for formsets without management form
abec преди 13 години
родител
ревизия
c832574900

+ 47 - 7
apps/filebrowser/src/filebrowser/forms.py

@@ -17,6 +17,7 @@
 
 from django import forms
 from django.forms import FileField, CharField, BooleanField, Textarea
+from django.forms.formsets import formset_factory, BaseFormSet, ManagementForm
 
 from desktop.lib import i18n
 from filebrowser.lib import rwx
@@ -28,6 +29,26 @@ from django.utils.translation import ugettext_lazy as _
 import logging
 logger = logging.getLogger(__name__)
 
+class FormSet(BaseFormSet):
+  def __init__(self, data=None, prefix=None, *args, **kwargs):
+    self.prefix = prefix or self.get_default_prefix()
+    if data:
+      self.data = {}
+      # Add management field info
+      # This is hard coded given that none of these keys or info is exportable
+      # This could be a problem point if the management form changes in later releases
+      self.data['%s-TOTAL_FORMS' % self.prefix] = len(data)
+      self.data['%s-INITIAL_FORMS' % self.prefix] = len(data)
+      self.data['%s-MAX_NUM_FORMS' % self.prefix] = 0
+
+      # Add correct data
+      for i in range(0, len(data)):
+        prefix = self.add_prefix(i)
+        for field in data[i]:
+          self.data['%s-%s' % (prefix, field)] = data[i][field]
+    BaseFormSet.__init__(self, self.data, self.prefix, *args, **kwargs)
+
+
 class PathField(CharField):
   def __init__(self, label, help_text=None, **kwargs):
     kwargs.setdefault('required', True)
@@ -53,6 +74,11 @@ class RenameForm(forms.Form):
   src_path = CharField(label=_("File to rename"), help_text=_("The file to rename."))
   dest_path = CharField(label=_("New name"), help_text=_("Rename the file to:"))
 
+class BaseRenameFormSet(FormSet):
+  op = "rename"
+
+RenameFormSet = formset_factory(RenameForm, formset=BaseRenameFormSet, extra=0)
+
 class UploadFileForm(forms.Form):
   op = "upload"
   # The "hdfs" prefix in "hdfs_file" triggers the HDFSfileUploadHandler
@@ -76,6 +102,11 @@ class RmTreeForm(forms.Form):
   op = "rmtree"
   path = PathField(label=_("Directory to remove (recursively)"))
 
+class BaseRmTreeFormset(FormSet):
+  op = "rmtree"
+
+RmTreeFormSet = formset_factory(RmTreeForm, formset=BaseRmTreeFormset, extra=0)
+
 class MkDirForm(forms.Form):
   op = "mkdir"
   path = PathField(label=_("Path in which to create the directory"))
@@ -103,6 +134,11 @@ class ChownForm(forms.Form):
     self.all_groups = [ group.name for group in Group.objects.all() ]
     self.all_users = [ user.username for user in User.objects.all() ]
 
+class BaseChownFormSet(FormSet):
+  op = "chown"
+
+ChownFormSet = formset_factory(ChownForm, formset=BaseChownFormSet, extra=0)
+
 class ChmodForm(forms.Form):
   op = "chmod"
   path = PathField(label=_("Path to change permissions"))
@@ -126,7 +162,7 @@ class ChmodForm(forms.Form):
       "other_read", "other_write", "other_execute",
       "sticky")
 
-  def __init__(self, initial):
+  def __init__(self, initial, *args, **kwargs):
     logging.info(dir(self))
     logging.info(dir(type(self)))
     # Convert from string representation.
@@ -137,11 +173,15 @@ class ChmodForm(forms.Form):
       for name, b in zip(self.names, bools):
         initial[name] = b
     logging.debug(initial)
-    forms.Form.__init__(self, initial)
+    kwargs['initial'] = initial
+    forms.Form.__init__(self, *args, **kwargs)
 
-  def is_valid(self):
-    if forms.Form.is_valid(self):
+  def full_clean(self):
+    forms.Form.full_clean(self)
+    if hasattr(self, "cleaned_data"):
       self.cleaned_data["mode"] = rwx.compress_mode(map(lambda name: self.cleaned_data[name], self.names))
-      return True
-    else:
-      return False
+
+class BaseChmodFormSet(FormSet):
+  op = "chmod"
+
+ChmodFormSet = formset_factory(ChmodForm, formset=BaseChmodFormSet, extra=0)

+ 0 - 87
apps/filebrowser/src/filebrowser/templates/chmod.mako

@@ -1,87 +0,0 @@
-## Licensed to Cloudera, Inc. under one
-## or more contributor license agreements.  See the NOTICE file
-## distributed with this work for additional information
-## regarding copyright ownership.  Cloudera, Inc. licenses this file
-## to you under the Apache License, Version 2.0 (the
-## "License"); you may not use this file except in compliance
-## with the License.  You may obtain a copy of the License at
-##
-##     http://www.apache.org/licenses/LICENSE-2.0
-##
-## Unless required by applicable law or agreed to in writing, software
-## distributed under the License is distributed on an "AS IS" BASIS,
-## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-## See the License for the specific language governing permissions and
-## limitations under the License.
-<%!
-from django.utils.translation import ugettext as _
-%>
-
-<%namespace name="edit" file="editor_components.mako" />
-<style>
-.table-margin {
-    padding-left:20px;
-    padding-right:20px;
-}
-</style>
-
-<form action="/filebrowser/chmod?next=${next|u}" method="POST" enctype="multipart/form-data"
-      class="form-inline form-padding-fix">
-    <div class="modal-header">
-        <a href="#" class="close" data-dismiss="modal">&times;</a>
-        <h3>${_('Change Permissions:')} ${path}</h3>
-    </div>
-    <div class="modal-body table-margin">
-        ${edit.render_field(form["path"], hidden=True)}
-        <table class="table table-striped">
-            <thead>
-            <tr>
-                <th>&nbsp;</th>
-                <th class="center">${_('User')}</th>
-                <th class="center">${_('Group')}</th>
-                <th class="center">${_('Other')}</th>
-                <th class="center">&nbsp;</th>
-                <th width="120">&nbsp</th>
-            </tr>
-            </thead>
-            <tbody>
-            <tr>
-                <td><strong>${_('Read')}</strong></td>
-                <td class="center">${edit.render_field(form["user_read"], tag="checkbox", button_text=" ", nolabel=True)}</td>
-                <td class="center">${edit.render_field(form["group_read"], tag="checkbox", button_text=" ", nolabel=True)}</td>
-                <td class="center">${edit.render_field(form["other_read"], tag="checkbox", button_text=" ", nolabel=True)}</td>
-                <td colspan="2">&nbsp;</td>
-            </tr>
-            <tr>
-                <td><strong>${_('Write')}</strong></td>
-                <td class="center">${edit.render_field(form["user_write"], tag="checkbox", button_text=" ", nolabel=True)}</td>
-                <td class="center">${edit.render_field(form["group_write"], tag="checkbox", button_text=" ", nolabel=True)}</td>
-                <td class="center">${edit.render_field(form["other_write"], tag="checkbox", button_text=" ", nolabel=True)}</td>
-                <td colspan="2">&nbsp;</td>
-            </tr>
-            <tr>
-                <td><strong>${_('Execute')}</strong></td>
-                <td class="center">${edit.render_field(form["user_execute"], tag="checkbox", button_text=" ", nolabel=True)}</td>
-                <td class="center">${edit.render_field(form["group_execute"], tag="checkbox", button_text=" ", nolabel=True)}</td>
-                <td class="center">${edit.render_field(form["other_execute"], tag="checkbox", button_text=" ", nolabel=True)}</td>
-                <td colspan="2">&nbsp;</td>
-            </tr>
-            <tr>
-                <td><strong>${_('Sticky')}</strong></td>
-                <td colspan="3">&nbsp;</td>
-                <td class="center">${edit.render_field(form["sticky"], tag="checkbox", button_text=" ", nolabel=True)}</td>
-                <td>&nbsp;</td>
-            </tr>
-            <tr>
-                <td><strong>${_('Recursive')}</strong></td>
-                <td colspan="3">&nbsp;</td>
-                <td class="center">${edit.render_field(form["recursive"], tag="checkbox", button_text=" ", nolabel=True)}</td>
-                <td>&nbsp;</td>
-            </tbody>
-        </table>
-    </div>
-    <div class="modal-footer" style="padding-top: 10px;">
-        <a class="btn" onclick="$('#changePermissionModal').modal('hide');">${_('Cancel')}</a>
-        <input class="btn primary" type="submit" value="${_('Submit')}"/>
-    </div>
-</form>

+ 0 - 149
apps/filebrowser/src/filebrowser/templates/chown.mako

@@ -1,149 +0,0 @@
-## Licensed to Cloudera, Inc. under one
-## or more contributor license agreements.  See the NOTICE file
-## distributed with this work for additional information
-## regarding copyright ownership.  Cloudera, Inc. licenses this file
-## to you under the Apache License, Version 2.0 (the
-## "License"); you may not use this file except in compliance
-## with the License.  You may obtain a copy of the License at
-##
-##     http://www.apache.org/licenses/LICENSE-2.0
-##
-## Unless required by applicable law or agreed to in writing, software
-## distributed under the License is distributed on an "AS IS" BASIS,
-## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-## See the License for the specific language governing permissions and
-## limitations under the License.
-<%!
-from django.utils.translation import ugettext as _
-%>
-
-<%namespace name="edit" file="editor_components.mako" />
-<%! from desktop.lib.django_util import extract_field_data %>
-
-<%
-  is_superuser = extra_params['current_user'].username == extra_params['superuser']
-  select_filter = is_superuser and 'SelectWithOther' or ''
-%>
-
-## Puts together a selection list with an "other" field as well.
-<%def name="selection(name, choices, current_value, other_key=None)">
-    <% seen = False %>
-    % if len(choices) == 0:
-      <select name="${name}" class="hide">
-    % else:
-      <select name="${name}">
-    % endif
-    % for choice in choices:
-      % if choice == current_value:
-        <% seen = True %>
-        <option selected>${choice}</option>
-      % else:
-        <option>${choice}</option>
-      % endif
-    % endfor
-    % if is_superuser:
-      % if seen or not current_value:
-        <option value="__other__">Other</option>
-      % else:
-        <option value="__other__" selected="true">Other</option>
-      % endif
-    % endif
-
-    </select>
-    % if is_superuser:
-      % if seen or not current_value:
-        <input name="${other_key}" class="hide">
-      % else:
-        <input name="${other_key}" value="${current_value}">
-      % endif
-    % endif
-</%def>
-<form id="chownForm" action="/filebrowser/chown?next=${next|u}" method="POST" enctype="multipart/form-data" class="form-stacked form-padding-fix">
-    <div class="modal-header">
-        <a href="#" class="close" data-dismiss="modal">&times;</a>
-        <h3>${_('Change Owner / Group:')} ${path}</h3>
-    </div>
-    <div class="modal-body change-owner-modal-body clearfix" >
-        <div class="alert alert-message block-message info">${_('Note: Only the Hadoop superuser, "%(superuser)s" on this file system, may change the owner of a file.') % dict(superuser=extra_params['superuser'])}"</div>
-        <div style="padding-left: 15px; padding-bottom: 10px;">
-            ${edit.render_field(form["path"], hidden=True)}
-
-            <label>${_('User')}</label>
-
-            % if is_superuser:
-            ${ selection("user", form.all_users, extract_field_data(form["user"]), "user_other") }
-            % else:
-            ${ selection("user", [extract_field_data(form['user'])], extract_field_data(form["user"])) }
-            % endif
-
-            <label>${_('Group')}</label>
-
-            % if is_superuser:
-            ${ selection("group", form.all_groups, extract_field_data(form["group"]), "group_other") }
-            % else:
-            ${ selection("group", [group for group in form.all_groups if group in extra_params['current_user'].get_groups()], extract_field_data(form["group"])) }
-            % endif
-            <br />
-            <label style="display: inline;">${_('Recursive')}</label>${edit.render_field(form["recursive"], tag="checkbox", button_text=" ", nolabel=True)}
-        </div>
-
-
-    </div>
-    <div class="modal-footer" style="padding-top: 10px;">
-        <div id="chownRequired" class="hide" style="position: absolute; left: 10;">
-            <span class="label label-important">${_('Sorry, name is required.')}</span>
-        </div>
-        <a class="btn" onclick="$('#changeOwnerModal').modal('hide');">${_('Cancel')}</a>
-        <input class="btn primary" type="submit" value="${_('Submit')}" />
-    </div>
-</form>
-
-<script type="text/javascript" charset="utf-8">
-    $(document).ready(function(){
-        $("select[name='user']").change(function(){
-            if ($(this).val() == "__other__"){
-                $("input[name='user_other']").show();
-            }
-            else {
-                $("input[name='user_other']").hide();
-            }
-        });
-        $("select[name='group']").change(function(){
-            if ($(this).val() == "__other__"){
-                $("input[name='group_other']").show();
-            }
-            else {
-                $("input[name='group_other']").hide();
-            }
-        });
-
-        $("#chownForm").submit(function(){
-            if ($("select[name='user']").val() == null){
-                $("#chownRequired").find(".label").text("${_('Sorry, user is required.')}");
-                $("#chownRequired").show();
-                return false;
-            }
-            else if ($("select[name='group']").val() == null){
-                $("#chownRequired").find(".label").text("${_('Sorry, group is required.')}");
-                $("#chownRequired").show();
-                return false;
-            }
-            else {
-                if ($("select[name='group']").val() == "__other__" && $("input[name='group_other']").val() == ""){
-                    $("#chownRequired").find(".label").text("${_('Sorry, you need to specify another group.')}");
-                    $("input[name='group_other']").addClass("fieldError");
-                    $("#chownRequired").show();
-                    return false;
-                }
-                if ($("select[name='user']").val() == "__other__" && $("input[name='user_other']").val() == ""){
-                    $("#chownRequired").find(".label").text("${_('Sorry, you need to specify another user.')}");
-                    $("input[name='user_other']").addClass("fieldError");
-                    $("#chownRequired").show();
-                    return false;
-                }
-                return true;
-            }
-        });
-    });
-</script>
-

+ 41 - 6
apps/filebrowser/src/filebrowser/templates/editor_components.mako

@@ -24,6 +24,7 @@
   hidden=False,
   notitle=False,
   tag='input',
+  name=None,
   klass=None,
   attrs=None,
   value=None,
@@ -82,18 +83,18 @@
         ${unicode(field) | n}
       % else:
         % if tag == 'textarea':
-          <textarea name="${field.html_name | n}" ${make_attr_str(attrs) | n} />${extract_field_data(field) or ''}</textarea>
+          <textarea name="${name or field.html_name | n}" ${make_attr_str(attrs) | n} />${extract_field_data(field) or ''}</textarea>
         % elif tag == 'button':
-          <button name="${field.html_name | n}" ${make_attr_str(attrs) | n} value="${value}"/>${button_text or field.name or ''}</button>
+          <button name="${name or field.html_name | n}" ${make_attr_str(attrs) | n} value="${value}"/>${button_text or field.name or ''}</button>
         % elif tag == 'checkbox':
-          <input type="checkbox" name="${field.html_name | n}" ${make_attr_str(attrs) | n} ${value and "CHECKED" or ""}/>${button_text or field.name or ''}</input>
+          <input type="checkbox" name="${name or field.html_name | n}" ${make_attr_str(attrs) | n} ${value and "CHECKED" or ""}/>${button_text or field.name or ''}</input>
         % elif hidden:
-          <input type="hidden" name="${field.html_name | n}" ${make_attr_str(attrs) | n} value="${extract_field_data(field)}"></input>
+          <input type="hidden" name="${name or field.html_name | n}" ${make_attr_str(attrs) | n} value="${extract_field_data(field)}"></input>
         % else:
           %if file_chooser:
-            <${tag} type="text" name="${field.html_name | n}" value="${extract_field_data(field) or ''}" class="${cls}" ${make_attr_str(attrs) | n}/><a class="btn fileChooserBtn" href="#" data-filechooser-destination="${field.html_name | n}">..</a>
+            <${tag} type="text" name="${name or field.html_name | n}" value="${extract_field_data(field) or ''}" class="${cls}" ${make_attr_str(attrs) | n}/><a class="btn fileChooserBtn" href="#" data-filechooser-destination="${field.html_name | n}">..</a>
           %else:
-            <${tag} type="text" name="${field.html_name | n}" value="${extract_field_data(field) or ''}" class="${cls}" ${make_attr_str(attrs) | n}/>
+            <${tag} type="text" name="${name or field.html_name | n}" value="${extract_field_data(field) or ''}" class="${cls}" ${make_attr_str(attrs) | n}/>
           %endif
         % endif
       % endif
@@ -107,3 +108,37 @@
       </div>
     % endif
 </%def>
+
+## Puts together a selection list with an "other" field as well.
+<%def name="selection(name, choices, current_value, other_key=None)">
+    <% seen = False %>
+    % if len(choices) == 0:
+      <select name="${name}" class="hide">
+    % else:
+      <select name="${name}">
+    % endif
+    % for choice in choices:
+      % if choice == current_value:
+        <% seen = True %>
+        <option selected>${choice}</option>
+      % else:
+        <option>${choice}</option>
+      % endif
+    % endfor
+    % if is_superuser:
+      % if seen or not current_value:
+        <option value="__other__">Other</option>
+      % else:
+        <option value="__other__" selected="true">Other</option>
+      % endif
+    % endif
+
+    </select>
+    % if is_superuser:
+      % if seen or not current_value:
+        <input name="${other_key}" class="hide">
+      % else:
+        <input name="${other_key}" value="${current_value}">
+      % endif
+    % endif
+</%def>

+ 9 - 1
apps/filebrowser/src/filebrowser/templates/fileop.mako

@@ -15,6 +15,7 @@
 ## limitations under the License.
 <%!
 import datetime
+from django import forms
 from django.template.defaultfilters import urlencode, escape, stringformat, date, filesizeformat, time
 from desktop.views import commonheader, commonfooter
 from django.utils.translation import ugettext as _
@@ -30,7 +31,14 @@ ${commonheader(_('File Operation'), 'filebrowser', user)}
 <div class="well">
 <form action="" method="POST" enctype="multipart/form-data" class="form-stacked">
 <h1>${form.op}</h1>
-${form.as_p()|n}
+% if isinstance(form, forms.Form):
+	${form.as_p()|n}
+% else:
+	% for _form in form.forms:
+		${_form.as_p()|n}
+	% endfor
+	${form.management_form}
+% endif
 <div>
 <input type="submit" value="${('Submit')}" class="btn primary" />
 <a href="${urlencode(next)}" class="btn">${('Cancel')}</a>

+ 4 - 4
apps/filebrowser/src/filebrowser/templates/listdir.mako

@@ -35,12 +35,12 @@ ${commonheader(_('File Browser'), 'filebrowser', user)}
 
         <%def name="actions()">
             <button class="btn fileToolbarBtn" title="${_('Rename')}" data-bind="click: renameFile, enable: selectedFiles().length == 1"><i class="icon-font"></i> ${_('Rename')}</button>
-            <button class="btn fileToolbarBtn" title="${_('Move')}" data-bind="click: move, enable: selectedFiles().length == 1"><i class="icon-random"></i> ${_('Move')}</button>
+            <button class="btn fileToolbarBtn" title="${_('Move')}" data-bind="click: move, enable: selectedFiles().length > 0"><i class="icon-random"></i> ${_('Move')}</button>
             %if is_fs_superuser:
-                <button class="btn fileToolbarBtn" title="${_('Change Owner / Group')}" data-bind="click: changeOwner, enable: selectedFiles().length == 1"><i class="icon-user"></i> ${_('Change Owner / Group')}</button>
+                <button class="btn fileToolbarBtn" title="${_('Change Owner / Group')}" data-bind="click: changeOwner, enable: selectedFiles().length > 0"><i class="icon-user"></i> ${_('Change Owner / Group')}</button>
             %endif
-            <button class="btn fileToolbarBtn" title="${_('Change Permissions')}" data-bind="click: changePermissions, enable: selectedFiles().length == 1"><i class="icon-list-alt"></i> ${_('Change Permissions')}</button>
-            <button class="btn fileToolbarBtn" title="${_('Delete')}" data-bind="click: deleteSelected, enable: selectedFiles().length == 1"><i class="icon-trash"></i> ${_('Delete')}</button>
+            <button class="btn fileToolbarBtn" title="${_('Change Permissions')}" data-bind="click: changePermissions, enable: selectedFiles().length > 0"><i class="icon-list-alt"></i> ${_('Change Permissions')}</button>
+            <button class="btn fileToolbarBtn" title="${_('Delete')}" data-bind="click: deleteSelected, enable: selectedFiles().length > 0"><i class="icon-trash"></i> ${_('Delete')}</button>
         </%def>
 
         <%def name="creation()">

+ 271 - 45
apps/filebrowser/src/filebrowser/templates/listdir_components.mako

@@ -17,11 +17,13 @@
 import datetime
 import md5
 from django.template.defaultfilters import urlencode, stringformat, filesizeformat, date, time, escape
-from desktop.lib.django_util import reverse_with_get
+from desktop.lib.django_util import reverse_with_get, extract_field_data
 from django.utils.encoding import smart_str
 from django.utils.translation import ugettext as _
 %>
 
+<%namespace name="edit" file="editor_components.mako" />
+
 <%def name="list_table_chooser(files, path, current_request_path)">
   ${_table(files, path, current_request_path, 'chooser')}
 </%def>
@@ -127,13 +129,12 @@ from django.utils.translation import ugettext as _
             <h3>${_('Please Confirm')}</h3>
         </div>
         <div class="modal-body">
-            <p>${_('Are you sure you want to delete this file?')}</p>
+            <p>${_('Are you sure you want to delete these files?')}</p>
         </div>
         <div class="modal-footer">
-            <form id="deleteForm" action="" method="POST" enctype="multipart/form-data" class="form-stacked">
+            <form id="deleteForm" action="/filebrowser/rmtree" method="POST" enctype="multipart/form-data" class="form-stacked">
                 <input type="submit" value="${_('Yes')}" class="btn primary" />
                 <a id="cancelDeleteBtn" class="btn">${_('No')}</a>
-                <input id="fileToDeleteInput" type="hidden" name="path" />
             </form>
         </div>
     </div>
@@ -160,11 +161,130 @@ from django.utils.translation import ugettext as _
         </form>
     </div>
 
-    <div id="changeOwnerModal" class="modal hide fade"></div>
+    <!-- chown modal -->
+    % if is_superuser:
+    <div id="changeOwnerModal" class="modal hide fade">
+        <%
+          select_filter = is_superuser and 'SelectWithOther' or ''
+        %>
+        <form id="chownForm" action="/filebrowser/chown" method="POST" enctype="multipart/form-data" class="form-stacked form-padding-fix">
+            <div class="modal-header">
+                <a href="#" class="close" data-dismiss="modal">&times;</a>
+                <h3>${_('Change Owner / Group')}</h3>
+            </div>
+            <div class="modal-body change-owner-modal-body clearfix" >
+                <div class="alert alert-message block-message info">${_('Note: Only the Hadoop superuser, "%(superuser)s" on this file system, may change the owner of a file.') % dict(superuser=superuser)}</div>
+                <div style="padding-left: 15px; padding-bottom: 10px;">
+                    <label>${_('User')}</label>
+                    ${ edit.selection("user", users, user.username, "user_other") }
+                    <label>${_('Group')}</label>
+                    ${ edit.selection("group", groups, 'supergroup', "group_other") }
+                    <br />
+                    <label style="display: inline;">${_('Recursive')}</label><input type="checkbox" name="recursive">
+                </div>
 
-    <div id="changePermissionModal" class="modal hide fade"></div>
 
-    <div id="moveModal" class="modal hide fade"></div>
+            </div>
+            <div class="modal-footer" style="padding-top: 10px;">
+                <div id="chownRequired" class="hide" style="position: absolute; left: 10;">
+                    <span class="label label-important">${_('Sorry, name is required.')}</span>
+                </div>
+                <a class="btn" onclick="$('#changeOwnerModal').modal('hide');">${_('Cancel')}</a>
+                <input class="btn primary" type="submit" value="${_('Submit')}" />
+            </div>
+        </form>
+    </div>
+    % endif
+
+    <!-- chmod modal -->
+    <div id="changePermissionModal" class="modal hide fade">
+        <form action="/filebrowser/chmod" method="POST" enctype="multipart/form-data" class="form-inline form-padding-fix" id="chmodForm">
+            <div class="modal-header">
+                <a href="#" class="close" data-dismiss="modal">&times;</a>
+                <h3>${_('Change Permissions:')} </h3>
+            </div>
+            <div class="modal-body table-margin">
+                <table class="table table-striped">
+                    <thead>
+                    <tr>
+                        <th>&nbsp;</th>
+                        <th class="center">${_('User')}</th>
+                        <th class="center">${_('Group')}</th>
+                        <th class="center">${_('Other')}</th>
+                        <th class="center">&nbsp;</th>
+                        <th width="120">&nbsp</th>
+                    </tr>
+                    </thead>
+                    <tbody>
+                    <tr>
+                        <td><strong>${_('Read')}</strong></td>
+                        <td class="center"><input type="checkbox" data-bind="attr: {checked: selectedFile.mode }" checked="" name="user_read"></td>
+                        <td class="center"><input type="checkbox" data-bind="attr: {checked: selectedFile.mode }" checked="" name="group_read"></td>
+                        <td class="center"><input type="checkbox" data-bind="attr: {checked: selectedFile.mode }" checked="" name="other_read"></td>
+                        <td colspan="2">&nbsp;</td>
+                    </tr>
+                    <tr>
+                        <td><strong>${_('Write')}</strong></td>
+                        <td class="center"><input type="checkbox" data-bind="attr: {checked: selectedFile.mode }" checked="" name="user_write"></td>
+                        <td class="center"><input type="checkbox" data-bind="attr: {checked: selectedFile.mode }" checked="" name="group_write"></td>
+                        <td class="center"><input type="checkbox" data-bind="attr: {checked: selectedFile.mode }" checked="" name="other_write"></td>
+                        <td colspan="2">&nbsp;</td>
+                    </tr>
+                    <tr>
+                        <td><strong>${_('Execute')}</strong></td>
+                        <td class="center"><input type="checkbox" checked="" name="user_execute"></td>
+                        <td class="center"><input type="checkbox" checked="" name="group_execute"></td>
+                        <td class="center"><input type="checkbox" checked="" name="other_execute"></td>
+                        <td colspan="2">&nbsp;</td>
+                    </tr>
+                    <tr>
+                        <td><strong>${_('Sticky')}</strong></td>
+                        <td colspan="3">&nbsp;</td>
+                        <td class="center"><input type="checkbox" name="sticky"></td>
+                        <td>&nbsp;</td>
+                    </tr>
+                    <tr>
+                        <td><strong>${_('Recursive')}</strong></td>
+                        <td colspan="3">&nbsp;</td>
+                        <td class="center"><input type="checkbox" name="recursive"></td>
+                        <td>&nbsp;</td>
+                    </tbody>
+                </table>
+            </div>
+            <div class="modal-footer" style="padding-top: 10px;">
+                <a class="btn" onclick="$('#changePermissionModal').modal('hide');">${_('Cancel')}</a>
+                <input class="btn primary" type="submit" value="${_('Submit')}"/>
+            </div>
+        </form>
+    </div>
+
+    <!-- move modal -->
+    <div id="moveModal" class="modal hide fade">
+        <form id="moveForm" action="/filebrowser/move" method="POST" enctype="multipart/form-data" class="form-inline form-padding-fix">
+            <div class="modal-header">
+                <a href="#" class="close" data-dismiss="modal">&times;</a>
+                <h3>${_('Move:')}</h3>
+            </div>
+            <div class="modal-body">
+                <div style="padding-left: 15px;">
+                    <label for="id_dest_path">${_('Destination')}</label>
+                    <input type="text" class="input-xlarge pathChooser" value="" name="dest_path" id="moveDestination" />
+                    <a class="btn fileChooserBtn" href="#" data-filechooser-destination="dest_path">..</a>
+                </div>
+                <br/>
+                <div class="fileChooserModal" class="smallModal well hide">
+                    <a href="#" class="close" data-dismiss="modal">&times;</a>
+                </div>
+            </div>
+            <div class="modal-footer">
+                <div id="moveNameRequiredAlert" class="hide" style="position: absolute; left: 10;">
+                    <span class="label label-important">${_('Sorry, name is required.')}</span>
+                </div>
+                <a class="btn" onclick="$('#moveModal').modal('hide');">${_('Cancel')}</a>
+                <input class="btn primary" type="submit" value="${_('Submit')}"/>
+            </div>
+        </form>
+    </div>
 
     <!-- upload file modal -->
     <div id="uploadFileModal" class="modal hide fade">
@@ -261,43 +381,73 @@ from django.utils.translation import ugettext as _
             });
         }
 
-        function openChmodWindow(path, mode, next){
-            $.ajax({
-                url: "/filebrowser/chmod",
-                data: {"path":path, "mode":mode, "next" : next},
-                beforeSend: function(xhr){
-                    xhr.setRequestHeader("X-Requested-With", "Hue");
+        // Modal file chooser
+        // The file chooser should be at least 2 levels deeper than the modal container
+        $(".fileChooserBtn").on('click', function(e){
+            e.preventDefault();
+            var _destination = $(this).attr("data-filechooser-destination");
+            var fileChooser = $(this).parent().parent().find(".fileChooserModal");
+            fileChooser.jHueFileChooser({
+                initialPath: $("input[name='"+_destination+"']").val(),
+                onFolderChange: function(folderPath){
+                    $("input[name='"+_destination+"']").val(folderPath);
                 },
-                dataType: "html",
-                success: function(data){
-                    $("#changePermissionModal").html(data);
-                    $("#changePermissionModal").modal({
-                        keyboard: true,
-                        show: true
-                    });
-                }
+                onFolderChoose: function(folderPath){
+                    $("input[name='"+_destination+"']").val(folderPath);
+                    fileChooser.slideUp();
+                },
+                selectFolder: true,
+                createFolder: true,
+                uploadFile: false
             });
-        }
+            fileCooser.slideDown();
+        });
 
-        function openMoveModal(src_path, mode, next){
-            $.ajax({
-                url: "/filebrowser/move",
-                data: {"src_path":src_path, "mode":mode, "next" : next},
-                beforeSend: function(xhr){
-                    xhr.setRequestHeader("X-Requested-With", "Hue");
-                },
-                dataType: "html",
-                success: function(data){
-                    $("#moveModal").html(data);
-                    $("#moveModal").modal({
-                        keyboard: true,
-                        show: true
-                    });
+        $(document).ready(function(){
+            $("#chownForm select[name='user']").change(function(){
+                if ($(this).val() == "__other__"){
+                    $("input[name='user_other']").show();
+                }
+                else {
+                    $("input[name='user_other']").hide();
+                }
+            });
+            $("#chownForm  select[name='group']").change(function(){
+                if ($(this).val() == "__other__"){
+                    $("input[name='group_other']").show();
+                }
+                else {
+                    $("input[name='group_other']").hide();
                 }
             });
-        }
 
-        $(document).ready(function(){
+            $("#chownForm").submit(function(){
+                if ($("#chownForm select[name='user']").val() == null){
+                    $("#chownRequired").find(".label").text("${_('Sorry, user is required.')}");
+                    $("#chownRequired").show();
+                    return false;
+                }
+                else if ($("#chownForm select[name='group']").val() == null){
+                    $("#chownRequired").find(".label").text("${_('Sorry, group is required.')}");
+                    $("#chownRequired").show();
+                    return false;
+                }
+                else {
+                    if ($("#chownForm select[name='group']").val() == "__other__" && $("input[name='group_other']").val() == ""){
+                        $("#chownRequired").find(".label").text("${_('Sorry, you need to specify another group.')}");
+                        $("#chownForm input[name='group_other']").addClass("fieldError");
+                        $("#chownRequired").show();
+                        return false;
+                    }
+                    if ($("#chownForm select[name='user']").val() == "__other__" && $("input[name='user_other']").val() == ""){
+                        $("#chownRequired").find(".label").text("${_('Sorry, you need to specify another user.')}");
+                        $("#chownForm input[name='user_other']").addClass("fieldError");
+                        $("#chownRequired").show();
+                        return false;
+                    }
+                    return true;
+                }
+            });
 
             $("#cancelDeleteBtn").click(function(){
                 $("#deleteModal").modal("hide");
@@ -321,9 +471,9 @@ from django.utils.translation import ugettext as _
             });
 
             $("#moveForm").live("submit", function(){
-                if ($.trim($("#moveForm").find("input[name='dest_path']").val()) == ""){
+                if ($.trim($("#moveForm").find("input.pathChooser").val()) == ""){
                     $("#moveNameRequiredAlert").show();
-                    $("#moveForm").find("input[name='dest_path']").addClass("fieldError");
+                    $("#moveForm").find("input[name='*dest_path']").addClass("fieldError");
                     return false;
                 }
                 return true;
@@ -675,20 +825,64 @@ from django.utils.translation import ugettext as _
             };
 
             self.move = function () {
-                openMoveModal(self.selectedFile().path, self.selectedFile().mode, "${url('filebrowser.views.view', path=urlencode('/'))}"+ "." + self.currentPath());
+                var paths = [];
+                $(self.selectedFiles()).each(function(index, file) {
+                    paths.push(file.path);
+                });
+                hiddenFields($("#moveForm"), "src_path", paths);
+                $("#moveForm").attr("action", "/filebrowser/move?next=${url('filebrowser.views.view', path=urlencode('/'))}" + "." + self.currentPath());
+                $("#moveModal").modal({
+                    keyboard:true,
+                    show:true
+                });
             };
 
             self.changeOwner = function () {
-                openChownWindow(self.selectedFile().path, self.selectedFile().stats.user, self.selectedFile().stats.group, "${url('filebrowser.views.view', path=urlencode('/'))}"+ "." + self.currentPath());
+                var paths = [];
+                $(self.selectedFiles()).each(function(index, file) {
+                    paths.push(file.path);
+                });
+                hiddenFields($("#chownForm"), 'path', paths);
+                $("#chownForm").attr("action", "/filebrowser/chown?next=${url('filebrowser.views.view', path=urlencode('/'))}" + "." + self.currentPath());
+                $("#changeOwnerModal").modal({
+                    keyboard:true,
+                    show:true
+                });
+
             };
 
             self.changePermissions = function () {
-                openChmodWindow(self.selectedFile().path, self.selectedFile().mode, "${url('filebrowser.views.view', path=urlencode('/'))}"+ "." + self.currentPath());
+                var paths = [];
+                $(self.selectedFiles()).each(function(index, file) {
+                    paths.push(file.path);
+                });
+                hiddenFields($("#chmodForm"), 'path', paths);
+                $("#chmodForm").attr("action", "/filebrowser/chmod?next=${url('filebrowser.views.view', path=urlencode('/'))}" + "." + self.currentPath());
+                $("#changePermissionModal").modal({
+                    keyboard:true,
+                    show:true
+                });
+
+                // Initial values for form
+                permissions = ["sticky", "user_read", "user_write", "user_execute", "group_read", "group_write", "group_execute", "other_read", "other_write", "other_execute"].reverse();
+                var mode = octal(self.selectedFile().mode) & 01777;
+                for (var i = 0; i < permissions.length; i++) {
+                    if (mode & 1) {
+                        $("#chmodForm input[name=" + permissions[i] + "]").attr("checked", true);
+                    } else {
+                        $("#chmodForm input[name=" + permissions[i] + "]").attr("checked", false);
+                    }
+                    mode >>>= 1;
+                }
             };
 
             self.deleteSelected = function () {
-                $("#fileToDeleteInput").attr("value", self.selectedFile().path);
-                $("#deleteForm").attr("action", "/filebrowser/" + (self.selectedFile().type == "dir" ? "rmtree" : "remove") + "?next=${url('filebrowser.views.view', path=urlencode('/'))}" + "." + self.currentPath() + "&path=" + self.selectedFile().path);
+                var paths = [];
+                $(self.selectedFiles()).each(function(index, file) {
+                    paths.push(file.path);
+                });
+                hiddenFields($("#deleteForm"), 'path', paths);
+                $("#deleteForm").attr("action", "/filebrowser/rmtree" + "?next=${url('filebrowser.views.view', path=urlencode('/'))}" + "." + self.currentPath());
                 $("#deleteModal").modal({
                     keyboard:true,
                     show:true
@@ -800,6 +994,38 @@ from django.utils.translation import ugettext as _
                     });
                 };
             })();
+
+            // Place all values into hidden fields under parent element.
+            // Looks for managed hidden fields and handles sizing appropriately.
+            var hiddenFields = function(parentEl, name, values) {
+                parentEl = $(parentEl);
+                var fields = parentEl.find("input.hidden-field");
+
+                // Create or delete hidden fields according to needs.
+                var resize = values.length - fields.length;
+                while(resize > 0) {
+                    resize--;
+                    var field = $("<input type='hidden' />");
+                    field.attr("name", name);
+                    field.attr("class", "hidden-field")
+                    parentEl.append(field);
+                }
+                while (resize < 0) {
+                    resize++;
+                    var field = fields[fields.length - resize - 1]
+                    parentEl.remove(field);
+                }
+
+                // Set values
+                fields = parentEl.find("input.hidden-field");
+                $(values).each(function(index, value) {
+                    $(fields[index]).val(value);
+                });
+            }
+
+            var octal = function(strInt) {
+                return parseInt("0" + strInt);
+            }
         };
 
         var viewModel = new FileBrowserModel([], null, [], "/");

+ 0 - 64
apps/filebrowser/src/filebrowser/templates/move.mako

@@ -1,64 +0,0 @@
-## Licensed to Cloudera, Inc. under one
-## or more contributor license agreements.  See the NOTICE file
-## distributed with this work for additional information
-## regarding copyright ownership.  Cloudera, Inc. licenses this file
-## to you under the Apache License, Version 2.0 (the
-## "License"); you may not use this file except in compliance
-## with the License.  You may obtain a copy of the License at
-##
-##     http://www.apache.org/licenses/LICENSE-2.0
-##
-## Unless required by applicable law or agreed to in writing, software
-## distributed under the License is distributed on an "AS IS" BASIS,
-## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-## See the License for the specific language governing permissions and
-## limitations under the License.
-<%!
-from django.utils.translation import ugettext as _
-%>
-<%namespace name="edit" file="editor_components.mako" />
-
-<form id="moveForm" action="/filebrowser/move?next=${next|u}" method="POST" enctype="multipart/form-data" class="form-inline form-padding-fix">
-    <div class="modal-header">
-        <a href="#" class="close" data-dismiss="modal">&times;</a>
-        <h3>${_('Move:')} ${src_path}</h3>
-    </div>
-    <div class="modal-body">
-        <div style="padding-left: 15px;">
-        ${edit.render_field(form["src_path"], hidden=True)}
-        <label for="id_dest_path">${_('Destination')}</label>${edit.render_field(form["dest_path"], notitle=True, nolabel=True, klass="input-xlarge pathChooser", file_chooser=True)}
-        </div>
-        <br/>
-        <div id="fileChooserModal" class="smallModal well hide">
-            <a href="#" class="close" data-dismiss="modal">&times;</a>
-        </div>
-    </div>
-    <div class="modal-footer">
-        <div id="moveNameRequiredAlert" class="hide" style="position: absolute; left: 10;">
-            <span class="label label-important">${_('Sorry, name is required.')}</span>
-        </div>
-        <a class="btn" onclick="$('#moveModal').modal('hide');">${_('Cancel')}</a>
-        <input class="btn primary" type="submit" value="${_('Submit')}"/>
-    </div>
-</form>
-
-<script type="text/javascript" charset="utf-8">
-    $(".fileChooserBtn").click(function(e){
-        e.preventDefault();
-        var _destination = $(this).attr("data-filechooser-destination");
-        $("#fileChooserModal").jHueFileChooser({
-            initialPath: $("input[name='"+_destination+"']").val(),
-            onFolderChange: function(folderPath){
-                $("input[name='"+_destination+"']").val(folderPath);
-            },
-            onFolderChoose: function(folderPath){
-                $("input[name='"+_destination+"']").val(folderPath);
-                $("#fileChooserModal").slideUp();
-            },
-            selectFolder: true,
-            createFolder: true,
-            uploadFile: false
-        });
-        $("#fileChooserModal").slideDown();
-    });
-</script>

+ 1 - 3
apps/filebrowser/src/filebrowser/urls.py

@@ -40,9 +40,7 @@ urlpatterns = patterns('filebrowser.views',
   url(r'rename', 'rename', name='rename'),
   url(r'mkdir', 'mkdir', name='mkdir'),
   url(r'touch', 'touch', name='touch'),
-  url(r'^move', 'move', name='move'),
-  url(r'remove', 'remove', name='remove'),
-  url(r'rmdir', 'rmdir', name='rmdir'),
+  url(r'move', 'move', name='move'),
   url(r'rmtree', 'rmtree', name='rmtree'),
   url(r'chmod', 'chmod', name='chmod'),
   url(r'chown', 'chown', name='chown'),

+ 167 - 44
apps/filebrowser/src/filebrowser/views.py

@@ -34,10 +34,13 @@ try:
 except ImportError:
   import simplejson as json
 
+from django import forms
 from django.contrib import messages
-from django.core import urlresolvers
+from django.contrib.auth.models import User, Group
+from django.core import urlresolvers, serializers
 from django.template.defaultfilters import stringformat, filesizeformat
 from django.http import Http404, HttpResponse, HttpResponseNotModified
+from django.views.decorators.http import require_http_methods
 from django.views.static import was_modified_since
 from django.utils.functional import curry
 from django.utils.http import http_date, urlquote
@@ -54,7 +57,8 @@ from filebrowser.lib.archives import archive_factory
 from filebrowser.lib.rwx import filetype, rwx
 from filebrowser.lib import xxd
 from filebrowser.forms import RenameForm, UploadFileForm, UploadArchiveForm, MkDirForm,\
-    RmDirForm, RmTreeForm, RemoveForm, ChmodForm, ChownForm, EditorForm, TouchForm
+    RmDirForm, RmTreeForm, RemoveForm, ChmodForm, ChownForm, EditorForm, TouchForm,\
+    RenameFormSet, RmTreeFormSet, ChmodFormSet,ChownFormSet
 from hadoop.fs.hadoopfs import Hdfs
 from hadoop.fs.exceptions import WebHdfsException
 
@@ -337,6 +341,10 @@ def listdir(request, path, chooser):
         'current_request_path': request.path,
         'home_directory': request.fs.isdir(home_dir_path) and home_dir_path or None,
         'cwd_set': True,
+        'is_superuser': request.user.username == request.fs.superuser,
+        'groups': request.user.username == request.fs.superuser and [str(x) for x in Group.objects.values_list('name', flat=True)] or [],
+        'users': request.user.username == request.fs.superuser and [str(x) for x in User.objects.values_list('username', flat=True)] or [],
+        'superuser': request.fs.superuser,
         'show_upload': (request.REQUEST.get('show_upload') == 'false' and (False,) or (True,))[0]
     }
 
@@ -444,7 +452,11 @@ def listdir_paged(request, path):
         'cwd_set': True,
         'file_filter': 'any',
         'current_dir_path': path,
-        'is_fs_superuser': request.user.username == request.fs.superuser
+        'is_fs_superuser': request.user.username == request.fs.superuser,
+        'is_superuser': request.user.username == request.fs.superuser,
+        'groups': request.user.username == request.fs.superuser and [str(x) for x in Group.objects.values_list('name', flat=True)] or [],
+        'users': request.user.username == request.fs.superuser and [str(x) for x in User.objects.values_list('username', flat=True)] or [],
+        'superuser': request.fs.superuser
     }
     return render('listdir.mako', request, data)
 
@@ -737,7 +749,94 @@ def _calculate_navigation(offset, length, size):
     return first, prev, next, last
 
 
-def generic_op(form_class, request, op, parameter_names, piggyback=None, template="fileop.mako", extra_params=None):
+def default_initial_value_extractor(request, parameter_names):
+    initial_values = {}
+    for p in parameter_names:
+        val = request.GET.get(p)
+        if val:
+            initial_values[p] = val
+    return initial_values
+
+
+def formset_initial_value_extractor(request, parameter_names):
+    """
+    Builds a list of data that formsets should use by extending some fields to every object,
+    whilst others are assumed to be received in order.
+    Formsets should receive data that looks like this: [{'param1': <something>,...}, ...].
+    The formsets should then handle construction on their own.
+    """
+    def _intial_value_extractor(request):
+        if not submitted:
+            return []
+        # Build data with list of in order parameters receive in POST data
+        # Size can be inferred from largest list returned in POST data
+        data = []
+        for param in submitted:
+            i = 0
+            for val in request.POST.getlist(param):
+                if len(data) == i:
+                    data.append({})
+                data[i][param] = val
+                i += 1
+        # Extend every data object with recurring params
+        for kwargs in data:
+            for recurrent in recurring:
+                kwargs[recurrent] = request.POST.get(recurrent)
+        initial_data = data
+        return {'initial': initial_data}
+
+    return _intial_value_extractor
+
+
+def default_arg_extractor(request, form, parameter_names):
+    return [form.cleaned_data[p] for p in parameter_names]
+
+
+def formset_arg_extractor(request, formset, parameter_names):
+    data = []
+    for form in formset.forms:
+        data_dict = {}
+        for p in parameter_names:
+            data_dict[p] = form.cleaned_data[p]
+        data.append(data_dict)
+    return data
+
+
+def default_data_extractor(request):
+    return {'data': request.POST.copy()}
+
+
+def formset_data_extractor(recurring=[], submitted=[]):
+    """
+    Builds a list of data that formsets should use by extending some fields to every object,
+    whilst others are assumed to be received in order.
+    Formsets should receive data that looks like this: [{'param1': <something>,...}, ...].
+    The formsets should then handle construction on their own.
+    """
+    def _data_extractor(request):
+        if not submitted:
+            return []
+        # Build data with list of in order parameters receive in POST data
+        # Size can be inferred from largest list returned in POST data
+        data = []
+        for param in submitted:
+            i = 0
+            for val in request.POST.getlist(param):
+                if len(data) == i:
+                    data.append({})
+                data[i][param] = val
+                i += 1
+        # Extend every data object with recurring params
+        for kwargs in data:
+            for recurrent in recurring:
+                kwargs[recurrent] = request.POST.get(recurrent)
+        initial = list(data)
+        return {'initial': initial, 'data': data}
+
+    return _data_extractor
+
+
+def generic_op(form_class, request, op, parameter_names, piggyback=None, template="fileop.mako", data_extractor=default_data_extractor, arg_extractor=default_arg_extractor, initial_value_extractor=default_initial_value_extractor, extra_params=None):
     """
     Generic implementation for several operations.
 
@@ -746,12 +845,13 @@ def generic_op(form_class, request, op, parameter_names, piggyback=None, templat
     @param op callable with the filesystem operation
     @param parameter_names list of form parameters that are extracted and then passed to op
     @param piggyback list of form parameters whose file stats to look up after the operation
+    @param data_extractor function that extracts POST data to be used by op
+    @param arg_extractor function that extracts args from a given form or formset
+    @param initial_value_extractor function that extracts the initial values of a form or formset
     @param extra_params dictionary of extra parameters to send to the template for rendering
     """
     # Use next for non-ajax requests, when available.
-    next = request.GET.get("next")
-    if next is None:
-        next = request.POST.get("next")
+    next = request.GET.get("next", request.POST.get("next", None))
 
     ret = dict({
         'next': next
@@ -766,10 +866,10 @@ def generic_op(form_class, request, op, parameter_names, piggyback=None, templat
             ret[p] = val
 
     if request.method == 'POST':
-        form = form_class(request.POST)
+        form = form_class(**data_extractor(request))
         ret['form'] = form
         if form.is_valid():
-            args = [form.cleaned_data[p] for p in parameter_names]
+            args = arg_extractor(request, form, parameter_names)
             try:
                 op(*args)
             except (IOError, WebHdfsException), e:
@@ -794,23 +894,16 @@ def generic_op(form_class, request, op, parameter_names, piggyback=None, templat
                 logger.exception("Exception while processing piggyback data")
                 ret["result_error"] = True
 
+            ret['user'] = request.user
             return render(template, request, ret)
     else:
-        # Initial parameters may be specified with get
-        initial_values = {}
-        for p in parameter_names:
-            val = request.GET.get(p)
-            if val:
-                initial_values[p] = val
-        form = form_class(initial=initial_values)
-        ret['form'] = form
+        # Initial parameters may be specified with get with the default extractor
+        initial_values = initial_value_extractor(request, parameter_names)
+        formset = form_class(initial=initial_values)
+        ret['form'] = formset
     return render(template, request, ret)
 
 
-def move(request):
-    return generic_op(RenameForm, request, request.fs.rename, ["src_path", "dest_path"], None, template="move.mako")
-
-
 def rename(request):
     def smart_rename(src_path, dest_path):
         """If dest_path doesn't have a directory specified, use same dir."""
@@ -832,7 +925,6 @@ def mkdir(request):
 
     return generic_op(MkDirForm, request, smart_mkdir, ["path", "name"], "path")
 
-
 def touch(request):
     def smart_touch(path, name):
         # Make sure only the filename is specified.
@@ -843,40 +935,71 @@ def touch(request):
 
     return generic_op(TouchForm, request, smart_touch, ["path", "name"], "path")
 
-
-def remove(request):
-    return generic_op(RemoveForm, request, request.fs.remove, ["path"], None)
-
-
-def rmdir(request):
-    return generic_op(RmDirForm, request, request.fs.rmdir, ["path"], None)
-
-
+@require_http_methods(["POST"])
 def rmtree(request):
-    return generic_op(RmTreeForm, request, request.fs.rmtree, ["path"], None)
-
-
+    recurring = []
+    params = ["path"]
+    def bulk_rmtree(*args, **kwargs):
+        for arg in args:
+            request.fs.rmtree(arg['path'])
+    return generic_op(RmTreeFormSet, request, bulk_rmtree, ["path"], None,
+                      data_extractor=formset_data_extractor(recurring, params),
+                      arg_extractor=formset_arg_extractor,
+                      initial_value_extractor=formset_initial_value_extractor)
+
+
+@require_http_methods(["POST"])
+def move(request):
+    recurring = ['dest_path']
+    params = ['src_path']
+    def bulk_move(*args, **kwargs):
+        for arg in args:
+            request.fs.rename(arg['src_path'], arg['dest_path'])
+    return generic_op(RenameFormSet, request, bulk_move, ["src_path", "dest_path"], None,
+                      data_extractor=formset_data_extractor(recurring, params),
+                      arg_extractor=formset_arg_extractor,
+                      initial_value_extractor=formset_initial_value_extractor)
+
+
+@require_http_methods(["POST"])
 def chmod(request):
+    recurring = ["sticky", "user_read", "user_write", "user_execute", "group_read", "group_write", "group_execute", "other_read", "other_write", "other_execute"]
+    params = ["path"]
+    def bulk_chmod(*args, **kwargs):
+        op = curry(request.fs.chmod, recursive=request.POST.get('recursive', False))
+        for arg in args:
+            op(arg['path'], arg['mode'])
     # mode here is abused: on input, it's a string, but when retrieved,
     # it's an int.
-    op = curry(request.fs.chmod, recursive=request.POST.get('recursive', False))
-    return generic_op(ChmodForm, request, op, ["path", "mode"], "path", template="chmod.mako")
+    return generic_op(ChmodFormSet, request, bulk_chmod, ['path', 'mode'], "path",
+                      data_extractor=formset_data_extractor(recurring, params),
+                      arg_extractor=formset_arg_extractor,
+                      initial_value_extractor=formset_initial_value_extractor)
 
 
+@require_http_methods(["POST"])
 def chown(request):
     # This is a bit clever: generic_op takes an argument (here, args), indicating
     # which POST parameters to pick out and pass to the given function.
     # We update that mapping based on whether or not the user selected "other".
-    args = ["path", "user", "group"]
+    param_names = ["path", "user", "group"]
     if request.POST.get("user") == "__other__":
-        args[1] = "user_other"
+        param_names[1] = "user_other"
     if request.POST.get("group") == "__other__":
-        args[2] = "group_other"
-
-    op = curry(request.fs.chown, recursive=request.POST.get('recursive', False))
-    return generic_op(ChownForm, request, op, args, "path", template="chown.mako",
-                      extra_params=dict(current_user=request.user,
-                      superuser=request.fs.superuser))
+        param_names[2] = "group_other"
+
+    recurring = ["user", "group", "user_other", "group_other"]
+    params = ["path"]
+    def bulk_chown(*args, **kwargs):
+        op = curry(request.fs.chown, recursive=request.POST.get('recursive', False))
+        for arg in args:
+            varg = [arg[param] for param in param_names]
+            op(*varg)
+
+    return generic_op(ChownFormSet, request, bulk_chown, param_names, "path",
+                      data_extractor=formset_data_extractor(recurring, params),
+                      arg_extractor=formset_arg_extractor,
+                      initial_value_extractor=formset_initial_value_extractor)
 
 
 def upload_flash(request):

+ 139 - 31
apps/filebrowser/src/filebrowser/views_test.py

@@ -43,6 +43,98 @@ from lib.rwx import expand_mode
 LOG = logging.getLogger(__name__)
 
 
+@attr('requires_hadoop')
+def test_remove():
+  cluster = pseudo_hdfs4.shared_cluster()
+
+  try:
+    c = make_logged_in_client(cluster.superuser)
+    cluster.fs.setuser(cluster.superuser)
+
+    prefix = '/test-delete'
+    PATH_1 = '/%s/1' % prefix
+    PATH_2 = '/%s/2' % prefix
+    PATH_3 = '/%s/3' % prefix
+    cluster.fs.mkdir(prefix)
+    cluster.fs.mkdir(PATH_1)
+    cluster.fs.mkdir(PATH_2)
+    cluster.fs.mkdir(PATH_3)
+
+    assert_true(cluster.fs.exists(PATH_1))
+    assert_true(cluster.fs.exists(PATH_2))
+    assert_true(cluster.fs.exists(PATH_3))
+
+    c.post('/filebrowser/rmtree', dict(path=[PATH_1]))
+    assert_false(cluster.fs.exists(PATH_1))
+    assert_true(cluster.fs.exists(PATH_2))
+    assert_true(cluster.fs.exists(PATH_3))
+
+    c.post('/filebrowser/rmtree', dict(path=[PATH_2, PATH_3]))
+    assert_false(cluster.fs.exists(PATH_1))
+    assert_false(cluster.fs.exists(PATH_2))
+    assert_false(cluster.fs.exists(PATH_3))
+
+  finally:
+    try:
+      cluster.fs.rmtree(prefix)     # Clean up
+    except:
+      pass      # Don't let cleanup errors mask earlier failures
+
+
+@attr('requires_hadoop')
+def test_move():
+  cluster = pseudo_hdfs4.shared_cluster()
+
+  try:
+    c = make_logged_in_client(cluster.superuser)
+    cluster.fs.setuser(cluster.superuser)
+
+    prefix = '/test-move'
+    PATH_1 = '%s/1' % prefix
+    PATH_2 = '%s/2' % prefix
+    SUB_PATH1_1 = '%s/1' % PATH_1
+    SUB_PATH1_2 = '%s/2' % PATH_1
+    SUB_PATH1_3 = '%s/3' % PATH_1
+    SUB_PATH2_1 = '%s/1' % PATH_2
+    SUB_PATH2_2 = '%s/2' % PATH_2
+    SUB_PATH2_3 = '%s/3' % PATH_2
+    cluster.fs.mkdir(prefix)
+    cluster.fs.mkdir(PATH_1)
+    cluster.fs.mkdir(PATH_2)
+    cluster.fs.mkdir(SUB_PATH1_1)
+    cluster.fs.mkdir(SUB_PATH1_2)
+    cluster.fs.mkdir(SUB_PATH1_3)
+
+    assert_true(cluster.fs.exists(SUB_PATH1_1))
+    assert_true(cluster.fs.exists(SUB_PATH1_2))
+    assert_true(cluster.fs.exists(SUB_PATH1_3))
+    assert_false(cluster.fs.exists(SUB_PATH2_1))
+    assert_false(cluster.fs.exists(SUB_PATH2_2))
+    assert_false(cluster.fs.exists(SUB_PATH2_3))
+
+    c.post('/filebrowser/move', dict(src_path=[SUB_PATH1_1], dest_path=PATH_2))
+    assert_false(cluster.fs.exists(SUB_PATH1_1))
+    assert_true(cluster.fs.exists(SUB_PATH1_2))
+    assert_true(cluster.fs.exists(SUB_PATH1_3))
+    assert_true(cluster.fs.exists(SUB_PATH2_1))
+    assert_false(cluster.fs.exists(SUB_PATH2_2))
+    assert_false(cluster.fs.exists(SUB_PATH2_3))
+
+    c.post('/filebrowser/move', dict(src_path=[SUB_PATH1_2, SUB_PATH1_3], dest_path=PATH_2))
+    assert_false(cluster.fs.exists(SUB_PATH1_1))
+    assert_false(cluster.fs.exists(SUB_PATH1_2))
+    assert_false(cluster.fs.exists(SUB_PATH1_3))
+    assert_true(cluster.fs.exists(SUB_PATH2_1))
+    assert_true(cluster.fs.exists(SUB_PATH2_2))
+    assert_true(cluster.fs.exists(SUB_PATH2_3))
+
+  finally:
+    try:
+      cluster.fs.rmtree(prefix)     # Clean up
+    except:
+      pass      # Don't let cleanup errors mask earlier failures
+
+
 @attr('requires_hadoop')
 def test_mkdir_singledir():
   cluster = pseudo_hdfs4.shared_cluster()
@@ -131,7 +223,7 @@ def test_chmod():
 
     # Setup post data
     permissions_dict = dict( zip(permissions, [True]*len(permissions)) )
-    kwargs = {'path': PATH}
+    kwargs = {'path': [PATH]}
     kwargs.update(permissions_dict)
 
     # Set 1777, then check permissions of dirs
@@ -144,9 +236,23 @@ def test_chmod():
     response = c.post("/filebrowser/chmod", kwargs)
     assert_equal(041777, int(cluster.fs.stats(SUBPATH)["mode"]))
 
+    # Test bulk chmod
+    PATH_2 = u"/test-chmod2"
+    PATH_3 = u"/test-chown3"
+    cluster.fs.mkdir(PATH_2)
+    cluster.fs.mkdir(PATH_3)
+    kwargs['path'] = [PATH_2, PATH_3]
+    assert_not_equal(041777, int(cluster.fs.stats(PATH_2)["mode"]))
+    assert_not_equal(041777, int(cluster.fs.stats(PATH_3)["mode"]))
+    c.post("/filebrowser/chmod", kwargs)
+    assert_equal(041777, int(cluster.fs.stats(PATH_2)["mode"]))
+    assert_equal(041777, int(cluster.fs.stats(PATH_3)["mode"]))
+
   finally:
     try:
       cluster.fs.rmtree(PATH)     # Clean up
+      cluster.fs.rmtree(PATH_2)     # Clean up
+      cluster.fs.rmtree(PATH_3)     # Clean up
     except:
       pass      # Don't let cleanup errors mask earlier failures
 
@@ -173,7 +279,7 @@ def test_chmod_sticky():
         'sticky') # Order matters!
     permissions_dict = dict(filter(lambda x: x[1], zip(permissions, mode)))
     permissions_dict['sticky'] = True
-    kwargs = {'path': PATH}
+    kwargs = {'path': [PATH]}
     kwargs.update(permissions_dict)
 
     # Set sticky bit, then check sticky bit is on in hdfs
@@ -204,50 +310,52 @@ def test_chown():
 
   PATH = u"/test-chown-en-Español"
   cluster.fs.mkdir(PATH)
-  c.post("/filebrowser/chown", dict(path=PATH, user="x", group="y"))
+  c.post("/filebrowser/chown", dict(path=[PATH], user="x", group="y"))
   assert_equal("x", cluster.fs.stats(PATH)["user"])
   assert_equal("y", cluster.fs.stats(PATH)["group"])
-  c.post("/filebrowser/chown", dict(path=PATH, user="__other__", user_other="z", group="y"))
+  c.post("/filebrowser/chown", dict(path=[PATH], user="__other__", user_other="z", group="y"))
   assert_equal("z", cluster.fs.stats(PATH)["user"])
 
   # Now check recursive
   SUBPATH = PATH + '/test'
   cluster.fs.mkdir(SUBPATH)
-  c.post("/filebrowser/chown", dict(path=PATH, user="x", group="y", recursive=True))
+  c.post("/filebrowser/chown", dict(path=[PATH], user="x", group="y", recursive=True))
   assert_equal("x", cluster.fs.stats(SUBPATH)["user"])
   assert_equal("y", cluster.fs.stats(SUBPATH)["group"])
-  c.post("/filebrowser/chown", dict(path=PATH, user="__other__", user_other="z", group="y", recursive=True))
+  c.post("/filebrowser/chown", dict(path=[PATH], user="__other__", user_other="z", group="y", recursive=True))
   assert_equal("z", cluster.fs.stats(SUBPATH)["user"])
 
-  # Make sure that the regular user chown form doesn't have useless fields,
-  # and that the superuser's form has all the fields it could dream of.
-  PATH = '/filebrowser/chown-regular-user'
-  cluster.fs.mkdir(PATH)
-  cluster.fs.chown(PATH, 'chown_test', 'chown_test')
-  response = c.get('/filebrowser/chown', dict(path=PATH, user='chown_test', group='chown_test'))
-  assert_true('<option value="__other__"' in response.content)
-  c = make_logged_in_client('chown_test')
-  response = c.get('/filebrowser/chown', dict(path=PATH, user='chown_test', group='chown_test'))
-  assert_false('<option value="__other__"' in response.content)
+  # Test bulk chown
+  PATH_2 = u"/test-chown-en-Español2"
+  PATH_3 = u"/test-chown-en-Español2"
+  cluster.fs.mkdir(PATH_2)
+  cluster.fs.mkdir(PATH_3)
+  c.post("/filebrowser/chown", dict(path=[PATH_2, PATH_3], user="x", group="y", recursive=True))
+  assert_equal("x", cluster.fs.stats(PATH_2)["user"])
+  assert_equal("y", cluster.fs.stats(PATH_2)["group"])
+  assert_equal("x", cluster.fs.stats(PATH_3)["user"])
+  assert_equal("y", cluster.fs.stats(PATH_3)["group"])
+
 
 @attr('requires_hadoop')
 def test_rename():
-    cluster = pseudo_hdfs4.shared_cluster()
+  cluster = pseudo_hdfs4.shared_cluster()
 
-    c = make_logged_in_client(cluster.superuser)
-    cluster.fs.setuser(cluster.superuser)
+  c = make_logged_in_client(cluster.superuser)
+  cluster.fs.setuser(cluster.superuser)
+
+  PREFIX = u"/test-rename/"
+  NAME = u"test-rename-before"
+  NEW_NAME = u"test-rename-after"
+  cluster.fs.mkdir(PREFIX + NAME)
+  op = "rename"
+  # test for full path rename
+  c.post("/filebrowser/rename", dict(src_path=PREFIX + NAME, dest_path=PREFIX + NEW_NAME))
+  assert_true(cluster.fs.exists(PREFIX + NEW_NAME))
+  # test for smart rename
+  c.post("/filebrowser/rename", dict(src_path=PREFIX + NAME, dest_path=NEW_NAME))
+  assert_true(cluster.fs.exists(PREFIX + NEW_NAME))
 
-    PREFIX = u"/test-rename/"
-    NAME = u"test-rename-before"
-    NEW_NAME = u"test-rename-after"
-    cluster.fs.mkdir(PREFIX + NAME)
-    op = "rename"
-    # test for full path rename
-    c.post("/filebrowser/rename", dict(src_path=PREFIX + NAME, dest_path=PREFIX + NEW_NAME))
-    assert_true(cluster.fs.exists(PREFIX + NEW_NAME))
-    # test for smart rename
-    c.post("/filebrowser/rename", dict(src_path=PREFIX + NAME, dest_path=NEW_NAME))
-    assert_true(cluster.fs.exists(PREFIX + NEW_NAME))
 
 @attr('requires_hadoop')
 def test_listdir():
@@ -796,4 +904,4 @@ def test_upload_archive():
     try:
       cluster.fs.remove(HDFS_DEST_DIR)
     except Exception, ex:
-      pass
+      pass