Merge branch 'AIRAVATA-3126--Implement-compute-resource-reservation-addition'
diff --git a/.travis.yml b/.travis.yml
index 9b65b65..0029b02 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -7,6 +7,7 @@
   - cp django_airavata/settings_local.py.sample django_airavata/settings_local.py
   - python manage.py migrate
   - python manage.py check
+  - python manage.py test
   # For now ignore long line endings
   - flake8 --ignore=E501 .
   - ./lint_js.sh
diff --git a/airavata/model/application/io/ttypes.py b/airavata/model/application/io/ttypes.py
index 8c9017a..17f1760 100644
--- a/airavata/model/application/io/ttypes.py
+++ b/airavata/model/application/io/ttypes.py
@@ -74,6 +74,8 @@
     metaData:
       Any metadat. This is typically ignore by Airavata and is used by gateways for application configuration.
 
+    overrideFilename:
+      Rename input file to given value when staging to compute resource.
 
     Attributes:
      - name
@@ -89,6 +91,7 @@
      - dataStaged
      - storageResourceId
      - isReadOnly
+     - overrideFilename
     """
 
     thrift_spec = (
@@ -106,9 +109,10 @@
         (11, TType.BOOL, 'dataStaged', None, None, ),  # 11
         (12, TType.STRING, 'storageResourceId', 'UTF8', None, ),  # 12
         (13, TType.BOOL, 'isReadOnly', None, None, ),  # 13
+        (14, TType.STRING, 'overrideFilename', 'UTF8', None, ),  # 14
     )
 
-    def __init__(self, name=None, value=None, type=None, applicationArgument=None, standardInput=None, userFriendlyDescription=None, metaData=None, inputOrder=None, isRequired=None, requiredToAddedToCommandLine=None, dataStaged=None, storageResourceId=None, isReadOnly=None,):
+    def __init__(self, name=None, value=None, type=None, applicationArgument=None, standardInput=None, userFriendlyDescription=None, metaData=None, inputOrder=None, isRequired=None, requiredToAddedToCommandLine=None, dataStaged=None, storageResourceId=None, isReadOnly=None, overrideFilename=None,):
         self.name = name
         self.value = value
         self.type = type
@@ -122,6 +126,7 @@
         self.dataStaged = dataStaged
         self.storageResourceId = storageResourceId
         self.isReadOnly = isReadOnly
+        self.overrideFilename = overrideFilename
 
     def read(self, iprot):
         if iprot._fast_decode is not None and isinstance(iprot.trans, TTransport.CReadableTransport) and self.thrift_spec is not None:
@@ -197,6 +202,11 @@
                     self.isReadOnly = iprot.readBool()
                 else:
                     iprot.skip(ftype)
+            elif fid == 14:
+                if ftype == TType.STRING:
+                    self.overrideFilename = iprot.readString().decode('utf-8') if sys.version_info[0] == 2 else iprot.readString()
+                else:
+                    iprot.skip(ftype)
             else:
                 iprot.skip(ftype)
             iprot.readFieldEnd()
@@ -259,6 +269,10 @@
             oprot.writeFieldBegin('isReadOnly', TType.BOOL, 13)
             oprot.writeBool(self.isReadOnly)
             oprot.writeFieldEnd()
+        if self.overrideFilename is not None:
+            oprot.writeFieldBegin('overrideFilename', TType.STRING, 14)
+            oprot.writeString(self.overrideFilename.encode('utf-8') if sys.version_info[0] == 2 else self.overrideFilename)
+            oprot.writeFieldEnd()
         oprot.writeFieldStop()
         oprot.writeStructEnd()
 
diff --git a/django_airavata/apps/admin/static/django_airavata_admin/src/components/applications/ApplicationInputFieldEditor.vue b/django_airavata/apps/admin/static/django_airavata_admin/src/components/applications/ApplicationInputFieldEditor.vue
index 2f6f472..1d50b2d 100644
--- a/django_airavata/apps/admin/static/django_airavata_admin/src/components/applications/ApplicationInputFieldEditor.vue
+++ b/django_airavata/apps/admin/static/django_airavata_admin/src/components/applications/ApplicationInputFieldEditor.vue
@@ -11,16 +11,19 @@
         <span class="sr-only">Delete</span>
       </b-link>
     </div>
-    <b-collapse :visible="!collapse">
+    <b-collapse :id="id+'-collapse'" :visible="!collapse">
       <b-form-group label="Name" :label-for="id+'-name'">
         <b-form-input :id="id+'-name'" type="text" v-model="data.name" ref="nameInput" required :disabled="readonly"></b-form-input>
       </b-form-group>
-      <b-form-group label="Initial Value" :label-for="id+'-value'">
-        <b-form-input :id="id+'-value'" type="text" v-model="data.value" :disabled="readonly"></b-form-input>
-      </b-form-group>
       <b-form-group label="Type" :label-for="id+'-type'">
         <b-form-select :id="id+'-type'" v-model="data.type" :options="inputTypeOptions" :disabled="readonly" />
       </b-form-group>
+      <b-form-group label="Initial Value" :label-for="id+'-value'" v-if="showValueField">
+        <b-form-input :id="id+'-value'" type="text" v-model="data.value" :disabled="readonly"></b-form-input>
+      </b-form-group>
+      <b-form-group label="Override Filename" :label-for="id+'-value'" v-if="showOverrideFilenameField">
+        <b-form-input :id="id+'-override-filename'" type="text" v-model="data.overrideFilename" :disabled="readonly"></b-form-input>
+      </b-form-group>
       <b-form-group label="Application Argument" :label-for="id+'-argument'">
         <b-form-input :id="id+'-argument'" type="text" v-model="data.applicationArgument" :disabled="readonly"></b-form-input>
       </b-form-group>
@@ -102,6 +105,12 @@
     },
     id() {
       return "id-" + this.data.key;
+    },
+    showValueField() {
+      return this.data.type.isSimpleValueType
+    },
+    showOverrideFilenameField() {
+      return this.data.type === models.DataType.URI;
     }
   },
   methods: {
diff --git a/django_airavata/apps/admin/static/django_airavata_admin/src/components/applications/ApplicationInterfaceEditor.vue b/django_airavata/apps/admin/static/django_airavata_admin/src/components/applications/ApplicationInterfaceEditor.vue
index 99faa51..ad6e17a 100644
--- a/django_airavata/apps/admin/static/django_airavata_admin/src/components/applications/ApplicationInterfaceEditor.vue
+++ b/django_airavata/apps/admin/static/django_airavata_admin/src/components/applications/ApplicationInterfaceEditor.vue
@@ -91,7 +91,7 @@
   },
   data() {
     return {
-      focusApplicationInputIndex: null,
+      focusApplicationInputKey: null,
       focusApplicationOutputKey: null,
       dragOptions: {
         handle: ".drag-handle"
@@ -113,8 +113,9 @@
       Object.assign(input, newValue);
     },
     addApplicationInput() {
-      this.data.applicationInputs.push(new models.InputDataObjectType());
-      this.focusApplicationInputIndex = this.data.applicationInputs.length - 1;
+      const appInput = new models.InputDataObjectType();
+      this.data.applicationInputs.push(appInput);
+      this.focusApplicationInputKey = appInput.key;
     },
     deleteInput(input) {
       const inputIndex = this.data.applicationInputs.findIndex(
diff --git a/django_airavata/apps/admin/static/django_airavata_admin/src/components/applications/JSONEditor.vue b/django_airavata/apps/admin/static/django_airavata_admin/src/components/applications/JSONEditor.vue
index 54f8941..88b240c 100644
--- a/django_airavata/apps/admin/static/django_airavata_admin/src/components/applications/JSONEditor.vue
+++ b/django_airavata/apps/admin/static/django_airavata_admin/src/components/applications/JSONEditor.vue
@@ -8,7 +8,6 @@
   props: {
     value: {
       type: Object,
-      required: true
     },
     id: String,
     rows: Number,
diff --git a/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/UserDetailsContainer.vue b/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/UserDetailsContainer.vue
index 9bd2eba..77fab42 100644
--- a/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/UserDetailsContainer.vue
+++ b/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/UserDetailsContainer.vue
@@ -1,14 +1,20 @@
 <template>
   <div>
-    <user-group-membership-editor
-      v-if="iamUserProfile.airavataUserProfileExists"
-      v-model="localIAMUserProfile.groups"
-      :editable-groups="editableGroups"
-      :airavata-internal-user-id="iamUserProfile.airavataInternalUserId"
-      @input="groupsUpdated"
-    />
+    <b-card header="Edit Groups">
+      <user-group-membership-editor
+        v-if="iamUserProfile.airavataUserProfileExists"
+        v-model="localIAMUserProfile.groups"
+        :editable-groups="editableGroups"
+        :airavata-internal-user-id="iamUserProfile.airavataInternalUserId"
+      />
+      <b-button @click="groupsUpdated" variant="primary" :disabled="!areGroupsUpdated">Save</b-button>
+    </b-card>
     <activate-user-panel
-      v-if="iamUserProfile.enabled && iamUserProfile.emailVerified && !iamUserProfile.airavataUserProfileExists"
+      v-if="
+        iamUserProfile.enabled &&
+          iamUserProfile.emailVerified &&
+          !iamUserProfile.airavataUserProfileExists
+      "
       :username="iamUserProfile.userId"
       @activate-user="$emit('enable-user', $event)"
     />
@@ -64,7 +70,33 @@
     groupsUpdated() {
       this.$emit("groups-updated", this.localIAMUserProfile);
     }
+  },
+  computed: {
+    currentGroupIds() {
+      const groupIds = this.iamUserProfile.groups.map(g => g.id);
+      groupIds.sort();
+      return groupIds;
+    },
+    updatedGroupIds() {
+      const groupIds = this.localIAMUserProfile.groups.map(g => g.id);
+      groupIds.sort();
+      return groupIds;
+    },
+    areGroupsUpdated() {
+      for (const groupId of this.currentGroupIds) {
+        // Check if a group was removed
+        if (this.updatedGroupIds.indexOf(groupId) < 0) {
+          return true;
+        }
+      }
+      for (const groupId of this.updatedGroupIds) {
+        // Check if a group was added
+        if (this.currentGroupIds.indexOf(groupId) < 0) {
+          return true;
+        }
+      }
+      return false;
+    }
   }
 };
 </script>
-
diff --git a/django_airavata/apps/api/data_products_helper.py b/django_airavata/apps/api/data_products_helper.py
index 30ab128..6dcdd84 100644
--- a/django_airavata/apps/api/data_products_helper.py
+++ b/django_airavata/apps/api/data_products_helper.py
@@ -1,4 +1,6 @@
+import copy
 import logging
+import mimetypes
 import os
 from urllib.parse import urlparse
 
@@ -19,30 +21,34 @@
 TMP_INPUT_FILE_UPLOAD_DIR = "tmp"
 
 
-def save(request, path, file, name=None):
+def save(request, path, file, name=None, content_type=None):
     "Save file in path in the user's storage."
     username = request.user.username
     full_path = datastore.save(username, path, file, name=name)
-    data_product = _save_data_product(request, full_path, name=name)
+    data_product = _save_data_product(request, full_path, name=name,
+                                      content_type=content_type)
     return data_product
 
 
-def move_from_filepath(request, source_path, target_path, name=None):
+def move_from_filepath(request, source_path, target_path, name=None,
+                       content_type=None):
     "Move a file from filesystem into user's storage."
     username = request.user.username
     file_name = name if name is not None else os.path.basename(source_path)
     full_path = datastore.move_external(
         source_path, username, target_path, file_name)
-    data_product = _save_data_product(request, full_path, name=file_name)
+    data_product = _save_data_product(request, full_path, name=file_name,
+                                      content_type=content_type)
     return data_product
 
 
-def save_input_file_upload(request, file, name=None):
+def save_input_file_upload(request, file, name=None, content_type=None):
     """Save input file in staging area for input file uploads."""
     username = request.user.username
     file_name = name if name is not None else os.path.basename(file.name)
     full_path = datastore.save(username, TMP_INPUT_FILE_UPLOAD_DIR, file)
-    data_product = _save_data_product(request, full_path, name=file_name)
+    data_product = _save_data_product(request, full_path, name=file_name,
+                                      content_type=content_type)
     return data_product
 
 
@@ -54,7 +60,7 @@
                                request.user.username,
                                TMP_INPUT_FILE_UPLOAD_DIR,
                                name=name)
-    return _save_data_product(request, full_path, name=name)
+    return _save_copy_of_data_product(request, full_path, data_product)
 
 
 def is_input_file_upload(request, data_product):
@@ -77,21 +83,23 @@
         path,
         file_name)
     _delete_data_product(data_product.ownerName, source_path)
-    data_product = _save_data_product(request, full_path, name=file_name)
+    data_product = _save_copy_of_data_product(request, full_path, data_product)
     return data_product
 
 
-def move_input_file_upload_from_filepath(request, source_path, name=None):
+def move_input_file_upload_from_filepath(request, source_path, name=None,
+                                         content_type=None):
     "Move a file from filesystem into user's input file staging area."
     username = request.user.username
     file_name = name if name is not None else os.path.basename(source_path)
     full_path = datastore.move_external(
         source_path, username, TMP_INPUT_FILE_UPLOAD_DIR, file_name)
-    data_product = _save_data_product(request, full_path, name=file_name)
+    data_product = _save_data_product(request, full_path, name=file_name,
+                                      content_type=content_type)
     return data_product
 
 
-def open(request, data_product):
+def open_file(request, data_product):
     "Return file object for replica if it exists in user storage."
     path = _get_replica_filepath(data_product)
     return datastore.open(data_product.ownerName, path)
@@ -196,19 +204,43 @@
     return product_uri
 
 
-def _save_data_product(request, full_path, name=None):
+def _save_data_product(request, full_path, name=None, content_type=None):
     "Create, register and record in DB a data product for full_path."
     data_product = _create_data_product(
-        request.user.username, full_path, name=name)
+        request.user.username, full_path, name=name, content_type=content_type)
+    product_uri = _register_data_product(request, full_path, data_product)
+    data_product.productUri = product_uri
+    return data_product
+
+
+def _register_data_product(request, full_path, data_product):
     product_uri = request.airavata_client.registerDataProduct(
         request.authz_token, data_product)
-    data_product.productUri = product_uri
     user_file_instance = models.User_Files(
         username=request.user.username,
         file_path=full_path,
         file_dpu=product_uri)
     user_file_instance.save()
-    return data_product
+    return product_uri
+
+
+def _save_copy_of_data_product(request, full_path, data_product):
+    """Save copy of a data product with a different path."""
+    data_product_copy = _copy_data_product(request, data_product, full_path)
+    product_uri = _register_data_product(request, full_path, data_product_copy)
+    data_product_copy.productUri = product_uri
+    return data_product_copy
+
+
+def _copy_data_product(request, data_product, full_path):
+    """Create an unsaved copy of a data product with different path."""
+    data_product_copy = copy.copy(data_product)
+    data_product_copy.productUri = None
+    data_product_copy.ownerName = request.user.username
+    data_replica_location = _create_replica_location(
+        full_path, data_product_copy.productName)
+    data_product_copy.replicaLocations = [data_replica_location]
+    return data_product_copy
 
 
 def _delete_data_product(username, full_path):
@@ -220,7 +252,8 @@
         user_file.delete()
 
 
-def _create_data_product(username, full_path, name=None):
+def _create_data_product(username, full_path, name=None,
+                         content_type=None):
     data_product = DataProductModel()
     data_product.gatewayId = settings.GATEWAY_ID
     data_product.ownerName = username
@@ -230,6 +263,31 @@
         file_name = os.path.basename(full_path)
     data_product.productName = file_name
     data_product.dataProductType = DataProductType.FILE
+    final_content_type = _determine_content_type(full_path, content_type)
+    if final_content_type is not None:
+        data_product.productMetadata = {'mime-type': final_content_type}
+    data_replica_location = _create_replica_location(full_path, file_name)
+    data_product.replicaLocations = [data_replica_location]
+    return data_product
+
+
+def _determine_content_type(full_path, content_type=None):
+    result = content_type
+    if result is None:
+        # Try to guess the content-type from file extension
+        guessed_type, encoding = mimetypes.guess_type(full_path)
+        result = guessed_type
+    if result is None or result == 'application/octet-stream':
+        # Check if file is Unicode text by trying to read some of it
+        try:
+            open(full_path, 'r').read(1024)
+            result = 'text/plain'
+        except UnicodeDecodeError:
+            logger.debug(f"Failed to read as Unicode text: {full_path}")
+    return result
+
+
+def _create_replica_location(full_path, file_name):
     data_replica_location = DataReplicaLocationModel()
     data_replica_location.storageResourceId = \
         settings.GATEWAY_DATA_STORE_RESOURCE_ID
@@ -242,8 +300,7 @@
     data_replica_location.filePath = \
         "file://{}:{}".format(settings.GATEWAY_DATA_STORE_HOSTNAME,
                               full_path)
-    data_product.replicaLocations = [data_replica_location]
-    return data_product
+    return data_replica_location
 
 
 def _get_replica_filepath(data_product):
diff --git a/django_airavata/apps/api/output_views.py b/django_airavata/apps/api/output_views.py
index 2375bd2..0614e56 100644
--- a/django_airavata/apps/api/output_views.py
+++ b/django_airavata/apps/api/output_views.py
@@ -193,7 +193,7 @@
         data_product = request.airavata_client.getDataProduct(
             request.authz_token, experiment_output.value)
         if data_products_helper.exists(request, data_product):
-            output_file = data_products_helper.open(request, data_product)
+            output_file = data_products_helper.open_file(request, data_product)
         elif settings.DEBUG and test_output_file is not None:
             output_file = open(test_output_file, 'rb')
     # TODO: change interface to provide output_file as a path
diff --git a/django_airavata/apps/api/signals.py b/django_airavata/apps/api/signals.py
index 3967e20..f01df92 100644
--- a/django_airavata/apps/api/signals.py
+++ b/django_airavata/apps/api/signals.py
@@ -1,15 +1,20 @@
-"""Signal receivers for the api app."""
+"""Signal and receivers for the api app."""
 
 import logging
 
 from django.contrib.auth.signals import user_logged_in
-from django.dispatch import receiver
+from django.dispatch import Signal, receiver
 
 from . import data_products_helper
 
 log = logging.getLogger(__name__)
 
 
+# Signals
+user_added_to_group = Signal(providing_args=["user", "groups", "request"])
+
+
+# Receivers
 @receiver(user_logged_in)
 def create_user_storage_dir(sender, request, user, **kwargs):
     """Create user's home direct in gateway storage."""
