Jelajahi Sumber

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 tahun lalu
induk
melakukan
c832574

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

@@ -17,6 +17,7 @@
 
 
 from django import forms
 from django import forms
 from django.forms import FileField, CharField, BooleanField, Textarea
 from django.forms import FileField, CharField, BooleanField, Textarea
+from django.forms.formsets import formset_factory, BaseFormSet, ManagementForm
 
 
 from desktop.lib import i18n
 from desktop.lib import i18n
 from filebrowser.lib import rwx
 from filebrowser.lib import rwx
@@ -28,6 +29,26 @@ from django.utils.translation import ugettext_lazy as _
 import logging
 import logging
 logger = logging.getLogger(__name__)
 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):
 class PathField(CharField):
   def __init__(self, label, help_text=None, **kwargs):
   def __init__(self, label, help_text=None, **kwargs):
     kwargs.setdefault('required', True)
     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."))
   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:"))
   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):
 class UploadFileForm(forms.Form):
   op = "upload"
   op = "upload"
   # The "hdfs" prefix in "hdfs_file" triggers the HDFSfileUploadHandler
   # The "hdfs" prefix in "hdfs_file" triggers the HDFSfileUploadHandler
@@ -76,6 +102,11 @@ class RmTreeForm(forms.Form):
   op = "rmtree"
   op = "rmtree"
   path = PathField(label=_("Directory to remove (recursively)"))
   path = PathField(label=_("Directory to remove (recursively)"))
 
 
+class BaseRmTreeFormset(FormSet):
+  op = "rmtree"
+
+RmTreeFormSet = formset_factory(RmTreeForm, formset=BaseRmTreeFormset, extra=0)
+
 class MkDirForm(forms.Form):
 class MkDirForm(forms.Form):
   op = "mkdir"
   op = "mkdir"
   path = PathField(label=_("Path in which to create the directory"))
   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_groups = [ group.name for group in Group.objects.all() ]
     self.all_users = [ user.username for user in User.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):
 class ChmodForm(forms.Form):
   op = "chmod"
   op = "chmod"
   path = PathField(label=_("Path to change permissions"))
   path = PathField(label=_("Path to change permissions"))
@@ -126,7 +162,7 @@ class ChmodForm(forms.Form):
       "other_read", "other_write", "other_execute",
       "other_read", "other_write", "other_execute",
       "sticky")
       "sticky")
 
 
-  def __init__(self, initial):
+  def __init__(self, initial, *args, **kwargs):
     logging.info(dir(self))
     logging.info(dir(self))
     logging.info(dir(type(self)))
     logging.info(dir(type(self)))
     # Convert from string representation.
     # Convert from string representation.
@@ -137,11 +173,15 @@ class ChmodForm(forms.Form):
       for name, b in zip(self.names, bools):
       for name, b in zip(self.names, bools):
         initial[name] = b
         initial[name] = b
     logging.debug(initial)
     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))
       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,
   hidden=False,
   notitle=False,
   notitle=False,
   tag='input',
   tag='input',
+  name=None,
   klass=None,
   klass=None,
   attrs=None,
   attrs=None,
   value=None,
   value=None,
@@ -82,18 +83,18 @@
         ${unicode(field) | n}
         ${unicode(field) | n}
       % else:
       % else:
         % if tag == 'textarea':
         % 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':
         % 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':
         % 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:
         % 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:
         % else:
           %if file_chooser:
           %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:
           %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
         % endif
       % endif
       % endif
@@ -107,3 +108,37 @@
       </div>
       </div>
     % endif
     % endif
 </%def>
 </%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.
 ## limitations under the License.
 <%!
 <%!
 import datetime
 import datetime
+from django import forms
 from django.template.defaultfilters import urlencode, escape, stringformat, date, filesizeformat, time
 from django.template.defaultfilters import urlencode, escape, stringformat, date, filesizeformat, time
 from desktop.views import commonheader, commonfooter
 from desktop.views import commonheader, commonfooter
 from django.utils.translation import ugettext as _
 from django.utils.translation import ugettext as _
@@ -30,7 +31,14 @@ ${commonheader(_('File Operation'), 'filebrowser', user)}
 <div class="well">
 <div class="well">
 <form action="" method="POST" enctype="multipart/form-data" class="form-stacked">
 <form action="" method="POST" enctype="multipart/form-data" class="form-stacked">
 <h1>${form.op}</h1>
 <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>
 <div>
 <input type="submit" value="${('Submit')}" class="btn primary" />
 <input type="submit" value="${('Submit')}" class="btn primary" />
 <a href="${urlencode(next)}" class="btn">${('Cancel')}</a>
 <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()">
         <%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="${_('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:
             %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
             %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>
 
 
         <%def name="creation()">
         <%def name="creation()">

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

@@ -17,11 +17,13 @@
 import datetime
 import datetime
 import md5
 import md5
 from django.template.defaultfilters import urlencode, stringformat, filesizeformat, date, time, escape
 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.encoding import smart_str
 from django.utils.translation import ugettext as _
 from django.utils.translation import ugettext as _
 %>
 %>
 
 
