Add swift object copy function

Current Horizon does not have object copy page for swift.
This blueprint is for adding above one as Angular application.
- One row action is added as "Copy" menu
- User can copy which bytes > 0
  Because bytes = 0 means, true 0 byte object or separated file.
  Copying of separated object is not allowed in Swift API.

Change-Id: I60d731f79e5c9ab55fd2a73b7687b47723fe901f
Implements: blueprint swift-object-copy-function
This commit is contained in:
Keiichi Hikita 2018-02-08 11:43:33 +09:00
parent d09170e02a
commit 60a78e1958
9 changed files with 678 additions and 2 deletions

View File

@ -276,11 +276,14 @@ def swift_copy_object(request, orig_container_name, orig_object_name,
headers = {"X-Copy-From": FOLDER_DELIMITER.join([orig_container_name,
orig_object_name])}
return swift_api(request).put_object(new_container_name,
etag = swift_api(request).put_object(new_container_name,
new_object_name,
None,
headers=headers)
obj_info = {'name': new_object_name, 'etag': etag}
return StorageObject(obj_info, new_container_name)
@profiler.trace
def swift_upload_object(request, container_name, object_name,

View File

@ -0,0 +1,132 @@
/**
* Licensed 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.
*/
(function () {
'use strict';
/**
* @ngdoc directive
* @name check-copy-destination
* @element
* @description
* The `check-copy-destination` directive is used on an angular form
* element to verify whether a copy destination is valid or not.
*
* This directive is called if value of dest_container or dest_name is changed,
* then check following.
* - destination container correctly exists or not.
* - destination object does not exists.(To prevent over writeby mistake)
*/
angular
.module('horizon.dashboard.project.containers')
.directive('checkCopyDestination', CheckCopyDestination);
CheckCopyDestination.$inject = [
'horizon.app.core.openstack-service-api.swift',
'horizon.dashboard.project.containers.containers-model',
'$q'
];
function CheckCopyDestination(swiftAPI, model, $q) {
/**
* functions that is used from inside of directive.
* These function will return just exist or not as true or false.
*/
function checkContainer(container) {
var def = $q.defer();
swiftAPI
.getContainer(container, true)
.then(def.resolve, def.reject);
return def.promise;
}
function checkObject(container, object) {
var def = $q.defer();
swiftAPI
.getObjectDetails(container, object, true)
.then(def.resolve, def.reject);
return def.promise;
}
return {
restrict: 'A',
require: 'ngModel',
link: function(scope, element, attrs, ngModel) {
var ctrl = scope.ctrl;
scope.$watch(function() {
/**
* function that returns watching target.
* In this case, if either dest_container or dest_name is changed,
* second argument(this is also function) will be called.
* 3rd argment(true) means watch element of return value from 1st argument.
* (=not only reference to array)
*/
var destContainer = (ctrl.model.dest_container === undefined ||
ctrl.model.dest_container === null) ? "" : ctrl.model.dest_container;
var destName = (ctrl.model.dest_name === undefined ||
ctrl.model.dest_name === null) ? "" : ctrl.model.dest_name;
return [destContainer, destName];
}, function (value) {
/**
* These function set validity according to
* API execution result.
*
* If exepected value is "exist" like contianer,
* error will not be set if object (correctly) exist.
*
* If exepected value is "does not exist" like object,
* error will be set if object exist.
*/
var destContainer = value[0];
var destName = value[1];
ngModel.$setValidity('containerNotFound', true);
ngModel.$setValidity('objectExists', true);
if (destContainer === "") {
return value;
}
checkContainer(destContainer).then(
function success() {
ngModel.$setValidity('containerNotFound', true);
return value;
},
function error() {
ngModel.$setValidity('containerNotFound', false);
return;
}
);
if (destName !== "") {
checkObject(destContainer, destName).then(
function success() {
ngModel.$setValidity('objectExists', false);
return;
},
function error () {
ngModel.$setValidity('objectExists', true);
return value;
}
);
}
}, true);
}
};
}
})();

View File

@ -0,0 +1,135 @@
/**
* Licensed 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.
*/
(function() {
'use strict';
describe('horizon.dashboard.project.containers check-copy-destination.directive', function() {
beforeEach(module('horizon.app.core.openstack-service-api'));
beforeEach(module('horizon.dashboard.project.containers'));
beforeEach(module('horizon.framework'));
var $compile, $scope, model, element, swiftAPI, apiDeferred;
beforeEach(inject(function inject($injector, _$q_, _$rootScope_) {
$scope = _$rootScope_.$new();
$compile = $injector.get('$compile');
model = $injector.get('horizon.dashboard.project.containers.containers-model');
swiftAPI = $injector.get('horizon.app.core.openstack-service-api.swift');
apiDeferred = _$q_.defer();
spyOn(swiftAPI, 'getContainer').and.returnValue(apiDeferred.promise);
spyOn(swiftAPI, 'getObjectDetails').and.returnValue(apiDeferred.promise);
model.container = {name: 'spam'};
model.folder = 'ham';
$scope.ctrl = {
model: {
dest_container: '',
dest_name: ''
}
};
element = angular.element(
'<div ng-form="copyForm">' +
'<input id="id_dest_container" name="dest_container" type="text" ' +
'check-copy-destination ng-model="ctrl.model.dest_container" />' +
'<span id="id_span_dest_container" ' +
'ng-show="copyForm.dest_container.$error.containerNotFound">' +
'Container does not exist</span>' +
'' +
'<input id="id_dest_name" name="dest_name" type="text"' +
' check-copy-destination ng-model="ctrl.model.dest_name" />' +
'<span id="id_span_dest_name" ' +
'ng-show="copyForm.dest_name.$error.objectExists">' +
'Object already exists</span>' +
'</div>'
);
element = $compile(element)($scope);
$scope.$apply();
}));
it('should accept container name that exists', function test() {
// input field value
var containerName = 'someContainerName';
element.find('input#id_dest_container').val(containerName).trigger('input');
expect(swiftAPI.getContainer).toHaveBeenCalledWith(containerName, true);
// In case resolve() returned, it means specified container
// correctly exists. so error <span> for container should not be displayed.
apiDeferred.resolve();
$scope.$apply();
expect(element.find('#id_span_dest_container').hasClass('ng-hide')).toEqual(true);
});
it('should accept container name that dees not exist', function test() {
// input field value
var containerName = 'someContainerName';
element.find('input#id_dest_container').val(containerName).trigger('input');
expect(swiftAPI.getContainer).toHaveBeenCalledWith(containerName, true);
// In case reject() returned, it means specified container
// does not exist. so error <span> for container should be displayed.
apiDeferred.reject();
$scope.$apply();
expect(element.find('#id_span_dest_container').hasClass('ng-hide')).toEqual(false);
});
it('should not accept object already exists to prevent overwrite of object', function test() {
// input field value (destination container)
var containerName = 'someContainerName';
element.find('input#id_dest_container').val(containerName).trigger('input');
expect(swiftAPI.getContainer).toHaveBeenCalledWith(containerName, true);
// In case resolve() returned, it means specified container
// correctly exists. so error <span> for container should not be displayed.
apiDeferred.resolve();
$scope.$apply();
// input field value (destination object)
var objectName = 'someObjectName';
element.find('input#id_dest_name').val(objectName).trigger('input');
expect(swiftAPI.getObjectDetails).toHaveBeenCalledWith(containerName, objectName, true);
apiDeferred.resolve();
$scope.$apply();
// In case resolve() returned, it means specified object
// already exists. so error <span> for object should be displayed.
expect(element.find('#id_span_dest_name').hasClass('ng-hide')).toEqual(false);
});
it('should accept object name does not exist', function test() {
// input field value (destination container)
var containerName = 'someContainerName';
element.find('input#id_dest_container').val(containerName).trigger('input');
expect(swiftAPI.getContainer).toHaveBeenCalledWith(containerName, true);
// In case resolve() returned, it means specified container
// correctly exists. so error <span> for container should not be displayed.
apiDeferred.resolve();
$scope.$apply();
// input field value (destination object)
var objectName = 'someObjectName';
element.find('input#id_dest_name').val(objectName).trigger('input');
expect(swiftAPI.getObjectDetails).toHaveBeenCalledWith(containerName, objectName, true);
apiDeferred.reject();
$scope.$apply();
// In case resolve() returned, it means specified object
// already exists. so error <span> for object should be displayed.
expect(element.find('#id_span_dest_name').hasClass('ng-hide')).toEqual(false);
});
});
})();

View File

@ -0,0 +1,36 @@
/**
* Licensed 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.
*/
(function () {
'use strict';
angular
.module('horizon.dashboard.project.containers')
.controller(
'horizon.dashboard.project.containers.CopyObjectModalController',
CopyObjectModalController
);
CopyObjectModalController.$inject = [
'fileDetails'
];
function CopyObjectModalController(fileDetails) {
var ctrl = this;
ctrl.model = {
container: fileDetails.container,
path: fileDetails.path,
dest_container: "",
dest_name: ""
};
}
})();

View File

@ -0,0 +1,43 @@
/**
* Licensed 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.
*/
(function() {
'use strict';
describe('horizon.dashboard.project.containers copy-object controller', function() {
var controller, ctrl;
beforeEach(module('horizon.framework'));
beforeEach(module('horizon.dashboard.project.containers'));
beforeEach(module(function ($provide) {
$provide.value('fileDetails', {
container: 'spam',
path: 'ham/eggs'
});
}));
beforeEach(inject(function ($injector) {
controller = $injector.get('$controller');
ctrl = controller('horizon.dashboard.project.containers.CopyObjectModalController');
}));
it('should initialise the controller model when created', function test() {
expect(ctrl.model.container).toEqual('spam');
expect(ctrl.model.path).toEqual('ham/eggs');
expect(ctrl.model.dest_container).toEqual('');
expect(ctrl.model.dest_name).toEqual('');
});
});
})();

View File

@ -0,0 +1,106 @@
<div ng-form="copyForm">
<div class="modal-header">
<button type="button" class="close" ng-click="$dismiss()" aria-hidden="true" aria-label="Close">
<span aria-hidden="true" class="fa fa-times"></span>
</button>
<div class="h3 modal-title">
<translate>Copy Object: {$ ctrl.model.container $}/{$ ctrl.model.path $}</translate>
</div>
</div>
<div class="modal-body">
<div class="row">
<div class="col-sm-6">
<fieldset>
<!-- Destination Container -->
<div class="form-group required"
ng-class="{'has-error': copyForm.dest_container.$invalid && copyForm.dest_container.$dirty}">
<label class="control-label required" for="id_dest_container" translate>
Destination Container
</label>
<span class="hz-icon-required fa fa-asterisk"></span>
<div>
<input class="form-control"
id="id_dest_container"
name="dest_container"
type="text"
maxlength="255"
ng-model="ctrl.model.dest_container"
ng-required="true"
ng-model-options="{ debounce: 1000 }"
check-copy-destination
>
</div>
<span class="help-block"
ng-show="copyForm.dest_container.$error.required && copyForm.dest_container.$dirty"
translate>
This field is required.
</span>
<span class="help-block text-danger"
ng-show="copyForm.dest_container.$error.containerNotFound"
translate>
This container does not exist.
</span>
</div>
<!-- Destination Object -->
<div class="form-group required"
ng-class="{'has-error': copyForm.dest_name.$invalid && copyForm.dest_name.$dirty}">
<label class="control-label required" for="id_dest_name" translate>
Destination Object
</label>
<span class="hz-icon-required fa fa-asterisk"></span>
<div>
<input class="form-control"
id="id_dest_name"
name="dest_name"
type="text"
maxlength="255"
ng-model="ctrl.model.dest_name"
ng-required="true"
ng-model-options="{ debounce: 1000 }"
check-copy-destination
>
</div>
<span class="help-block"
ng-show="copyForm.dest_name.$error.required && copyForm.dest_name.$dirty"
translate>
This field is required.
</span>
<span class="help-block text-danger"
ng-show="copyForm.dest_name.$error.objectExists"
translate>
This name already exists.
</span>
</div>
</fieldset>
</div>
<div class="col-sm-6">
<p translate>
You can copy objects. You have to create destination container prior to copy.
</p>
<p translate>
You can specify folder by using '/' at destination object field.
For example, if you want to copy object under the folder named 'folder1', you need to specify destination object like 'folder1/[your object name]'.
</p>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-default" ng-click="$dismiss()">
<span class="fa fa-close"></span>
<translate>Cancel</translate>
</button>
<button class="btn btn-primary" ng-click="$close(ctrl.model)"
ng-disabled="copyForm.$invalid">
<span class="fa fa-upload"></span>
<translate>Copy Object</translate>
</button>
</div>
</div>

View File

@ -23,6 +23,7 @@
.factory('horizon.dashboard.project.containers.objects-actions.download', downloadService)
.factory('horizon.dashboard.project.containers.objects-actions.edit', editService)
.factory('horizon.dashboard.project.containers.objects-actions.view', viewService)
.factory('horizon.dashboard.project.containers.objects-actions.copy', copyService)
.run(registerActions);
registerActions.$inject = [
@ -32,6 +33,7 @@
'horizon.dashboard.project.containers.objects-actions.download',
'horizon.dashboard.project.containers.objects-actions.edit',
'horizon.dashboard.project.containers.objects-actions.view',
'horizon.dashboard.project.containers.objects-actions.copy',
'horizon.framework.util.i18n.gettext'
];
/**
@ -45,6 +47,7 @@
downloadService,
editService,
viewService,
copyService,
gettext
) {
registryService.getResourceType(objectResCode).itemActions
@ -60,6 +63,10 @@
service: editService,
template: {text: gettext('Edit')}
})
.append({
service: copyService,
template: {text: gettext('Copy')}
})
.append({
service: deleteService,
template: {text: gettext('Delete'), type: 'delete'}
@ -229,4 +236,93 @@
});
}
}
copyService.$inject = [
'horizon.app.core.openstack-service-api.swift',
'horizon.dashboard.project.containers.basePath',
'horizon.dashboard.project.containers.containerRoute',
'horizon.dashboard.project.containers.containers-model',
'horizon.framework.util.q.extensions',
'horizon.framework.widgets.modal-wait-spinner.service',
'horizon.framework.widgets.toast.service',
'$uibModal',
'$location'
];
function copyService(swiftAPI,
basePath,
containerRoute,
model,
$qExtensions,
modalWaitSpinnerService,
toastService,
$uibModal,
$location) {
return {
allowed: allowed,
perform: perform
};
function allowed(file) {
var objectCheck = file.is_object;
var capacityCheck = (file.bytes > 0);
var result = (objectCheck && capacityCheck);
return $qExtensions.booleanAsPromise(result);
}
function perform(file) {
var localSpec = {
backdrop: 'static',
keyboard: false,
controller: 'horizon.dashboard.project.containers.CopyObjectModalController as ctrl',
templateUrl: basePath + 'copy-object-modal.html',
resolve: {
fileDetails: function fileDetails() {
return {
path: file.path,
container: model.container.name
};
}
}
};
return $uibModal.open(localSpec).result.then(copyObjectCallback);
}
function copyObjectCallback(copyInfo) {
modalWaitSpinnerService.showModalSpinner(gettext("Copying"));
swiftAPI.copyObject(
model.container.name,
copyInfo.path,
copyInfo.dest_container,
copyInfo.dest_name
).then(success, error);
function success() {
var dstNameArray = copyInfo.dest_name.split('/');
dstNameArray.pop();
var dstFolder = dstNameArray.join('/');
modalWaitSpinnerService.hideModalSpinner();
toastService.add(
'success',
interpolate(gettext('Object %(path)s has copied.'), copyInfo, true)
);
model.updateContainer();
model.selectContainer(
copyInfo.dest_container,
dstFolder
).then(function openDest() {
var path = containerRoute + copyInfo.dest_container + '/' + dstFolder;
$location.path(path);
});
}
function error() {
modalWaitSpinnerService.hideModalSpinner();
}
}
}
})();

View File

@ -41,7 +41,7 @@
}));
it('should create an actions list', function test() {
expect(rowActions.length).toEqual(4);
expect(rowActions.length).toEqual(5);
angular.forEach(rowActions, function check(action) {
expect(action.service).toBeDefined();
expect(action.template).toBeDefined();
@ -262,6 +262,123 @@
});
});
describe('copyService', function test() {
var swiftAPI, copyService, modalWaitSpinnerService, toastService, $q, objectDef;
beforeEach(inject(function inject($injector, _$q_) {
swiftAPI = $injector.get('horizon.app.core.openstack-service-api.swift');
copyService = $injector.get('horizon.dashboard.project.containers.objects-actions.copy');
modalWaitSpinnerService = $injector.get(
'horizon.framework.widgets.modal-wait-spinner.service'
);
toastService = $injector.get('horizon.framework.widgets.toast.service');
$q = _$q_;
}));
it('should have an allowed and perform', function test() {
expect(copyService.allowed).toBeDefined();
expect(copyService.perform).toBeDefined();
});
it('should only allow files which has size(bytes) over 0', function test() {
objectDef = {
is_object: true,
bytes: 1
};
expectAllowed(copyService.allowed(objectDef));
});
it('should not allow folders', function test() {
objectDef = {
is_object: false,
bytes: 1
};
expectNotAllowed(copyService.allowed(objectDef));
});
it('should not allow 0 byte file, because it means separated files', function test() {
objectDef = {
is_object: true,
bytes: 0
};
expectNotAllowed(copyService.allowed(objectDef));
});
it('should handle copy success correctly', function() {
var modalDeferred = $q.defer();
var apiDeferred = $q.defer();
var result = { result: modalDeferred.promise };
spyOn($uibModal, 'open').and.returnValue(result);
spyOn(modalWaitSpinnerService, 'showModalSpinner');
spyOn(modalWaitSpinnerService, 'hideModalSpinner');
spyOn(swiftAPI, 'copyObject').and.returnValue(apiDeferred.promise);
spyOn(toastService, 'add').and.callThrough();
spyOn(model,'updateContainer');
spyOn(model,'selectContainer').and.returnValue(apiDeferred.promise);
copyService.perform();
model.container = {name: 'spam'};
$rootScope.$apply();
// Close the modal, make sure API call succeeds
var sourceObjectPath = 'sourceObjectPath';
modalDeferred.resolve({name: 'ham',
path: sourceObjectPath,
dest_container: 'dest_container',
dest_name: 'dest_name'});
apiDeferred.resolve();
$rootScope.$apply();
// Check the string of functions called by this code path succeed
expect($uibModal.open).toHaveBeenCalled();
expect(modalWaitSpinnerService.showModalSpinner).toHaveBeenCalled();
expect(swiftAPI.copyObject).toHaveBeenCalled();
expect(toastService.add).
toHaveBeenCalledWith('success', 'Object ' + sourceObjectPath +
' has copied.');
expect(modalWaitSpinnerService.hideModalSpinner).toHaveBeenCalled();
expect(model.updateContainer).toHaveBeenCalled();
expect(model.selectContainer).toHaveBeenCalled();
});
it('should handle copy error correctly', function() {
var modalDeferred = $q.defer();
var apiDeferred = $q.defer();
var result = { result: modalDeferred.promise };
spyOn($uibModal, 'open').and.returnValue(result);
spyOn(modalWaitSpinnerService, 'showModalSpinner');
spyOn(modalWaitSpinnerService, 'hideModalSpinner');
spyOn(swiftAPI, 'copyObject').and.returnValue(apiDeferred.promise);
spyOn(toastService, 'add').and.callThrough();
spyOn(model,'updateContainer');
spyOn(model,'selectContainer').and.returnValue(apiDeferred.promise);
copyService.perform();
model.container = {name: 'spam'};
$rootScope.$apply();
// Close the modal, make sure API call succeeds
var sourceObjectPath = 'sourceObjectPath';
modalDeferred.resolve({name: 'ham',
path: sourceObjectPath,
dest_container: 'dest_container',
dest_name: 'dest_name'});
apiDeferred.reject();
$rootScope.$apply();
// Check the string of functions called by this code path succeed
expect(modalWaitSpinnerService.showModalSpinner).toHaveBeenCalled();
expect(swiftAPI.copyObject).toHaveBeenCalled();
expect(modalWaitSpinnerService.hideModalSpinner).toHaveBeenCalled();
expect($uibModal.open).toHaveBeenCalled();
// Check the success branch is not called
expect(model.updateContainer).not.toHaveBeenCalled();
expect(model.selectContainer).not.toHaveBeenCalled();
expect(toastService.add).not.toHaveBeenCalledWith('success');
});
});
function exerciseAllowedPromise(promise) {
var handler = jasmine.createSpyObj('handler', ['success', 'error']);
promise.then(handler.success, handler.error);

View File

@ -0,0 +1,8 @@
---
features:
- |
Added support for Swift object copy as one of row actions.
Destination container must exist in advance.
To avoid overwriting an existing object,
you cannot copy an object if a specified destination object
already exists.