diff --git a/django_airavata/apps/api/static/django_airavata_api/js/models/DataProduct.js b/django_airavata/apps/api/static/django_airavata_api/js/models/DataProduct.js
index 2634807..da6f6d9 100644
--- a/django_airavata/apps/api/static/django_airavata_api/js/models/DataProduct.js
+++ b/django_airavata/apps/api/static/django_airavata_api/js/models/DataProduct.js
@@ -32,6 +32,7 @@
 ];
 
 const FILENAME_REGEX = /[^/]+$/;
+const TEXT_MIME_TYPE_REGEX = /^text\/.+/;
 
 export default class DataProduct extends BaseModel {
     constructor(data = {}) {
@@ -49,4 +50,12 @@
         }
         return null;
     }
+
+    get isText() {
+      return this.mimeType && TEXT_MIME_TYPE_REGEX.test(this.mimeType);
+    }
+
+    get mimeType() {
+      return this.productMetadata && this.productMetadata['mime-type'] ? this.productMetadata['mime-type'] : null;
+    }
 }
diff --git a/django_airavata/apps/api/static/django_airavata_api/js/models/InputDataObjectType.js b/django_airavata/apps/api/static/django_airavata_api/js/models/InputDataObjectType.js
index f3a25fb..872bc99 100644
--- a/django_airavata/apps/api/static/django_airavata_api/js/models/InputDataObjectType.js
+++ b/django_airavata/apps/api/static/django_airavata_api/js/models/InputDataObjectType.js
@@ -41,7 +41,8 @@
     name: "isReadOnly",
     type: "boolean",
     default: false
-  }
+  },
+  "overrideFilename"
 ];
 
 const IS_REQUIRED_DEFAULT = "This field is required.";
@@ -241,10 +242,11 @@
           this.editorDependencies.show
         );
         if ("showOptions" in this.editorDependencies) {
-          if ("toggle" in this.editorDependencies.showOptions) {
-            this.editorDependencies.showOptions.toggle.forEach(prop => {
-              this[prop] = this.show;
-            });
+          if (
+            "isRequired" in this.editorDependencies.showOptions &&
+            this.editorDependencies.showOptions.isRequired
+          ) {
+            this.isRequired = this.show;
           }
         }
       }