+<%namespace name="edit" file="editor_components.mako" />
+
 <%def name="list_table_chooser(files, path, current_request_path)">
 <%def name="list_table_chooser(files, path, current_request_path)">
   ${_table(files, path, current_request_path, 'chooser')}
   ${_table(files, path, current_request_path, 'chooser')}
 </%def>
 </%def>
@@ -127,13 +129,12 @@ from django.utils.translation import ugettext as _
             <h3>${_('Please Confirm')}</h3>
             <h3>${_('Please Confirm')}</h3>
         </div>
         </div>
         <div class="modal-body">
         <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>
         <div class="modal-footer">
         <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" />
                 <input type="submit" value="${_('Yes')}" class="btn primary" />
                 <a id="cancelDeleteBtn" class="btn">${_('No')}</a>
                 <a id="cancelDeleteBtn" class="btn">${_('No')}</a>
-                <input id="fileToDeleteInput" type="hidden" name="path" />
             </form>
             </form>
         </div>
         </div>
     </div>
     </div>
@@ -160,11 +161,130 @@ from django.utils.translation import ugettext as _
         </form>
         </form>
     </div>
     </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 -->
     <!-- upload file modal -->
     <div id="uploadFileModal" class="modal hide fade">
     <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(){
             $("#cancelDeleteBtn").click(function(){
                 $("#deleteModal").modal("hide");
                 $("#deleteModal").modal("hide");
@@ -321,9 +471,9 @@ from django.utils.translation import ugettext as _
             });
             });
 
 
             $("#moveForm").live("submit", function(){
             $("#moveForm").live("submit", function(){
-                if ($.trim($("#moveForm").find("input[name='dest_path']").val()) == ""){
+                if ($.trim($("#moveForm").find("input.pathChooser").val()) == ""){
                     $("#moveNameRequiredAlert").show();
                     $("#moveNameRequiredAlert").show();
-                    $("#moveForm").find("input[name='dest_path']").addClass("fieldError");
+                    $("#moveForm").find("input[name='*dest_path']").addClass("fieldError");
                     return false;
                     return false;
                 }
                 }
                 return true;
                 return true;
@@ -675,20 +825,64 @@ from django.utils.translation import ugettext as _
             };
             };
 
 
             self.move = function () {
             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 () {
             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 () {
             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 () {
             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({
                 $("#deleteModal").modal({
                     keyboard:true,
                     keyboard:true,
                     show: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, [], "/");
         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'rename', 'rename', name='rename'),
   url(r'mkdir', 'mkdir', name='mkdir'),
   url(r'mkdir', 'mkdir', name='mkdir'),
   url(r'touch', 'touch', name='touch'),
   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'rmtree', 'rmtree', name='rmtree'),
   url(r'chmod', 'chmod', name='chmod'),
   url(r'chmod', 'chmod', name='chmod'),
   url(r'chown', 'chown', name='chown'),
   url(r'chown', 'chown', name='chown'),

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

@@ -34,10 +34,13 @@ try:
 except ImportError:
 except ImportError:
   import simplejson as json
   import simplejson as json
 
 
+from django import forms
 from django.contrib import messages
 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.template.defaultfilters import stringformat, filesizeformat
 from django.http import Http404, HttpResponse, HttpResponseNotModified
 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.views.static import was_modified_since
 from django.utils.functional import curry
 from django.utils.functional import curry
 from django.utils.http import http_date, urlquote
 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.rwx import filetype, rwx
 from filebrowser.lib import xxd
 from filebrowser.lib import xxd
 from filebrowser.forms import RenameForm, UploadFileForm, UploadArchiveForm, MkDirForm,\
 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.hadoopfs import Hdfs
 from hadoop.fs.exceptions import WebHdfsException
 from hadoop.fs.exceptions import WebHdfsException
 
 
@@ -337,6 +341,10 @@ def listdir(request, path, chooser):
         'current_request_path': request.path,
         'current_request_path': request.path,
         'home_directory': request.fs.isdir(home_dir_path) and home_dir_path or None,
         'home_directory': request.fs.isdir(home_dir_path) and home_dir_path or None,
         'cwd_set': True,
         '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]
         '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,
         'cwd_set': True,
         'file_filter': 'any',
         'file_filter': 'any',
         'current_dir_path': path,
         '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)
     return render('listdir.mako', request, data)
 
 
@@ -737,7 +749,94 @@ def _calculate_navigation(offset, length, size):
     return first, prev, next, last
     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.
     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 op callable with the filesystem operation
     @param parameter_names list of form parameters that are extracted and then passed to op
     @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 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
     @param extra_params dictionary of extra parameters to send to the template for rendering
     """
     """
     # Use next for non-ajax requests, when available.
     # 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({
     ret = dict({
         'next': next
         'next': next
@@ -766,10 +866,10 @@ def generic_op(form_class, request, op, parameter_names, piggyback=None, templat
             ret[p] = val
             ret[p] = val
 
 
     if request.method == 'POST':
     if request.method == 'POST':
-        form = form_class(request.POST)
+        form = form_class(**data_extractor(request))
         ret['form'] = form
         ret['form'] = form
         if form.is_valid():
         if form.is_valid():
-            args = [form.cleaned_data[p] for p in parameter_names]
+            args = arg_extractor(request, form, parameter_names)
             try:
             try:
                 op(*args)
                 op(*args)
             except (IOError, WebHdfsException), e:
             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")
                 logger.exception("Exception while processing piggyback data")
                 ret["result_error"] = True
                 ret["result_error"] = True
 
 
+            ret['user'] = request.user
             return render(template, request, ret)
             return render(template, request, ret)
     else:
     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)
     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 rename(request):
     def smart_rename(src_path, dest_path):
     def smart_rename(src_path, dest_path):
         """If dest_path doesn't have a directory specified, use same dir."""
         """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")
     return generic_op(MkDirForm, request, smart_mkdir, ["path", "name"], "path")
 
 
-
 def touch(request):
 def touch(request):
     def smart_touch(path, name):
     def smart_touch(path, name):
         # Make sure only the filename is specified.
         # Make sure only the filename is specified.
@@ -843,40 +935,71 @@ def touch(request):
 
 
     return generic_op(TouchForm, request, smart_touch, ["path", "name"], "path")
     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):
 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):
 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,
     # mode here is abused: on input, it's a string, but when retrieved,
     # it's an int.
     # 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):
 def chown(request):
     # This is a bit clever: generic_op takes an argument (here, args), indicating
     # 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.
     # 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".
     # 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__":
     if request.POST.get("user") == "__other__":
-        args[1] = "user_other"
+        param_names[1] = "user_other"
     if request.POST.get("group") == "__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):
 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__)
 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')
 @attr('requires_hadoop')
 def test_mkdir_singledir():
 def test_mkdir_singledir():
   cluster = pseudo_hdfs4.shared_cluster()
   cluster = pseudo_hdfs4.shared_cluster()
@@ -131,7 +223,7 @@ def test_chmod():
 
 
     # Setup post data
     # Setup post data
     permissions_dict = dict( zip(permissions, [True]*len(permissions)) )
     permissions_dict = dict( zip(permissions, [True]*len(permissions)) )
-    kwargs = {'path': PATH}
+    kwargs = {'path': [PATH]}
     kwargs.update(permissions_dict)
     kwargs.update(permissions_dict)
 
 
     # Set 1777, then check permissions of dirs
     # Set 1777, then check permissions of dirs
@@ -144,9 +236,23 @@ def test_chmod():
     response = c.post("/filebrowser/chmod", kwargs)
     response = c.post("/filebrowser/chmod", kwargs)
     assert_equal(041777, int(cluster.fs.stats(SUBPATH)["mode"]))
     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:
   finally:
     try:
     try:
       cluster.fs.rmtree(PATH)     # Clean up
       cluster.fs.rmtree(PATH)     # Clean up
+      cluster.fs.rmtree(PATH_2)     # Clean up
+      cluster.fs.rmtree(PATH_3)     # Clean up
     except:
     except:
       pass      # Don't let cleanup errors mask earlier failures
       pass      # Don't let cleanup errors mask earlier failures
 
 
@@ -173,7 +279,7 @@ def test_chmod_sticky():
         'sticky') # Order matters!
         'sticky') # Order matters!
     permissions_dict = dict(filter(lambda x: x[1], zip(permissions, mode)))
     permissions_dict = dict(filter(lambda x: x[1], zip(permissions, mode)))
     permissions_dict['sticky'] = True
     permissions_dict['sticky'] = True
-    kwargs = {'path': PATH}
+    kwargs = {'path': [PATH]}
     kwargs.update(permissions_dict)
     kwargs.update(permissions_dict)
 
 
     # Set sticky bit, then check sticky bit is on in hdfs
     # 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"
   PATH = u"/test-chown-en-Español"
   cluster.fs.mkdir(PATH)
   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("x", cluster.fs.stats(PATH)["user"])
   assert_equal("y", cluster.fs.stats(PATH)["group"])
   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"])
   assert_equal("z", cluster.fs.stats(PATH)["user"])
 
 
   # Now check recursive
   # Now check recursive
   SUBPATH = PATH + '/test'
   SUBPATH = PATH + '/test'
   cluster.fs.mkdir(SUBPATH)
   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("x", cluster.fs.stats(SUBPATH)["user"])
   assert_equal("y", cluster.fs.stats(SUBPATH)["group"])
   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"])
   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')
 @attr('requires_hadoop')
 def test_rename():
 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')
 @attr('requires_hadoop')
 def test_listdir():
 def test_listdir():
@@ -796,4 +904,4 @@ def test_upload_archive():
     try:
     try:
       cluster.fs.remove(HDFS_DEST_DIR)
       cluster.fs.remove(HDFS_DEST_DIR)
     except Exception, ex:
     except Exception, ex:
-      pass
+      pass