diff --git a/django_airavata/apps/api/tests.py b/django_airavata/apps/api/tests.py
deleted file mode 100644
index 4929020..0000000
--- a/django_airavata/apps/api/tests.py
+++ /dev/null
@@ -1,2 +0,0 @@
-
-# Create your tests here.
diff --git a/django_airavata/apps/api/tests/__init__.py b/django_airavata/apps/api/tests/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/django_airavata/apps/api/tests/__init__.py
diff --git a/django_airavata/apps/api/tests/test_data_products_helper.py b/django_airavata/apps/api/tests/test_data_products_helper.py
new file mode 100644
index 0000000..839cb28
--- /dev/null
+++ b/django_airavata/apps/api/tests/test_data_products_helper.py
@@ -0,0 +1,180 @@
+import io
+import os
+import tempfile
+import uuid
+from unittest.mock import MagicMock
+from urllib.parse import urlparse
+
+from django.contrib.auth.models import User
+from django.test import RequestFactory, TestCase, override_settings
+
+from airavata.model.data.replica.ttypes import (
+    DataProductModel,
+    DataProductType,
+    DataReplicaLocationModel,
+    ReplicaLocationCategory
+)
+from django_airavata.apps.api import data_products_helper
+
+GATEWAY_ID = 'test-gateway'
+
+
+@override_settings(GATEWAY_ID=GATEWAY_ID)
+class BaseTestCase(TestCase):
+
+    def setUp(self):
+        self.user = User.objects.create_user('testuser')
+        self.factory = RequestFactory()
+        # Dummy POST request
+        self.request = self.factory.post('/upload', {})
+        self.request.user = self.user
+        self.request.airavata_client = MagicMock(name="airavata_client")
+        self.product_uri = f"airavata-dp://{uuid.uuid4()}"
+        self.request.airavata_client.registerDataProduct.return_value = \
+            self.product_uri
+        self.request.authz_token = "dummy"
+
+
+class SaveTests(BaseTestCase):
+
+    def test_save_with_defaults(self):
+        "Test save with default name and content type"
+        with tempfile.TemporaryDirectory() as tmpdirname, \
+                self.settings(GATEWAY_DATA_STORE_DIR=tmpdirname,
+                              GATEWAY_DATA_STORE_HOSTNAME="gateway.com"):
+            # path is just the user directory in gateway storage
+            path = os.path.join(tmpdirname, self.user.username)
+            file = io.StringIO("Foo file")
+            file.name = "foo.txt"
+            data_product = data_products_helper.save(self.request, path, file)
+
+            self.assertEqual(data_product.productUri, self.product_uri)
+            self.request.airavata_client.registerDataProduct.\
+                assert_called_once()
+            args, kws = self.request.airavata_client.registerDataProduct.\
+                call_args
+            dp = args[1]
+            self.assertEqual(self.user.username, dp.ownerName)
+            self.assertEqual("foo.txt", dp.productName)
+            self.assertEqual(DataProductType.FILE, dp.dataProductType)
+            self.assertDictEqual({'mime-type': 'text/plain'},
+                                 dp.productMetadata)
+            self.assertEqual(1, len(dp.replicaLocations))
+            self.assertEqual(f"file://gateway.com:{path}/{file.name}",
+                             dp.replicaLocations[0].filePath)
+
+    def test_save_with_name_and_content_type(self):
+        "Test save with specified name and content type"
+        with tempfile.TemporaryDirectory() as tmpdirname, \
+                self.settings(GATEWAY_DATA_STORE_DIR=tmpdirname,
+                              GATEWAY_DATA_STORE_HOSTNAME="gateway.com"):
+            # path is just the user directory in gateway storage
+            path = os.path.join(tmpdirname, self.user.username)
+            file = io.StringIO("Foo file")
+            file.name = "foo.txt"
+            data_product = data_products_helper.save(
+                self.request, path, file, name="bar.txt",
+                content_type="application/some-app")
+
+            self.assertEqual(data_product.productUri, self.product_uri)
+            self.request.airavata_client.registerDataProduct.\
+                assert_called_once()
+            args, kws = self.request.airavata_client.registerDataProduct.\
+                call_args
+            dp = args[1]
+            self.assertEqual(self.user.username, dp.ownerName)
+            self.assertEqual("bar.txt", dp.productName)
+            self.assertEqual(DataProductType.FILE, dp.dataProductType)
+            self.assertDictEqual({'mime-type': 'application/some-app'},
+                                 dp.productMetadata)
+            self.assertEqual(1, len(dp.replicaLocations))
+            self.assertEqual(f"file://gateway.com:{path}/bar.txt",
+                             dp.replicaLocations[0].filePath)
+
+    def test_save_with_unknown_text_file_type(self):
+        "Test save with unknown file ext for text file"
+        with tempfile.TemporaryDirectory() as tmpdirname, \
+                self.settings(GATEWAY_DATA_STORE_DIR=tmpdirname,
+                              GATEWAY_DATA_STORE_HOSTNAME="gateway.com"):
+            path = os.path.join(
+                tmpdirname, "foo.someext")
+            os.makedirs(os.path.dirname(path), exist_ok=True)
+            with open(path, 'w') as f:
+                f.write("Some Unicode text")
+            with open(path, 'r') as f:
+                dp = data_products_helper.save(
+                    self.request, "some/path", f,
+                    content_type="application/octet-stream")
+                # Make sure that the file contents are tested to see if text
+                self.assertDictEqual({'mime-type': 'text/plain'},
+                                     dp.productMetadata)
+
+    def test_save_with_unknown_binary_file_type(self):
+        "Test save with unknown file ext for binary file"
+        with tempfile.TemporaryDirectory() as tmpdirname, \
+                self.settings(GATEWAY_DATA_STORE_DIR=tmpdirname,
+                              GATEWAY_DATA_STORE_HOSTNAME="gateway.com"):
+            path = os.path.join(
+                tmpdirname, "foo.someext")
+            os.makedirs(os.path.dirname(path), exist_ok=True)
+            with open(path, 'wb') as f:
+                f.write(bytes(range(256)))
+            with open(path, 'rb') as f:
+                dp = data_products_helper.save(
+                    self.request, "some/path", f,
+                    content_type="application/octet-stream")
+                # Make sure that DID NOT determine file contents are text
+                self.assertDictEqual({'mime-type': 'application/octet-stream'},
+                                     dp.productMetadata)
+
+
+class CopyInputFileUploadTests(BaseTestCase):
+    def test_copy_input_file_upload(self):
+        "Test copy input file upload copies data product"
+        with tempfile.TemporaryDirectory() as tmpdirname, \
+                self.settings(GATEWAY_DATA_STORE_DIR=tmpdirname,
+                              GATEWAY_DATA_STORE_HOSTNAME="gateway.com"):
+            # path is just the user directory in gateway storage
+            source_path = os.path.join(
+                tmpdirname, self.user.username, "foo.ext")
+            os.makedirs(os.path.dirname(source_path))
+            with open(source_path, 'wb') as f:
+                f.write(b"123")
+
+            data_product = DataProductModel()
+            data_product.productUri = f"airavata-dp://{uuid.uuid4()}"
+            data_product.gatewayId = GATEWAY_ID
+            data_product.ownerName = self.user.username
+            data_product.productName = "foo.ext"
+            data_product.dataProductType = DataProductType.FILE
+            data_product.productMetadata = {
+                'mime-type': 'application/some-app'
+            }
+            replica_category = ReplicaLocationCategory.GATEWAY_DATA_STORE
+            replica_path = f"file://gateway.com:{source_path}"
+            data_product.replicaLocations = [
+                DataReplicaLocationModel(
+                    filePath=replica_path,
+                    replicaLocationCategory=replica_category)]
+
+            data_product_copy = data_products_helper.copy_input_file_upload(
+                self.request, data_product)
+
+            self.request.airavata_client.registerDataProduct.\
+                assert_called_once()
+            self.assertIsNot(data_product_copy, data_product)
+            self.assertNotEqual(data_product_copy.productUri,
+                                data_product.productUri)
+            self.assertDictEqual(data_product_copy.productMetadata,
+                                 data_product.productMetadata)
+            self.assertEqual(data_product_copy.productName,
+                             data_product.productName)
+            self.assertEqual(data_product_copy.dataProductType,
+                             data_product.dataProductType)
+            replica_copy_path = data_product_copy.replicaLocations[0].filePath
+            self.assertNotEqual(replica_copy_path, replica_path)
+            replica_copy_filepath = urlparse(replica_copy_path).path
+            self.assertEqual(
+                os.path.dirname(replica_copy_filepath),
+                os.path.join(tmpdirname, self.user.username, "tmp"),
+                msg="Verify input file copied to user's tmp dir")
diff --git a/django_airavata/apps/api/tests/test_views.py b/django_airavata/apps/api/tests/test_views.py
new file mode 100644
index 0000000..a70703f
--- /dev/null
+++ b/django_airavata/apps/api/tests/test_views.py
@@ -0,0 +1,470 @@
+from unittest.mock import MagicMock, call, patch
+
+from django.contrib.auth.models import User
+from django.test import TestCase, override_settings
+from django.urls import reverse
+# from rest_framework import status
+from rest_framework.test import APIRequestFactory, force_authenticate
+
+from airavata.model.appcatalog.gatewaygroups.ttypes import GatewayGroups
+from airavata.model.group.ttypes import GroupModel
+from airavata.model.user.ttypes import UserProfile
+from django_airavata.apps.api import signals, views
+
+GATEWAY_ID = "test-gateway"
+PORTAL_ADMINS = [('Admin Name', 'admin@example.com')]
+
+
+@override_settings(
+    GATEWAY_ID=GATEWAY_ID,
+    PORTAL_ADMINS=PORTAL_ADMINS
+)
+class GroupViewSetTests(TestCase):
+
+    def setUp(self):
+        self.user = User.objects.create_user('testuser')
+        self.factory = APIRequestFactory()
+
+    def test_create_group_sends_user_added_to_group_signal(self):
+
+        url = reverse('django_airavata_api:group-list')
+        data = {
+            "id": None,
+            "name": "test",
+            "description": None,
+            "members": [
+                    f"{self.user.username}@{GATEWAY_ID}",  # owner
+                    f"testuser1@{GATEWAY_ID}"],
+            "admins": []
+        }
+        request = self.factory.post(url, data)
+        force_authenticate(request, self.user)
+
+        # Mock api clients
+        group_manager_mock = MagicMock(name='group_manager')
+        user_profile_mock = MagicMock(name='user_profile')
+        request.profile_service = {
+            'group_manager': group_manager_mock,
+            'user_profile': user_profile_mock,
+        }
+        request.airavata_client = MagicMock(name="airavata_client")
+        request.airavata_client.getGatewayGroups.return_value = GatewayGroups(
+            gatewayId=GATEWAY_ID,
+            adminsGroupId="adminsGroupId",
+            readOnlyAdminsGroupId="readOnlyAdminsGroupId",
+            defaultGatewayUsersGroupId="defaultGatewayUsersGroupId"
+        )
+        request.authz_token = "dummy"
+        request.session = {}
+        group_manager_mock.createGroup.return_value = "abc123"
+        user_profile = UserProfile(
+            airavataInternalUserId=f"testuser1@{GATEWAY_ID}",
+            userId="testuser1",
+            firstName="Test",
+            lastName="User1",
+            emails=["testuser1@example.com"]
+        )
+        user_profile_mock.getUserProfileById.return_value = user_profile
+
+        # Mock signal handler to verify 'user_added_to_group' signal is sent
+        user_added_to_group_handler = MagicMock()
+        signals.user_added_to_group.connect(
+            user_added_to_group_handler,
+            sender=views.GroupViewSet)
+        group_create = views.GroupViewSet.as_view({'post': 'create'})
+        response = group_create(request)
+        self.assertEquals(201, response.status_code)
+        self.assertEquals("abc123", response.data['id'])
+        user_added_to_group_handler.assert_called_once()
+        args, kwargs = user_added_to_group_handler.call_args
+        self.assertEquals("abc123", kwargs["groups"][0].id)
+        self.assertIs(user_profile, kwargs["user"])
+
+    def test_update_group_sends_user_added_to_group_signal(self):
+        url = reverse('django_airavata_api:group-detail',
+                      kwargs={'group_id': 'abc123'})
+        data = {
+            "id": "abc123",
+            "name": "test",
+            "description": None,
+            "members": [
+                    f"{self.user.username}@{GATEWAY_ID}",  # owner
+                    f"testuser1@{GATEWAY_ID}",  # existing member
+                    f"testuser3@{GATEWAY_ID}"],  # new member
+            "admins": []
+        }
+        request = self.factory.put(url, data)
+        force_authenticate(request, self.user)
+
+        # Mock api clients
+        group_manager_mock = MagicMock(name='group_manager')
+        user_profile_mock = MagicMock(name='user_profile')
+        request.profile_service = {
+            'group_manager': group_manager_mock,
+            'user_profile': user_profile_mock,
+        }
+        request.airavata_client = MagicMock(name="airavata_client")
+        request.airavata_client.getGatewayGroups.return_value = GatewayGroups(
+            gatewayId=GATEWAY_ID,
+            adminsGroupId="adminsGroupId",
+            readOnlyAdminsGroupId="readOnlyAdminsGroupId",
+            defaultGatewayUsersGroupId="defaultGatewayUsersGroupId"
+        )
+        request.authz_token = "dummy"
+        request.session = {}
+
+        # mock getGroup
+        group = GroupModel(id="abc123", name="My Group",
+                           ownerId=f"{self.user.username}@{GATEWAY_ID}",
+                           members=[
+                               f"{self.user.username}@{GATEWAY_ID}",  # owner
+                               f"testuser1@{GATEWAY_ID}",  # existing member
+                               f"testuser2@{GATEWAY_ID}",  # new member
+                           ],
+                           admins=[])
+        group_manager_mock.getGroup.return_value = group
+
+        # Only user added is testuser3, so getUserProfileById will be called
+        # for that user
+        user_profile = UserProfile(
+            airavataInternalUserId=f"testuser3@{GATEWAY_ID}",
+            userId="testuser3",
+            firstName="Test",
+            lastName="User3",
+            emails=["testuser3@example.com"]
+        )
+        user_profile_mock.getUserProfileById.return_value = user_profile
+
+        # Mock signal handler to verify 'user_added_to_group' signal is sent
+        user_added_to_group_handler = MagicMock()
+        signals.user_added_to_group.connect(
+            user_added_to_group_handler,
+            sender=views.GroupViewSet)
+        group_update = views.GroupViewSet.as_view({'put': 'update'})
+        response = group_update(request, group_id="abc123")
+        self.assertEquals(200, response.status_code)
+        self.assertEquals("abc123", response.data['id'])
+
+        # verify addUsersToGroup
+        group_manager_mock.addUsersToGroup.assert_called_once()
+        args, kwargs = group_manager_mock.addUsersToGroup.call_args
+        self.assertEqual(args[1], [f"testuser3@{GATEWAY_ID}"])
+
+        # verify removeUsersFromGroup
+        group_manager_mock.removeUsersFromGroup.assert_called_once()
+        args, kwargs = group_manager_mock.removeUsersFromGroup.call_args
+        self.assertEqual(args[1], [f"testuser2@{GATEWAY_ID}"])
+
+        # verify updateGroup
+        group_manager_mock.updateGroup.assert_called_once()
+
+        user_added_to_group_handler.assert_called_once()
+        args, kwargs = user_added_to_group_handler.call_args
+        self.assertEquals("abc123", kwargs["groups"][0].id)
+        self.assertIs(user_profile, kwargs["user"])
+
+
+@override_settings(
+    GATEWAY_ID=GATEWAY_ID,
+    PORTAL_ADMINS=PORTAL_ADMINS
+)
+class IAMUserViewSetTests(TestCase):
+
+    def setUp(self):
+        self.user = User.objects.create_user('testuser')
+        self.factory = APIRequestFactory()
+
+    @patch("django_airavata.apps.api.views.iam_admin_client")
+    def test_update_that_adds_user_to_group_sends_user_added_to_group_signal(
+            self, iam_admin_client):
+
+        username = "testuser1"
+        url = reverse(
+            'django_airavata_api:iam-user-profile-detail',
+            kwargs={'user_id': username})
+        data = {
+            "airavataInternalUserId": f"{username}@{GATEWAY_ID}",
+            "userId": username,
+            "gatewayId": GATEWAY_ID,
+            "email": "testuser1@example.com",
+            "firstName": "Test",
+            "lastName": "User1",
+            "airavataUserProfileExists": True,
+            "enabled": True,
+            "emailVerified": True,
+            "groups": [
+                {"id": "group1", "name": "Group 1"},
+                {"id": "group2", "name": "Group 2"}
+            ]
+        }
+        request = self.factory.put(url, data)
+        force_authenticate(request, self.user)
+        request.is_gateway_admin = True
+
+        # Mock api clients
+        iam_user_profile = UserProfile(
+            airavataInternalUserId=f"testuser1@{GATEWAY_ID}",
+            userId="testuser1",
+            firstName="Test",
+            lastName="User1",
+            emails=["testuser1@example.com"]
+        )
+        iam_admin_client.get_user.return_value = iam_user_profile
+        group_manager_mock = MagicMock(name='group_manager')
+        user_profile_mock = MagicMock(name='user_profile')
+        request.profile_service = {
+            'group_manager': group_manager_mock,
+            'user_profile': user_profile_mock,
+        }
+        request.authz_token = "dummy"
+        user_profile_mock.doesUserExist.return_value = True
+        user_profile = UserProfile(
+            airavataInternalUserId=f"testuser1@{GATEWAY_ID}",
+            userId="testuser1",
+            firstName="Test",
+            lastName="User1",
+            emails=["testuser1@example.com"]
+        )
+        user_profile_mock.getUserProfileById.return_value = user_profile
+        group_manager_mock.getAllGroupsUserBelongs.return_value = [
+            GroupModel(id="group1")]
+        group = GroupModel(
+            id="group2", name="Group 2"
+        )
+        group_manager_mock.getGroup.return_value = group
+        request.airavata_client = MagicMock(name="airavata_client")
+        request.airavata_client.getGatewayGroups.return_value = GatewayGroups(
+            gatewayId=GATEWAY_ID,
+            adminsGroupId="adminsGroupId",
+            readOnlyAdminsGroupId="readOnlyAdminsGroupId",
+            defaultGatewayUsersGroupId="defaultGatewayUsersGroupId"
+        )
+        request.session = {}
+
+        # Mock signal handler to verify 'user_added_to_group' signal is sent
+        user_added_to_group_handler = MagicMock(
+            name="user_added_to_group_handler")
+        signals.user_added_to_group.connect(
+            user_added_to_group_handler,
+            sender=views.IAMUserViewSet)
+        iam_user_update = views.IAMUserViewSet.as_view({'put': 'update'})
+        response = iam_user_update(request, user_id=username)
+        self.assertEquals(200, response.status_code)
+
+        user_profile_mock.doesUserExist.assert_called_once()
+        group_manager_mock.getAllGroupsUserBelongs.assert_called_once()
+
+        user_profile_mock.getUserProfileById.assert_called_once()
+        args, kwargs = user_profile_mock.getUserProfileById.call_args
+        self.assertSequenceEqual(
+            args, [request.authz_token, "testuser1", GATEWAY_ID])
+
+        group_manager_mock.getGroup.assert_called_once()
+        args, kwargs = group_manager_mock.getGroup.call_args
+        self.assertSequenceEqual(args, [request.authz_token, "group2"])
+
+        group_manager_mock.addUsersToGroup.assert_called_once()
+        args, kwargs = group_manager_mock.addUsersToGroup.call_args
+        self.assertSequenceEqual(
+            args, [request.authz_token, [f"testuser1@{GATEWAY_ID}"], "group2"]
+        )
+
+        user_added_to_group_handler.assert_called_once()
+        args, kwargs = user_added_to_group_handler.call_args
+        self.assertEqual(kwargs["sender"], views.IAMUserViewSet)
+        self.assertEqual(kwargs["user"], user_profile)
+        self.assertEqual(kwargs["groups"][0], group)
+
+    @patch("django_airavata.apps.api.views.iam_admin_client")
+    def test_update_that_adds_user_to_multiple_groups(
+            self, iam_admin_client):
+
+        username = "testuser1"
+        url = reverse(
+            'django_airavata_api:iam-user-profile-detail',
+            kwargs={'user_id': username})
+        data = {
+            "airavataInternalUserId": f"{username}@{GATEWAY_ID}",
+            "userId": username,
+            "gatewayId": GATEWAY_ID,
+            "email": "testuser1@example.com",
+            "firstName": "Test",
+            "lastName": "User1",
+            "airavataUserProfileExists": True,
+            "enabled": True,
+            "emailVerified": True,
+            "groups": [
+                {"id": "group1", "name": "Group 1"},
+                {"id": "group2", "name": "Group 2"},
+                {"id": "group3", "name": "Group 3"},
+            ]
+        }
+        request = self.factory.put(url, data)
+        force_authenticate(request, self.user)
+        request.is_gateway_admin = True
+
+        # Mock api clients
+        iam_user_profile = UserProfile(
+            airavataInternalUserId=f"testuser1@{GATEWAY_ID}",
+            userId="testuser1",
+            firstName="Test",
+            lastName="User1",
+            emails=["testuser1@example.com"]
+        )
+        iam_admin_client.get_user.return_value = iam_user_profile
+        group_manager_mock = MagicMock(name='group_manager')
+        user_profile_mock = MagicMock(name='user_profile')
+        request.profile_service = {
+            'group_manager': group_manager_mock,
+            'user_profile': user_profile_mock,
+        }
+        request.authz_token = "dummy"
+        user_profile_mock.doesUserExist.return_value = True
+        user_profile = UserProfile(
+            airavataInternalUserId=f"testuser1@{GATEWAY_ID}",
+            userId="testuser1",
+            firstName="Test",
+            lastName="User1",
+            emails=["testuser1@example.com"]
+        )
+        user_profile_mock.getUserProfileById.return_value = user_profile
+        group_manager_mock.getAllGroupsUserBelongs.return_value = [
+            GroupModel(id="group1")]
+
+        def side_effect(authz_token, group_id):
+            if group_id == "group2":
+                return GroupModel(id="group2", name="Group 2")
+            elif group_id == "group3":
+                return GroupModel(id="group3", name="Group 3")
+            else:
+                raise Exception("Unexpected group id: " + group_id)
+
+        group_manager_mock.getGroup.side_effect = side_effect
+        request.airavata_client = MagicMock(name="airavata_client")
+        request.airavata_client.getGatewayGroups.return_value = GatewayGroups(
+            gatewayId=GATEWAY_ID,
+            adminsGroupId="adminsGroupId",
+            readOnlyAdminsGroupId="readOnlyAdminsGroupId",
+            defaultGatewayUsersGroupId="defaultGatewayUsersGroupId"
+        )
+        request.session = {}
+
+        # Mock signal handler to verify 'user_added_to_group' signal is sent
+        user_added_to_group_handler = MagicMock(
+            name="user_added_to_group_handler")
+        signals.user_added_to_group.connect(
+            user_added_to_group_handler,
+            sender=views.IAMUserViewSet)
+        iam_user_update = views.IAMUserViewSet.as_view({'put': 'update'})
+        response = iam_user_update(request, user_id=username)
+        self.assertEquals(200, response.status_code)
+
+        user_profile_mock.doesUserExist.assert_called_once()
+        group_manager_mock.getAllGroupsUserBelongs.assert_called_once()
+
+        user_profile_mock.getUserProfileById.assert_called_once()
+        args, kwargs = user_profile_mock.getUserProfileById.call_args
+        self.assertSequenceEqual(
+            args, [request.authz_token, "testuser1", GATEWAY_ID])
+
+        group_manager_mock.getGroup.assert_has_calls([
+            call(request.authz_token, "group2"),
+            call(request.authz_token, "group3")
+        ], any_order=True)
+
+        group_manager_mock.addUsersToGroup.assert_has_calls([
+            call(request.authz_token, [f"testuser1@{GATEWAY_ID}"], "group2"),
+            call(request.authz_token, [f"testuser1@{GATEWAY_ID}"], "group3"),
+        ], any_order=True)
+
+        # user_added_to_group signal should only be called once, with both
+        # groups passed to it
+        user_added_to_group_handler.assert_called_once()
+        args, kwargs = user_added_to_group_handler.call_args
+        self.assertEqual(kwargs["sender"], views.IAMUserViewSet)
+        self.assertEqual(kwargs["user"], user_profile)
+        self.assertSetEqual({"group2", "group3"},
+                            {g.id for g in kwargs["groups"]})
+
+    @patch("django_airavata.apps.api.views.iam_admin_client")
+    def test_update_that_does_not_add_user_to_groups(
+            self, iam_admin_client):
+
+        username = "testuser1"
+        url = reverse(
+            'django_airavata_api:iam-user-profile-detail',
+            kwargs={'user_id': username})
+        data = {
+            "airavataInternalUserId": f"{username}@{GATEWAY_ID}",
+            "userId": username,
+            "gatewayId": GATEWAY_ID,
+            "email": "testuser1@example.com",
+            "firstName": "Test",
+            "lastName": "User1",
+            "airavataUserProfileExists": True,
+            "enabled": True,
+            "emailVerified": True,
+            "groups": [
+                {"id": "group1", "name": "Group 1"},
+            ]
+        }
+        request = self.factory.put(url, data)
+        force_authenticate(request, self.user)
+        request.is_gateway_admin = True
+
+        # Mock api clients
+        iam_user_profile = UserProfile(
+            airavataInternalUserId=f"testuser1@{GATEWAY_ID}",
+            userId="testuser1",
+            firstName="Test",
+            lastName="User1",
+            emails=["testuser1@example.com"]
+        )
+        iam_admin_client.get_user.return_value = iam_user_profile
+        group_manager_mock = MagicMock(name='group_manager')
+        user_profile_mock = MagicMock(name='user_profile')
+        request.profile_service = {
+            'group_manager': group_manager_mock,
+            'user_profile': user_profile_mock,
+        }
+        request.authz_token = "dummy"
+        user_profile_mock.doesUserExist.return_value = True
+        user_profile = UserProfile(
+            airavataInternalUserId=f"testuser1@{GATEWAY_ID}",
+            userId="testuser1",
+            firstName="Test",
+            lastName="User1",
+            emails=["testuser1@example.com"]
+        )
+        user_profile_mock.getUserProfileById.return_value = user_profile
+        group_manager_mock.getAllGroupsUserBelongs.return_value = [
+            GroupModel(id="group1")]
+
+        request.airavata_client = MagicMock(name="airavata_client")
+        request.airavata_client.getGatewayGroups.return_value = GatewayGroups(
+            gatewayId=GATEWAY_ID,
+            adminsGroupId="adminsGroupId",
+            readOnlyAdminsGroupId="readOnlyAdminsGroupId",
+            defaultGatewayUsersGroupId="defaultGatewayUsersGroupId"
+        )
+        request.session = {}
+
+        # Mock signal handler to verify 'user_added_to_group' signal is sent
+        user_added_to_group_handler = MagicMock(
+            name="user_added_to_group_handler")
+        signals.user_added_to_group.connect(
+            user_added_to_group_handler,
+            sender=views.IAMUserViewSet)
+        iam_user_update = views.IAMUserViewSet.as_view({'put': 'update'})
+        response = iam_user_update(request, user_id=username)
+        self.assertEquals(200, response.status_code)
+
+        user_profile_mock.doesUserExist.assert_called_once()
+        group_manager_mock.getAllGroupsUserBelongs.assert_called_once()
+
+        # Since user wasn't added to a group, these all should not have been
+        # called
+        user_profile_mock.getUserProfileById.assert_not_called()
+        group_manager_mock.getGroup.assert_not_called()
+        group_manager_mock.addUsersToGroup.assert_not_called()
+        user_added_to_group_handler.assert_not_called()
diff --git a/django_airavata/apps/api/tus.py b/django_airavata/apps/api/tus.py
index 41d7640..8f6fea1 100644
--- a/django_airavata/apps/api/tus.py
+++ b/django_airavata/apps/api/tus.py
@@ -12,7 +12,7 @@
     """
     Move upload identified by upload_url using the provided move_function.
 
-    move_function signature should be (file_path, file_name). It's
+    move_function signature should be (file_path, file_name, file_type). It's
     return value will be returned.
     """
     # file UUID is last path component in URL. For example:
@@ -34,6 +34,7 @@
     with open(upload_info_path) as upload_info_file:
         upload_info = json.load(upload_info_file)
         filename = upload_info['MetaData']['filename']
-        result = move_function(upload_bin_path, filename)
+        filetype = upload_info['MetaData']['filetype']
+        result = move_function(upload_bin_path, filename, filetype)
     os.remove(upload_info_path)
     return result
diff --git a/django_airavata/apps/api/views.py b/django_airavata/apps/api/views.py
index c5d22cb..174d3b1 100644
--- a/django_airavata/apps/api/views.py
+++ b/django_airavata/apps/api/views.py
@@ -50,6 +50,7 @@
     models,
     output_views,
     serializers,
+    signals,
     thrift_utils,
     tus,
     view_utils
@@ -87,6 +88,8 @@
         group_id = self.request.profile_service['group_manager'].createGroup(
             self.authz_token, group)
         group.id = group_id
+        users_added_to_group = set(group.members) - {group.ownerId}
+        self._send_users_added_to_group(users_added_to_group, group)
 
     def perform_update(self, serializer):
         group = serializer.save()
@@ -94,6 +97,7 @@
         if len(group._added_members) > 0:
             group_manager_client.addUsersToGroup(
                 self.authz_token, group._added_members, group.id)
+            self._send_users_added_to_group(group._added_members, group)
         if len(group._removed_members) > 0:
             group_manager_client.removeUsersFromGroup(
                 self.authz_token, group._removed_members, group.id)
@@ -110,6 +114,17 @@
         group_manager_client.deleteGroup(
             self.authz_token, group.id, group.ownerId)
 
+    def _send_users_added_to_group(self, internal_user_ids, group):
+        for internal_user_id in internal_user_ids:
+            user_id, gateway_id = internal_user_id.rsplit("@", maxsplit=1)
+            user_profile = self.request.profile_service['user_profile'].getUserProfileById(
+                self.authz_token, user_id, gateway_id)
+            signals.user_added_to_group.send(
+                sender=self.__class__,
+                user=user_profile,
+                groups=[group],
+                request=self.request)
+
 
 class ProjectViewSet(APIBackedViewSet):
     serializer_class = serializers.ProjectSerializer
@@ -649,6 +664,7 @@
 
     def perform_create(self, serializer):
         application_interface = serializer.save()
+        self._update_input_metadata(application_interface)
         log.debug("application_interface: {}".format(application_interface))
         app_interface_id = self.request.airavata_client.registerApplicationInterface(
             self.authz_token, self.gateway_id, application_interface)
@@ -656,6 +672,7 @@
 
     def perform_update(self, serializer):
         application_interface = serializer.save()
+        self._update_input_metadata(application_interface)
         self.request.airavata_client.updateApplicationInterface(
             self.authz_token,
             application_interface.applicationInterfaceId,
@@ -665,6 +682,21 @@
         self.request.airavata_client.deleteApplicationInterface(
             self.authz_token, instance.applicationInterfaceId)
 
+    def _update_input_metadata(self, app_interface):
+        for app_input in app_interface.applicationInputs:
+            if app_input.metaData:
+                metadata = json.loads(app_input.metaData)
+                # Automatically add {showOptions: {isRequired: true/false}} to
+                # toggle isRequired on hidden/shown inputs
+                if ("editor" in metadata and
+                    "dependencies" in metadata["editor"] and
+                        "show" in metadata["editor"]["dependencies"]):
+                    if "showOptions" not in metadata["editor"]["dependencies"]:
+                        metadata["editor"]["dependencies"]["showOptions"] = {}
+                    o = metadata["editor"]["dependencies"]["showOptions"]
+                    o["isRequired"] = app_input.isRequired
+                    app_input.metaData = json.dumps(metadata)
+
     @detail_route()
     def compute_resources(self, request, app_interface_id):
         compute_resources = request.airavata_client.getAvailableAppInterfaceComputeResources(
@@ -911,7 +943,7 @@
     try:
         input_file = request.FILES['file']
         data_product = data_products_helper.save_input_file_upload(
-            request, input_file)
+            request, input_file, content_type=input_file.content_type)
         serializer = serializers.DataProductSerializer(
             data_product, context={'request': request})
         return JsonResponse({'uploaded': True,
@@ -927,15 +959,15 @@
 def tus_upload_finish(request):
     uploadURL = request.POST['uploadURL']
 
-    def move_input_file(file_path, file_name):
+    def move_input_file(file_path, file_name, file_type):
         return data_products_helper.move_input_file_upload_from_filepath(
-            request, file_path, name=file_name)
+            request, file_path, name=file_name, content_type=file_type)
     try:
         data_product = tus.move_tus_upload(uploadURL, move_input_file)
         serializer = serializers.DataProductSerializer(
             data_product, context={'request': request})
         return JsonResponse({'uploaded': True,
-                            'data-product': serializer.data})
+                             'data-product': serializer.data})
     except Exception as e:
         return exceptions.generic_json_exception_response(e, status=400)
 
@@ -960,12 +992,14 @@
                     .format(data_product_uri), exc_info=True)
         raise Http404("data product does not exist") from e
     try:
-        data_file = data_products_helper.open(request, data_product)
+        data_file = data_products_helper.open_file(request, data_product)
         response = FileResponse(data_file, content_type=mime_type)
         file_name = os.path.basename(data_file.name)
         if mime_type == 'application/octet-stream' or force_download:
             response['Content-Disposition'] = ('attachment; filename="{}"'
                                                .format(file_name))
+        else:
+            response['Content-Disposition'] = f'filename="{file_name}"'
         return response
     except ObjectDoesNotExist as e:
         raise Http404(str(e)) from e
@@ -1474,14 +1508,15 @@
         if 'file' in request.FILES:
             user_file = request.FILES['file']
             data_product = data_products_helper.save(
-                request, path, user_file)
+                request, path, user_file, content_type=user_file.content_type)
         # Handle a tus upload
         elif 'uploadURL' in request.POST:
             uploadURL = request.POST['uploadURL']
 
-            def move_file(file_path, file_name):
+            def move_file(file_path, file_name, file_type):
                 return data_products_helper.move_from_filepath(
-                    request, file_path, path, name=file_name)
+                    request, file_path, path, name=file_name,
+                    content_type=file_type)
             data_product = tus.move_tus_upload(uploadURL, move_file)
         return self._create_response(request, path, uploaded=data_product)
 
@@ -1596,10 +1631,24 @@
     def perform_update(self, serializer):
         managed_user_profile = serializer.save()
         group_manager_client = self.request.profile_service['group_manager']
+        user_profile_client = self.request.profile_service['user_profile']
         user_id = managed_user_profile['airavataInternalUserId']
+        added_groups = []
         for group_id in managed_user_profile['_added_group_ids']:
+            group = group_manager_client.getGroup(self.authz_token, group_id)
             group_manager_client.addUsersToGroup(
                 self.authz_token, [user_id], group_id)
+            added_groups.append(group)
+        if len(added_groups) > 0:
+            user_profile = user_profile_client.getUserProfileById(
+                self.authz_token,
+                managed_user_profile['userId'],
+                settings.GATEWAY_ID)
+            signals.user_added_to_group.send(
+                sender=self.__class__,
+                user=user_profile,
+                groups=added_groups,
+                request=self.request)
         for group_id in managed_user_profile['_removed_group_ids']:
             group_manager_client.removeUsersFromGroup(
                 self.authz_token, [user_id], group_id)
diff --git a/django_airavata/apps/auth/apps.py b/django_airavata/apps/auth/apps.py
index 1e103a4..cca02f9 100644
--- a/django_airavata/apps/auth/apps.py
+++ b/django_airavata/apps/auth/apps.py
@@ -4,3 +4,6 @@
 class AuthConfig(AppConfig):
     name = 'django_airavata.apps.auth'
     label = 'django_airavata_auth'
+
+    def ready(self):
+        from . import signals  # noqa
diff --git a/django_airavata/apps/auth/migrations/0005_auto_20191211_2011.py b/django_airavata/apps/auth/migrations/0005_auto_20191211_2011.py
new file mode 100644
index 0000000..eeb4b99
--- /dev/null
+++ b/django_airavata/apps/auth/migrations/0005_auto_20191211_2011.py
@@ -0,0 +1,66 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.21 on 2019-12-11 20:11
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+from django_airavata.apps.auth.models import USER_ADDED_TO_GROUP_TEMPLATE
+
+
+def default_templates(apps, schema_editor):
+
+    EmailTemplate = apps.get_model("django_airavata_auth", "EmailTemplate")
+    user_added_to_group_template = EmailTemplate(
+        template_type=USER_ADDED_TO_GROUP_TEMPLATE,
+        subject="You've been added to group{{ group_names|length|pluralize }} [{{group_names|join:'] and ['}}] in {{portal_title}}",
+        body="""
+        <p>
+        Dear {{first_name}} {{last_name}},
+        </p>
+
+        <p>
+        Your user account (username {{username}}) has been added to the
+        group{{ group_names|length|pluralize }} {{group_names|join:' and '}}.
+        {{portal_title}} uses groups to share applications and experiments.
+        </p>
+
+        <p>
+        You may have access to additional applications now that you are a
+        member of {{group_names|join:' and '}}. To check what applications you
+        have access to, please check: <a href="{{dashboard_url}}">{{dashboard_url}}</a>.
+        </p>
+
+        <p>
+        You may also have access to additional experiments. To check what
+        experiments you have access to, please check: <a
+        href="{{experiments_url}}">{{experiments_url}}</a>.
+        </p>
+
+        <p>
+        Please let us know if you have any questions.  Thanks.
+        </p>
+        """.strip())
+    user_added_to_group_template.save()
+
+
+def delete_default_templates(apps, schema_editor):
+    EmailTemplate = apps.get_model("django_airavata_auth", "EmailTemplate")
+    EmailTemplate.objects.filter(
+        template_type=USER_ADDED_TO_GROUP_TEMPLATE).delete()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('django_airavata_auth', '0004_password_reset_request'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='emailtemplate',
+            name='template_type',
+            field=models.IntegerField(choices=[(1, 'Verify Email Template'), (2, 'New User Email Template'), (3, 'Password Reset Email Template'), (4, 'User Added to Group Template')], primary_key=True, serialize=False),
+        ),
+        migrations.RunPython(default_templates,
+                             reverse_code=delete_default_templates)
+    ]
diff --git a/django_airavata/apps/auth/models.py b/django_airavata/apps/auth/models.py
index 5e4d109..cfea8c1 100644
--- a/django_airavata/apps/auth/models.py
+++ b/django_airavata/apps/auth/models.py
@@ -5,6 +5,7 @@
 VERIFY_EMAIL_TEMPLATE = 1
 NEW_USER_EMAIL_TEMPLATE = 2
 PASSWORD_RESET_EMAIL_TEMPLATE = 3
+USER_ADDED_TO_GROUP_TEMPLATE = 4
 
 
 class EmailVerification(models.Model):
@@ -20,6 +21,7 @@
         (VERIFY_EMAIL_TEMPLATE, 'Verify Email Template'),
         (NEW_USER_EMAIL_TEMPLATE, 'New User Email Template'),
         (PASSWORD_RESET_EMAIL_TEMPLATE, 'Password Reset Email Template'),
+        (USER_ADDED_TO_GROUP_TEMPLATE, 'User Added to Group Template'),
     )
     template_type = models.IntegerField(
         primary_key=True, choices=TEMPLATE_TYPE_CHOICES)
diff --git a/django_airavata/apps/auth/signals.py b/django_airavata/apps/auth/signals.py
new file mode 100644
index 0000000..32f3cb0
--- /dev/null
+++ b/django_airavata/apps/auth/signals.py
@@ -0,0 +1,25 @@
+from django.conf import settings
+from django.dispatch import receiver
+from django.shortcuts import reverse
+from django.template import Context
+
+from django_airavata.apps.api.signals import user_added_to_group
+
+from . import models, utils
+
+
+@receiver(user_added_to_group, dispatch_uid="auth_email_user_added_to_group")
+def email_user_added_to_group(sender, user, groups, request, **kwargs):
+    context = Context({
+        "email": user.emails[0],
+        "first_name": user.firstName,
+        "last_name": user.lastName,
+        "username": user.userId,
+        "portal_title": settings.PORTAL_TITLE,
+        "dashboard_url": request.build_absolute_uri(
+            reverse("django_airavata_workspace:dashboard")),
+        "experiments_url": request.build_absolute_uri(
+            reverse("django_airavata_workspace:experiments")),
+        "group_names": [g.name for g in groups]
+    })
+    utils.send_email_to_user(models.USER_ADDED_TO_GROUP_TEMPLATE, context)
diff --git a/django_airavata/apps/auth/tests.py b/django_airavata/apps/auth/tests.py
index 4929020..2dbcc6a 100644
--- a/django_airavata/apps/auth/tests.py
+++ b/django_airavata/apps/auth/tests.py
@@ -1,2 +1,82 @@
+from django.core import mail
+from django.shortcuts import reverse
+from django.test import RequestFactory, TestCase, override_settings
 
-# Create your tests here.
+from airavata.model.group.ttypes import GroupModel
+from airavata.model.user.ttypes import UserProfile
+from django_airavata.apps.api.signals import user_added_to_group
+
+from . import signals  # noqa
+
+GATEWAY_ID = "test-gateway"
+SERVER_EMAIL = "admin@test-gateway.com"
+PORTAL_TITLE = "Test Gateway"
+PORTAL_ADMINS = [('Portal Admin', 'admin@test-gateway.com')]
+
+
+@override_settings(
+    GATEWAY_ID=GATEWAY_ID,
+    SERVER_EMAIL=SERVER_EMAIL,
+    PORTAL_TITLE=PORTAL_TITLE,
+    PORTAL_ADMINS=PORTAL_ADMINS
+)
+class EmailUserAddedToGroupSignalReceiverTests(TestCase):
+
+    def setUp(self):
+        factory = RequestFactory()
+        self.request = factory.get("/")
+        self.user = UserProfile(
+            airavataInternalUserId=f"testuser@{GATEWAY_ID}",
+            userId="testuser",
+            gatewayId=GATEWAY_ID,
+            emails=["testuser@example.com"],
+            firstName="Test",
+            lastName="User")
+
+    def test(self):
+        group = GroupModel(id="abc123", name="Test Group")
+        user_added_to_group.send(None,
+                                 user=self.user,
+                                 groups=[group],
+                                 request=self.request)
+        self.assertEqual(len(mail.outbox), 1)
+        msg = mail.outbox[0]
+        self._assert_common_email_attributes(msg)
+        self.assertEqual(msg.subject,
+                         f"You've been added to group "
+                         f"[{group.name}] in {PORTAL_TITLE}")
+
+    def test_multiple_groups(self):
+        group1 = GroupModel(id="abc123", name="Test Group")
+        group2 = GroupModel(id="group2", name="Group 2")
+        user_added_to_group.send(None,
+                                 user=self.user,
+                                 groups=[group1, group2],
+                                 request=self.request)
+        self.assertEqual(len(mail.outbox), 1)
+        msg = mail.outbox[0]
+        self._assert_common_email_attributes(msg)
+        self.assertEqual(msg.subject,
+                         f"You've been added to groups "
+                         f"[{group1.name}] and [{group2.name}] "
+                         f"in {PORTAL_TITLE}")
+        self.assertIn("groups Test Group and Group 2", msg.body)
+
+    def _assert_common_email_attributes(self, msg):
+        self.assertEqual(msg.from_email,
+                         f"\"{PORTAL_TITLE}\" <{SERVER_EMAIL}>")
+        self.assertEqual(
+            msg.reply_to,
+            [f"\"{PORTAL_ADMINS[0][0]}\" <{PORTAL_ADMINS[0][1]}>"])
+        self.assertSequenceEqual(
+            msg.to, [f"\"{self.user.firstName} {self.user.lastName}\" "
+                     f"<{self.user.emails[0]}>"])
+        self.assertIn(
+            self.request.build_absolute_uri(
+                reverse("django_airavata_workspace:dashboard")),
+            msg.body)
+        self.assertIn(
+            self.request.build_absolute_uri(
+                reverse("django_airavata_workspace:experiments")),
+            msg.body)
+        self.assertIn(self.user.userId, msg.body)
diff --git a/django_airavata/apps/auth/utils.py b/django_airavata/apps/auth/utils.py
index e8ce6d0..0c35a3b 100644
--- a/django_airavata/apps/auth/utils.py
+++ b/django_airavata/apps/auth/utils.py
@@ -100,3 +100,21 @@
                        to=[a[1] for a in settings.PORTAL_ADMINS])
     msg.content_subtype = 'html'
     msg.send()
+
+
+def send_email_to_user(template_id, context):
+    email_template = models.EmailTemplate.objects.get(pk=template_id)
+    subject = Template(email_template.subject).render(context)
+    body = Template(email_template.body).render(context)
+    msg = EmailMessage(
+        subject=subject,
+        body=body,
+        from_email="\"{}\" <{}>".format(settings.PORTAL_TITLE,
+                                        settings.SERVER_EMAIL),
+        to=["\"{} {}\" <{}>".format(context['first_name'],
+                                    context['last_name'],
+                                    context['email'])],
+        reply_to=[f"\"{a[0]}\" <{a[1]}>" for a in settings.PORTAL_ADMINS]
+    )
+    msg.content_subtype = 'html'
+    msg.send()
diff --git a/django_airavata/apps/auth/views.py b/django_airavata/apps/auth/views.py
index 47fddb9..5da2a2e 100644
--- a/django_airavata/apps/auth/views.py
+++ b/django_airavata/apps/auth/views.py
@@ -7,12 +7,12 @@
 from django.contrib import messages
 from django.contrib.auth import authenticate, login, logout
 from django.core.exceptions import ObjectDoesNotExist
-from django.core.mail import EmailMessage
 from django.forms import ValidationError
 from django.http import HttpResponseBadRequest, JsonResponse
 from django.shortcuts import redirect, render, resolve_url
-from django.template import Context, Template
+from django.template import Context
 from django.urls import reverse
+from django.views.decorators.debug import sensitive_variables
 from requests_oauthlib import OAuth2Session
 
 from . import forms, iam_admin_client, models, utils
@@ -69,6 +69,7 @@
         raise Exception("idp_alias is not valid")
 
 
+@sensitive_variables('password')
 def handle_login(request):
     username = request.POST['username']
     password = request.POST['password']
@@ -151,6 +152,7 @@
     })
 
 
+@sensitive_variables('password')
 def create_account(request):
     if request.method == 'POST':
         form = forms.CreateAccountForm(request.POST)
@@ -305,7 +307,7 @@
         "portal_title": settings.PORTAL_TITLE,
         "url": verification_uri,
     })
-    _send_email_to_user(models.VERIFY_EMAIL_TEMPLATE, context)
+    utils.send_email_to_user(models.VERIFY_EMAIL_TEMPLATE, context)
 
 
 def forgot_password(request):
@@ -371,9 +373,10 @@
         "portal_title": settings.PORTAL_TITLE,
         "url": verification_uri,
     })
-    _send_email_to_user(models.PASSWORD_RESET_EMAIL_TEMPLATE, context)
+    utils.send_email_to_user(models.PASSWORD_RESET_EMAIL_TEMPLATE, context)
 
 
+@sensitive_variables('password')
 def reset_password(request, code):
     try:
         password_reset_request = models.PasswordResetRequest.objects.get(
@@ -423,23 +426,6 @@
     })
 
 
-def _send_email_to_user(template_id, context):
-    email_template = models.EmailTemplate.objects.get(
-        pk=template_id)
-    subject = Template(email_template.subject).render(context)
-    body = Template(email_template.body).render(context)
-    msg = EmailMessage(
-        subject=subject,
-        body=body,
-        from_email="{} <{}>".format(settings.PORTAL_TITLE,
-                                    settings.SERVER_EMAIL),
-        to=["{} {} <{}>".format(context['first_name'],
-                                context['last_name'],
-                                context['email'])])
-    msg.content_subtype = 'html'
-    msg.send()
-
-
 def login_desktop(request):
     context = {
         'options': settings.AUTHENTICATION_OPTIONS,
diff --git a/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/experiment/QueueSettingsEditor.vue b/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/experiment/QueueSettingsEditor.vue
index b8af3e9..84087f6 100644
--- a/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/experiment/QueueSettingsEditor.vue
+++ b/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/experiment/QueueSettingsEditor.vue
@@ -1,29 +1,35 @@
 <template>
-
   <div>
     <div class="row">
       <div class="col">
-        <div
-          class="card border-default"
-          :class="{ 'border-danger': !valid }"
-        >
+        <div class="card border-default" :class="{ 'border-danger': !valid }">
           <b-link
             @click="showConfiguration = !showConfiguration"
             class="card-link text-dark"
           >
             <div class="card-body">
-              <h5 class="card-title mb-4">Settings for queue {{ localComputationalResourceScheduling.queueName }}</h5>
+              <h5 class="card-title mb-4">
+                Settings for queue
+                {{ localComputationalResourceScheduling.queueName }}
+              </h5>
               <div class="row">
                 <div class="col">
-                  <h3 class="h5 mb-0">{{ localComputationalResourceScheduling.nodeCount }}</h3>
+                  <h3 class="h5 mb-0">
+                    {{ localComputationalResourceScheduling.nodeCount }}
+                  </h3>
                   <span class="text-muted text-uppercase">NODE COUNT</span>
                 </div>
                 <div class="col">
-                  <h3 class="h5 mb-0">{{ localComputationalResourceScheduling.totalCPUCount }}</h3>
+                  <h3 class="h5 mb-0">
+                    {{ localComputationalResourceScheduling.totalCPUCount }}
+                  </h3>
                   <span class="text-muted text-uppercase">CORE COUNT</span>
                 </div>
                 <div class="col">
-                  <h3 class="h5 mb-0">{{ localComputationalResourceScheduling.wallTimeLimit }} minutes</h3>
+                  <h3 class="h5 mb-0">
+                    {{ localComputationalResourceScheduling.wallTimeLimit }}
+                    minutes
+                  </h3>
                   <span class="text-muted text-uppercase">TIME LIMIT</span>
                 </div>
               </div>
@@ -51,7 +57,7 @@
             >
             </b-form-select>
             <div slot="description">
-              {{ selectedQueueDefault.queueDescription }}
+              {{ queueDescription }}
             </div>
           </b-form-group>
           <b-form-group
@@ -71,10 +77,7 @@
             >
             </b-form-input>
             <div slot="description">
-              <i
-                class="fa fa-info-circle"
-                aria-hidden="true"
-              ></i>
+              <i class="fa fa-info-circle" aria-hidden="true"></i>
               Max Allowed Nodes = {{ maxNodes }}
             </div>
           </b-form-group>
@@ -95,10 +98,7 @@
             >
             </b-form-input>
             <div slot="description">
-              <i
-                class="fa fa-info-circle"
-                aria-hidden="true"
-              ></i>
+              <i class="fa fa-info-circle" aria-hidden="true"></i>
               Max Allowed Cores = {{ maxCPUCount }}
             </div>
           </b-form-group>
@@ -121,10 +121,7 @@
               </b-form-input>
             </b-input-group>
             <div slot="description">
-              <i
-                class="fa fa-info-circle"
-                aria-hidden="true"
-              ></i>
+              <i class="fa fa-info-circle" aria-hidden="true"></i>
               Max Allowed Wall Time = {{ maxWalltime }} minutes
             </div>
           </b-form-group>
@@ -134,11 +131,9 @@
               href="#"
               @click.prevent="showConfiguration = false"
             >
-              <i
-                class="fa fa-times text-secondary"
-                aria-hidden="true"
-              ></i>
-              Hide Settings</a>
+              <i class="fa fa-times text-secondary" aria-hidden="true"></i>
+              Hide Settings</a
+            >
           </div>
         </div>
       </div>
@@ -172,8 +167,8 @@
   },
   data() {
     return {
-      queueDefaults: [],
-      showConfiguration: false
+      showConfiguration: false,
+      appDeploymentQueues: null
     };
   },
   computed: {
@@ -197,6 +192,9 @@
       );
     },
     maxCPUCount: function() {
+      if (!this.selectedQueueDefault) {
+        return 0;
+      }
       const batchQueueResourcePolicy = this.getBatchQueueResourcePolicy(
         this.selectedQueueDefault.queueName
       );
@@ -209,6 +207,9 @@
       return this.selectedQueueDefault.maxProcessors;
     },
     maxNodes: function() {
+      if (!this.selectedQueueDefault) {
+        return 0;
+      }
       const batchQueueResourcePolicy = this.getBatchQueueResourcePolicy(
         this.selectedQueueDefault.queueName
       );
@@ -221,6 +222,9 @@
       return this.selectedQueueDefault.maxNodes;
     },
     maxWalltime: function() {
+      if (!this.selectedQueueDefault) {
+        return 0;
+      }
       const batchQueueResourcePolicy = this.getBatchQueueResourcePolicy(
         this.selectedQueueDefault.queueName
       );
@@ -232,6 +236,27 @@
       }
       return this.selectedQueueDefault.maxRunTime;
     },
+    queueDefaults() {
+      return this.appDeploymentQueues
+        ? this.appDeploymentQueues
+            .filter(q => this.isQueueInComputeResourcePolicy(q.queueName))
+            .sort((a, b) => {
+              // Sort default first, then by alphabetically by name
+              if (a.isDefaultQueue) {
+                return -1;
+              } else if (b.isDefaultQueue) {
+                return 1;
+              } else {
+                return a.queueName.localeCompare(b.queueName);
+              }
+            })
+        : [];
+    },
+    queueDescription() {
+      return this.selectedQueueDefault
+        ? this.selectedQueueDefault.queueDescription
+        : null;
+    },
     validation() {
       // Don't run validation if we don't have selectedQueueDefault
       if (!this.selectedQueueDefault) {
@@ -262,41 +287,28 @@
         this.$emit("valid");
       }
     },
-    loadQueueDefaults: function(updateQueueSettings) {
+    loadAppDeploymentQueues() {
       return services.ApplicationDeploymentService.getQueues({
         lookup: this.appDeploymentId
-      }).then(queueDefaults => {
-        // Sort queue defaults
-        this.queueDefaults = queueDefaults
-          .filter(q => this.isQueueInComputeResourcePolicy(q.queueName))
-          .sort((a, b) => {
-            // Sort default first, then by alphabetically by name
-            if (a.isDefaultQueue) {
-              return -1;
-            } else if (b.isDefaultQueue) {
-              return 1;
-            } else {
-              return a.queueName.localeCompare(b.queueName);
-            }
-          });
+      }).then(queueDefaults => (this.appDeploymentQueues = queueDefaults));
+    },
+    setDefaultQueue() {
+      if (this.queueDefaults.length === 0) {
+        return;
+      }
+      const defaultQueue = this.queueDefaults[0];
 
-        if (updateQueueSettings) {
-          // Find the default queue and apply it's settings
-          const defaultQueue = this.queueDefaults[0];
-
-          this.localComputationalResourceScheduling.queueName =
-            defaultQueue.queueName;
-          this.localComputationalResourceScheduling.totalCPUCount = this.getDefaultCPUCount(
-            defaultQueue
-          );
-          this.localComputationalResourceScheduling.nodeCount = this.getDefaultNodeCount(
-            defaultQueue
-          );
-          this.localComputationalResourceScheduling.wallTimeLimit = this.getDefaultWalltime(
-            defaultQueue
-          );
-        }
-      });
+      this.localComputationalResourceScheduling.queueName =
+        defaultQueue.queueName;
+      this.localComputationalResourceScheduling.totalCPUCount = this.getDefaultCPUCount(
+        defaultQueue
+      );
+      this.localComputationalResourceScheduling.nodeCount = this.getDefaultNodeCount(
+        defaultQueue
+      );
+      this.localComputationalResourceScheduling.wallTimeLimit = this.getDefaultWalltime(
+        defaultQueue
+      );
     },
     isQueueInComputeResourcePolicy: function(queueName) {
       if (!this.computeResourcePolicy) {
@@ -363,19 +375,27 @@
     }
   },
   watch: {
-    appDeploymentId: function() {
-      this.loadQueueDefaults(true);
+    appDeploymentId() {
+      this.loadAppDeploymentQueues().then(() => this.setDefaultQueue());
+    },
+    computeResourcePolicy() {
+      this.setDefaultQueue();
+    },
+    batchQueueResourcePolicies() {
+      this.setDefaultQueue();
     }
   },
   mounted: function() {
-    // For brand new queue settings (no queueName specified) load the default
-    // queue and its default values and apply them
-    const updateQueueSettings = !this.value.queueName;
-    this.loadQueueDefaults(updateQueueSettings).then(() => this.validate());
+    this.loadAppDeploymentQueues().then(() => {
+      // For brand new queue settings (no queueName specified) load the default
+      // queue and its default values and apply them
+      if (!this.value.queueName) {
+        this.setDefaultQueue();
+      }
+    });
     this.$on("input", () => this.validate());
   }
 };
 </script>
 
-<style>
-</style>
+<style></style>
diff --git a/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/experiment/input-editors/FileInputEditor.vue b/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/experiment/input-editors/FileInputEditor.vue
index 52c0c39..3dcf631 100644
--- a/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/experiment/input-editors/FileInputEditor.vue
+++ b/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/experiment/input-editors/FileInputEditor.vue
@@ -8,8 +8,9 @@
         class="mr-auto"
         :data-product="dataProduct"
         :input-file="true"
+        :open-in-new-window="true"
       />
-      <b-link @click="viewFile">
+      <b-link @click="viewFile" v-if="isViewable">
         View File <i class="fa fa-eye"></i>
         <span class="sr-only">View file</span>
       </b-link>
@@ -85,6 +86,9 @@
       } else {
         return [];
       }
+    },
+    isViewable() {
+      return this.dataProduct.isText;
     }
   },
   data() {
diff --git a/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/storage/UserStoragePathViewer.vue b/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/storage/UserStoragePathViewer.vue
index 3351fd1..627935f 100644
--- a/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/storage/UserStoragePathViewer.vue
+++ b/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/storage/UserStoragePathViewer.vue
@@ -22,7 +22,7 @@
           v-else
           :href="data.item.downloadURL"
           :target="downloadTarget"
-        > <i class="fa fa-download"></i> {{ data.item.name }}</b-link>
+        > {{ data.item.name }}</b-link>
       </template>
       <template
         slot="createdTimestamp"
diff --git a/django_airavata/context_processors.py b/django_airavata/context_processors.py
index e98666f..866bd63 100644
--- a/django_airavata/context_processors.py
+++ b/django_airavata/context_processors.py
@@ -158,3 +158,9 @@
 def resolver_match(request):
     """Put resolver_match (ResolverMatch instance) into the context."""
     return {'resolver_match': request.resolver_match}
+
+
+def google_analytics_tracking_id(request):
+    """Put the Google Analytics tracking id into context."""
+    return {'ga_tracking_id':
+            getattr(settings, 'GOOGLE_ANALYTICS_TRACKING_ID', None)}
diff --git a/django_airavata/settings.py b/django_airavata/settings.py
index 9efe750..2b023a4 100644
--- a/django_airavata/settings.py
+++ b/django_airavata/settings.py
@@ -144,6 +144,7 @@
                 'django_airavata.context_processors.custom_app_registry',
                 'django_airavata.context_processors.get_notifications',
                 'django_airavata.context_processors.user_session_data',
+                'django_airavata.context_processors.google_analytics_tracking_id',
                 # 'django_airavata.context_processors.resolver_match',
             ],
         },
@@ -235,6 +236,7 @@
     ),
     'EXCEPTION_HANDLER':
         'django_airavata.apps.api.exceptions.custom_exception_handler',
+    'TEST_REQUEST_DEFAULT_FORMAT': 'json',
 }
 
 AUTHENTICATION_BACKENDS = [
diff --git a/django_airavata/settings_local.py.sample b/django_airavata/settings_local.py.sample
index 65358f4..da98351 100644
--- a/django_airavata/settings_local.py.sample
+++ b/django_airavata/settings_local.py.sample
@@ -106,6 +106,10 @@
 # Legacy (PGA) Portal link - uncomment to provide a link to the legacy portal
 # PGA_URL = '...'
 
+# Google Analytics Tracking ID ("UA-XXXXXXXX-X"). If this setting is set, then
+# Google Analytics tracking will be added to all pages.
+# GOOGLE_ANALYTICS_TRACKING_ID = '...'
+
 # Logging configuration. Uncomment following to override default log configuration
 # LOGGING = {
 #     'version': 1,
diff --git a/django_airavata/static/common/js/components/DataProductViewer.vue b/django_airavata/static/common/js/components/DataProductViewer.vue
index 52f35f7..788ca79 100644
--- a/django_airavata/static/common/js/components/DataProductViewer.vue
+++ b/django_airavata/static/common/js/components/DataProductViewer.vue
@@ -1,8 +1,7 @@
 <template>
 
   <span v-if="downloadURL">
-    <a :href="downloadURL" class="action-link">
-      <i class="fa fa-download"></i>
+    <a :href="downloadURL" class="action-link" :target="linkTarget">
       {{ filename }}
     </a>
   </span>
@@ -24,6 +23,10 @@
     },
     mimeType: {
       type: String
+    },
+    openInNewWindow: {
+      type: Boolean,
+      default: false
     }
   },
   computed: {
@@ -45,6 +48,9 @@
       } else {
         return this.dataProduct.downloadURL;
       }
+    },
+    linkTarget() {
+      return this.openInNewWindow ? "_blank": "_self";
     }
   }
 };
diff --git a/django_airavata/templates/base.html b/django_airavata/templates/base.html
index a3e3266..09778e1 100644
--- a/django_airavata/templates/base.html
+++ b/django_airavata/templates/base.html
@@ -4,6 +4,8 @@
 <!DOCTYPE html>
 
 <head>
+  {% include "./django_airavata/google_analytics.html" %}
+
   {% render_bundle 'chunk-vendors' 'css' 'COMMON' %}
   {% render_bundle 'app' 'css' 'COMMON' %}
   {% block css %}
diff --git a/django_airavata/templates/django_airavata/google_analytics.html b/django_airavata/templates/django_airavata/google_analytics.html
new file mode 100644
index 0000000..ee990ca
--- /dev/null
+++ b/django_airavata/templates/django_airavata/google_analytics.html
@@ -0,0 +1,11 @@
+{% if ga_tracking_id %}
+<!-- Global site tag (gtag.js) - Google Analytics -->
+<script async src="https://www.googletagmanager.com/gtag/js?id={{ ga_tracking_id }}"></script>
+<script>
+  window.dataLayer = window.dataLayer || [];
+  function gtag(){dataLayer.push(arguments);}
+  gtag('js', new Date());
+
+  gtag('config', '{{ ga_tracking_id }}');
+</script>
+{% endif %}
diff --git a/django_airavata/templates/includes/head.html b/django_airavata/templates/includes/head.html
index 2e3febe..2b605c6 100644
--- a/django_airavata/templates/includes/head.html
+++ b/django_airavata/templates/includes/head.html
@@ -18,6 +18,8 @@
     <meta name="description" content="{% if self.search_description %}{{ self.search_description }}{% endif %}">
     <meta name="viewport" content="width=device-width, initial-scale=1">
 
+    {% include "../django_airavata/google_analytics.html" %}
+
     <!-- Bootstrap Core + Font awesome CSS (see common/js/cms.js) -->
     {% render_bundle 'chunk-vendors' 'css' 'COMMON' %}