From f030262521202873e6f4d03da1a627a2d11d419a Mon Sep 17 00:00:00 2001 From: Szymon Wroblewski Date: Mon, 18 May 2015 13:41:16 +0200 Subject: [PATCH] Angular metadata update modal This patch adds metadata update modal dialog widet written in js and some required REST API methods. To see it in action checkout following patch https://review.openstack.org/#/c/184275/ which replaces old metadata modals with new ones written in angular. Co-Authored-By: Shaoquan Chen Co-Authored-By: Rajat Vig Partially-Implements: blueprint angularize-metadata-update-modals Change-Id: I36bfb91f8b6bbba49fed6bb01cd1dd266261cfdb --- openstack_dashboard/api/rest/glance.py | 26 ++++- openstack_dashboard/api/rest/nova.py | 46 +++++++- .../static/app/core/core.module.js | 14 ++- .../static/app/core/core.module.spec.js | 12 ++ .../app/core/metadata/metadata.module.js | 38 +++++++ .../app/core/metadata/metadata.module.spec.js | 25 ++++ .../app/core/metadata/metadata.service.js | 89 +++++++++++++++ .../core/metadata/metadata.service.spec.js | 107 ++++++++++++++++++ .../metadata/modal/modal-helper.controller.js | 60 ++++++++++ .../modal/modal-helper.controller.spec.js | 69 +++++++++++ .../core/metadata/modal/modal.controller.js | 77 +++++++++++++ .../metadata/modal/modal.controller.spec.js | 98 ++++++++++++++++ .../static/app/core/metadata/modal/modal.html | 22 ++++ .../app/core/metadata/modal/modal.module.js | 47 ++++++++ .../core/metadata/modal/modal.module.spec.js | 25 ++++ .../app/core/metadata/modal/modal.service.js | 71 ++++++++++++ .../core/metadata/modal/modal.service.spec.js | 62 ++++++++++ .../openstack-service-api/glance.service.js | 36 ++++++ .../glance.service.spec.js | 22 ++++ .../openstack-service-api/nova.service.js | 61 +++++++++- .../nova.service.spec.js | 38 ++++++- .../test/api_tests/glance_rest_tests.py | 23 ++++ .../test/api_tests/nova_rest_tests.py | 41 ++++++- 23 files changed, 1098 insertions(+), 11 deletions(-) create mode 100644 openstack_dashboard/static/app/core/metadata/metadata.module.js create mode 100644 openstack_dashboard/static/app/core/metadata/metadata.module.spec.js create mode 100644 openstack_dashboard/static/app/core/metadata/metadata.service.js create mode 100644 openstack_dashboard/static/app/core/metadata/metadata.service.spec.js create mode 100644 openstack_dashboard/static/app/core/metadata/modal/modal-helper.controller.js create mode 100644 openstack_dashboard/static/app/core/metadata/modal/modal-helper.controller.spec.js create mode 100644 openstack_dashboard/static/app/core/metadata/modal/modal.controller.js create mode 100644 openstack_dashboard/static/app/core/metadata/modal/modal.controller.spec.js create mode 100644 openstack_dashboard/static/app/core/metadata/modal/modal.html create mode 100644 openstack_dashboard/static/app/core/metadata/modal/modal.module.js create mode 100644 openstack_dashboard/static/app/core/metadata/modal/modal.module.spec.js create mode 100644 openstack_dashboard/static/app/core/metadata/modal/modal.service.js create mode 100644 openstack_dashboard/static/app/core/metadata/modal/modal.service.spec.js diff --git a/openstack_dashboard/api/rest/glance.py b/openstack_dashboard/api/rest/glance.py index 0c958b01ee..19c76391cc 100644 --- a/openstack_dashboard/api/rest/glance.py +++ b/openstack_dashboard/api/rest/glance.py @@ -30,7 +30,7 @@ CLIENT_KEYWORDS = {'resource_type', 'marker', 'sort_dir', 'sort_key', 'paginate' class Image(generic.View): """API for retrieving a single image """ - url_regex = r'glance/images/(?P.+|default)$' + url_regex = r'glance/images/(?P[^/]+|default)/$' @rest_utils.ajax() def get(self, request, image_id): @@ -41,6 +41,30 @@ class Image(generic.View): return api.glance.image_get(request, image_id).to_dict() +@urls.register +class ImageProperties(generic.View): + """API for retrieving only a custom properties of single image. + """ + url_regex = r'glance/images/(?P[^/]+)/properties/' + + @rest_utils.ajax() + def get(self, request, image_id): + """Get custom properties of specific image. + """ + return api.glance.image_get(request, image_id).properties + + @rest_utils.ajax(data_required=True) + def patch(self, request, image_id): + """Update custom properties of specific image. + + This method returns HTTP 204 (no content) on success. + """ + api.glance.image_update_properties( + request, image_id, request.DATA.get('removed'), + **request.DATA['updated'] + ) + + @urls.register class Images(generic.View): """API for Glance images. diff --git a/openstack_dashboard/api/rest/nova.py b/openstack_dashboard/api/rest/nova.py index 19fb25d873..7ab90c3446 100644 --- a/openstack_dashboard/api/rest/nova.py +++ b/openstack_dashboard/api/rest/nova.py @@ -250,7 +250,7 @@ class Flavors(generic.View): class Flavor(generic.View): """API for retrieving a single flavor """ - url_regex = r'nova/flavors/(?P.+)/$' + url_regex = r'nova/flavors/(?P[^/]+)/$' @rest_utils.ajax() def get(self, request, flavor_id): @@ -274,7 +274,7 @@ class Flavor(generic.View): class FlavorExtraSpecs(generic.View): """API for managing flavor extra specs """ - url_regex = r'nova/flavors/(?P.+)/extra-specs$' + url_regex = r'nova/flavors/(?P[^/]+)/extra-specs/$' @rest_utils.ajax() def get(self, request, flavor_id): @@ -284,3 +284,45 @@ class FlavorExtraSpecs(generic.View): http://localhost/api/nova/flavors/1/extra-specs """ return api.nova.flavor_get_extras(request, flavor_id, raw=True) + + @rest_utils.ajax(data_required=True) + def patch(self, request, flavor_id): + """Update a specific flavor's extra specs. + + This method returns HTTP 204 (no content) on success. + """ + if request.DATA.get('removed'): + api.nova.flavor_extra_delete( + request, flavor_id, request.DATA.get('removed') + ) + api.nova.flavor_extra_set( + request, flavor_id, request.DATA['updated'] + ) + + +@urls.register +class AggregateExtraSpecs(generic.View): + """API for managing aggregate extra specs + """ + url_regex = r'nova/aggregates/(?P[^/]+)/extra-specs/$' + + @rest_utils.ajax() + def get(self, request, aggregate_id): + """Get a specific aggregate's extra specs + + Example GET: + http://localhost/api/nova/flavors/1/extra-specs + """ + return api.nova.aggregate_get(request, aggregate_id).metadata + + @rest_utils.ajax(data_required=True) + def patch(self, request, aggregate_id): + """Update a specific aggregate's extra specs. + + This method returns HTTP 204 (no content) on success. + """ + updated = request.DATA['updated'] + if request.DATA.get('removed'): + for name in request.DATA.get('removed'): + updated[name] = None + api.nova.aggregate_set_metadata(request, aggregate_id, updated) diff --git a/openstack_dashboard/static/app/core/core.module.js b/openstack_dashboard/static/app/core/core.module.js index fa04d4fa7d..b6313670b6 100644 --- a/openstack_dashboard/static/app/core/core.module.js +++ b/openstack_dashboard/static/app/core/core.module.js @@ -28,10 +28,18 @@ */ angular .module('horizon.app.core', [ + 'horizon.app.core.cloud-services', 'horizon.app.core.images', - 'horizon.app.core.workflow', + 'horizon.app.core.metadata', 'horizon.app.core.openstack-service-api', - 'horizon.app.core.cloud-services' - ]); + 'horizon.app.core.workflow' + ], config); + + config.$inject = ['$provide', '$windowProvider']; + + function config($provide, $windowProvider) { + var path = $windowProvider.$get().STATIC_URL + 'app/core/'; + $provide.constant('horizon.app.core.basePath', path); + } })(); diff --git a/openstack_dashboard/static/app/core/core.module.spec.js b/openstack_dashboard/static/app/core/core.module.spec.js index 1038177a0c..fcceb8ae74 100644 --- a/openstack_dashboard/static/app/core/core.module.spec.js +++ b/openstack_dashboard/static/app/core/core.module.spec.js @@ -22,4 +22,16 @@ }); }); + describe('horizon.app.core.basePath', function () { + beforeEach(module('horizon.app.core')); + + it('should be defined and set correctly', inject([ + 'horizon.app.core.basePath', '$window', + function (basePath, $window) { + expect(basePath).toBeDefined(); + expect(basePath).toBe($window.STATIC_URL + 'app/core/'); + }]) + ); + }); + })(); diff --git a/openstack_dashboard/static/app/core/metadata/metadata.module.js b/openstack_dashboard/static/app/core/metadata/metadata.module.js new file mode 100644 index 0000000000..3e6d07ffcb --- /dev/null +++ b/openstack_dashboard/static/app/core/metadata/metadata.module.js @@ -0,0 +1,38 @@ +/* + * Copyright 2015, Intel Corp. + * + * 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 overview + * @name horizon.app.core.metadata + * @description + * + * # horizon.app.core.metadata + * + * The `horizon.app.core.metadata` provides provides metadata service. + * + * | Components | + * |------------------------------------------------------------------------------| + * | {@link horizon.app.core.metadata.service:metadataService `metadataService`} | + * + */ + angular + .module('horizon.app.core.metadata', [ + 'horizon.app.core.metadata.modal' + ]); + +})(); diff --git a/openstack_dashboard/static/app/core/metadata/metadata.module.spec.js b/openstack_dashboard/static/app/core/metadata/metadata.module.spec.js new file mode 100644 index 0000000000..d7c920f504 --- /dev/null +++ b/openstack_dashboard/static/app/core/metadata/metadata.module.spec.js @@ -0,0 +1,25 @@ +/* + * Copyright 2015, Intel Corp. + * + * 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.app.core.metadata', function () { + it('should be defined', function () { + expect(angular.module('horizon.app.core.metadata')).toBeDefined(); + }); + }); + +})(); diff --git a/openstack_dashboard/static/app/core/metadata/metadata.service.js b/openstack_dashboard/static/app/core/metadata/metadata.service.js new file mode 100644 index 0000000000..298fac0e78 --- /dev/null +++ b/openstack_dashboard/static/app/core/metadata/metadata.service.js @@ -0,0 +1,89 @@ +/* + * Copyright 2015, Intel Corp. + * + * 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.app.core.metadata') + .factory('horizon.app.core.metadata.service', metadataService); + + metadataService.$inject = [ + 'horizon.app.core.openstack-service-api.nova', + 'horizon.app.core.openstack-service-api.glance' + ]; + + /** + * @ngdoc service + * @name metadataService + * @description + * + * Unified acquisition and modification of metadata. + */ + function metadataService(nova, glance) { + var service = { + getMetadata: getMetadata, + editMetadata: editMetadata, + getNamespaces: getNamespaces + }; + + return service; + + /** + * Get metadata from specified resource. + * + * @param {string} resource Resource type. + * @param {string} id Resource identifier. + */ + function getMetadata(resource, id) { + return { + aggregate: nova.getAggregateExtraSpecs, + flavor: nova.getFlavorExtraSpecs, + image: glance.getImageProps + }[resource](id); + } + + /** + * Edit metadata of specified resource. + * + * @param {string} resource Resource type. + * @param {string} id Resource identifier. + * @param {object} updated New metadata. + * @param {[]} removed Names of removed metadata. + */ + function editMetadata(resource, id, updated, removed) { + return { + aggregate: nova.editAggregateExtraSpecs, + flavor: nova.editFlavorExtraSpecs, + image: glance.editImageProps + }[resource](id, updated, removed); + } + + /** + * Get available metadata namespaces for specified resource. + * + * @param {string} resource Resource type. + */ + function getNamespaces(resource) { + return glance.getNamespaces({ + resource_type: { + aggregate: 'OS::Nova::Aggregate', + flavor: 'OS::Nova::Flavor', + image: 'OS::Glance::Image' + }[resource] + }, false); + } + } +})(); diff --git a/openstack_dashboard/static/app/core/metadata/metadata.service.spec.js b/openstack_dashboard/static/app/core/metadata/metadata.service.spec.js new file mode 100644 index 0000000000..850f922d10 --- /dev/null +++ b/openstack_dashboard/static/app/core/metadata/metadata.service.spec.js @@ -0,0 +1,107 @@ +/* + * Copyright 2015, ThoughtWorks Inc. + * + * 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('metadata.service', function () { + + beforeEach(module('horizon.app.core.metadata')); + + var nova = {getAggregateExtraSpecs: function() {}, + getFlavorExtraSpecs: function() {}, + editAggregateExtraSpecs: function() {}, + editFlavorExtraSpecs: function() {} }; + + var glance = {getImageProps: function() {}, + editImageProps: function() {}, + getNamespaces: function() {}}; + + beforeEach(function() { + module(function($provide) { + $provide.value('horizon.app.core.openstack-service-api.nova', nova); + $provide.value('horizon.app.core.openstack-service-api.glance', glance); + }); + }); + + var metadataService; + + beforeEach(inject(function($injector) { + metadataService = $injector.get('horizon.app.core.metadata.service'); + })); + + it('should get aggregate metadata', function() { + var expected = 'aggregate metadata'; + spyOn(nova, 'getAggregateExtraSpecs').and.returnValue(expected); + var actual = metadataService.getMetadata('aggregate', '1'); + expect(actual).toBe(expected); + }); + + it('should edit aggregate metadata', function() { + spyOn(nova, 'editAggregateExtraSpecs'); + metadataService.editMetadata('aggregate', '1', 'updated', ['removed']); + expect(nova.editAggregateExtraSpecs).toHaveBeenCalledWith('1', 'updated', ['removed']); + }); + + it('should get aggregate namespace', function() { + spyOn(glance, 'getNamespaces'); + var actual = metadataService.getNamespaces('aggregate'); + expect(glance.getNamespaces) + .toHaveBeenCalledWith({ resource_type: 'OS::Nova::Aggregate' }, false); + }); + + it('should get flavor metadata', function() { + var expected = 'flavor metadata'; + spyOn(nova, 'getFlavorExtraSpecs').and.returnValue(expected); + var actual = metadataService.getMetadata('flavor', '1'); + expect(actual).toBe(expected); + }); + + it('should edit flavor metadata', function() { + spyOn(nova, 'editFlavorExtraSpecs'); + metadataService.editMetadata('flavor', '1', 'updated', ['removed']); + expect(nova.editFlavorExtraSpecs).toHaveBeenCalledWith('1', 'updated', ['removed']); + }); + + it('should get flavor namespace', function() { + spyOn(glance, 'getNamespaces'); + var actual = metadataService.getNamespaces('flavor'); + expect(glance.getNamespaces) + .toHaveBeenCalledWith({ resource_type: 'OS::Nova::Flavor' }, false); + }); + + it('should get image metadata', function() { + var expected = 'image metadata'; + spyOn(glance, 'getImageProps').and.returnValue(expected); + var actual = metadataService.getMetadata('image', '1'); + expect(actual).toBe(expected); + }); + + it('should edit image metadata', function() { + spyOn(glance, 'editImageProps'); + metadataService.editMetadata('image', '1', 'updated', ['removed']); + expect(glance.editImageProps).toHaveBeenCalledWith('1', 'updated', ['removed']); + }); + + it('should get image namespace', function() { + spyOn(glance, 'getNamespaces'); + var actual = metadataService.getNamespaces('image'); + expect(glance.getNamespaces) + .toHaveBeenCalledWith({ resource_type: 'OS::Glance::Image' }, false); + }); + + }); + +})(); diff --git a/openstack_dashboard/static/app/core/metadata/modal/modal-helper.controller.js b/openstack_dashboard/static/app/core/metadata/modal/modal-helper.controller.js new file mode 100644 index 0000000000..8355dba79e --- /dev/null +++ b/openstack_dashboard/static/app/core/metadata/modal/modal-helper.controller.js @@ -0,0 +1,60 @@ +/* + * Copyright 2015, Intel Corp. + * (c) Copyright 2015 Hewlett-Packard Development Company, L.P. + * + * 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.app.core.metadata.modal') + .controller('MetadataModalHelperController', MetadataModalHelperController); + + MetadataModalHelperController.$inject = [ + '$window', + 'horizon.app.core.metadata.modal.service' + ]; + + /** + * @ngdoc controller + * @name horizon.app.core.metadata.modal.controller:MetadataModalHelperController + * @description + * Helper controller used by Horizon part written in Django. + */ + function MetadataModalHelperController($window, metadataModalService) { + //NOTE(bluex): controller should be removed when reload is no longer needed + var ctrl = this; + + ctrl.openMetadataModal = openMetadataModal; + + /** + * Open modal allowing to edit metadata + * + * @param {string} resource Metadata resource type + * @param {string} id Object identifier to retrieve metadata from + * @param {boolean=} requireReload Whether to reload page when metadata successfully updated + */ + function openMetadataModal(resource, id, requireReload) { + metadataModalService.open(resource, id) + .result + .then(onOpened); + + function onOpened() { + if (requireReload) { + $window.location.reload(); + } + } + } + } +})(); diff --git a/openstack_dashboard/static/app/core/metadata/modal/modal-helper.controller.spec.js b/openstack_dashboard/static/app/core/metadata/modal/modal-helper.controller.spec.js new file mode 100644 index 0000000000..88ed9dc276 --- /dev/null +++ b/openstack_dashboard/static/app/core/metadata/modal/modal-helper.controller.spec.js @@ -0,0 +1,69 @@ +/** + * Copyright 2015 ThoughtWorks Inc. + * + * 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('MetadataModalHelperController', function () { + var $controller, $window; + + var metadataModalService = { + open: function () { + return { + result: { + then: function (callback) { + callback(); + } + } + }; + } + }; + + beforeEach(function() { + $window = { + location: { + reload: jasmine.createSpy() + } + }; + }); + + beforeEach(module('horizon.app.core.metadata.modal')); + beforeEach(inject(function (_$controller_) { + $controller = _$controller_; + })); + + it('should reload window if required', function () { + var params = { + $window: $window, + 'horizon.app.core.metadata.modal.service': metadataModalService + }; + var controller = $controller('MetadataModalHelperController', params); + controller.openMetadataModal('aggregate', '123', true); + expect($window.location.reload).toHaveBeenCalled(); + }); + + it('should not reload window if not required', function () { + var params = { + $window: $window, + 'horizon.app.core.metadata.modal.service': metadataModalService + }; + var controller = $controller('MetadataModalHelperController', params); + controller.openMetadataModal('aggregate', '123', false); + expect($window.location.reload).not.toHaveBeenCalled(); + }); + + }); +})(); diff --git a/openstack_dashboard/static/app/core/metadata/modal/modal.controller.js b/openstack_dashboard/static/app/core/metadata/modal/modal.controller.js new file mode 100644 index 0000000000..a69a19826f --- /dev/null +++ b/openstack_dashboard/static/app/core/metadata/modal/modal.controller.js @@ -0,0 +1,77 @@ +/* + * Copyright 2015, Intel Corp. + * (c) Copyright 2015 Hewlett-Packard Development Company, L.P. + * + * 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.app.core.metadata.modal') + .controller('MetadataModalController', MetadataModalController); + + MetadataModalController.$inject = [ + '$modalInstance', + 'horizon.framework.widgets.metadata.tree.service', + 'horizon.app.core.metadata.service', + // Dependencies injected with resolve by $modal.open + 'available', + 'existing', + 'params' + ]; + + /** + * @ngdoc controller + * @name MetadataModalController + * @description + * Controller used by `ModalService` + */ + function MetadataModalController( + $modalInstance, metadataTreeService, metadataService, + available, existing, params + ) { + var ctrl = this; + + ctrl.cancel = cancel; + ctrl.resourceType = params.resource; + ctrl.save = save; + ctrl.saving = false; + ctrl.tree = new metadataTreeService.Tree(available.data.items, existing.data); + + function save() { + ctrl.saving = true; + var updated = ctrl.tree.getExisting(); + var removed = angular.copy(existing.data); + angular.forEach(updated, function(value, key) { + delete removed[key]; + }); + + metadataService + .editMetadata(params.resource, params.id, updated, Object.keys(removed)) + .then(onEditSuccess, onEditFailure); + } + + function cancel() { + $modalInstance.dismiss('cancel'); + } + + function onEditSuccess() { + $modalInstance.close(); + } + + function onEditFailure() { + ctrl.saving = false; + } + } +})(); diff --git a/openstack_dashboard/static/app/core/metadata/modal/modal.controller.spec.js b/openstack_dashboard/static/app/core/metadata/modal/modal.controller.spec.js new file mode 100644 index 0000000000..86abdb9236 --- /dev/null +++ b/openstack_dashboard/static/app/core/metadata/modal/modal.controller.spec.js @@ -0,0 +1,98 @@ +/** + * Copyright 2015 ThoughtWorks Inc. + * + * 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('MetadataModalController', function () { + var $controller, treeService, modalInstance; + + var metadataService = { + editMetadata: function() {} + }; + + beforeEach(function() { + modalInstance = { + dismiss: jasmine.createSpy(), + close: jasmine.createSpy() + }; + }); + + beforeEach(module('horizon.app.core.metadata.modal', + 'horizon.framework.widgets.metadata.tree')); + beforeEach(inject(function (_$controller_, $injector) { + $controller = _$controller_; + treeService = $injector.get('horizon.framework.widgets.metadata.tree.service'); + })); + + it('should dismiss modal on cancel', function () { + var controller = createController(modalInstance); + + controller.cancel(); + + expect(modalInstance.dismiss).toHaveBeenCalledWith('cancel'); + }); + + it('should close modal on successful save', function () { + var controller = createController(modalInstance); + metadataService.editMetadata = function() { + return { + then: function(success, fail) { + success(); + } + }; + }; + + spyOn(metadataService, 'editMetadata').and.callThrough(); + + controller.save(); + + expect(modalInstance.close).toHaveBeenCalled(); + expect(metadataService.editMetadata) + .toHaveBeenCalledWith('aggregate', '123', {someProperty: 'someValue'}, []); + }); + + it('should clear saving flag on failed save', function() { + var controller = createController(modalInstance); + metadataService.editMetadata = function() { + return { + then: function(success, fail) { + fail(); + } + }; + }; + + spyOn(metadataService, 'editMetadata').and.callThrough(); + + controller.save(); + + expect(modalInstance.close).not.toHaveBeenCalled(); + expect(metadataService.editMetadata) + .toHaveBeenCalledWith('aggregate', '123', {someProperty: 'someValue'}, []); + }); + + function createController() { + return $controller('MetadataModalController', { + '$modalInstance': modalInstance, + 'horizon.framework.widgets.metadata.tree.service': treeService, + 'horizon.app.core.metadata.service': metadataService, + 'available': {data: {}}, + 'existing': {data: {someProperty: 'someValue'}}, + 'params': {resource: 'aggregate', id: '123'} + }); + } + }); +})(); diff --git a/openstack_dashboard/static/app/core/metadata/modal/modal.html b/openstack_dashboard/static/app/core/metadata/modal/modal.html new file mode 100644 index 0000000000..ea7addecc9 --- /dev/null +++ b/openstack_dashboard/static/app/core/metadata/modal/modal.html @@ -0,0 +1,22 @@ + + + diff --git a/openstack_dashboard/static/app/core/metadata/modal/modal.module.js b/openstack_dashboard/static/app/core/metadata/modal/modal.module.js new file mode 100644 index 0000000000..547370c8e4 --- /dev/null +++ b/openstack_dashboard/static/app/core/metadata/modal/modal.module.js @@ -0,0 +1,47 @@ +/* + * Copyright 2015, Intel Corp. + * + * 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'; + + /*eslint-disable max-len */ + /** + * @ngdoc overview + * @name horizon.app.core.metadata.modal + * @description + * + * # horizon.app.core.metadata.modal + * + * The `horizon.app.core.metadata.modal` provides provides metadata modal service. + * + * Requires {@link http://angular-ui.github.io/bootstrap/ `Angular-bootstrap`} + * + * | Components | + * |---------------------------------------------------------------------------------------------| + * | {@link horizon.app.core.metadata.modal.service:modalService `modalService`} | + * | {@link horizon.app.core.metadata.modal.controller:MetadataModalController `MetadataModalController`} | + * | {@link horizon.app.core.metadata.modal.controller:MetadataModalHelperController `MetadataModalHelperController`} | + * + */ + /*eslint-enable max-len */ + angular + .module('horizon.app.core.metadata.modal', []) + .constant('horizon.app.core.metadata.modal.constants', { + backdrop: 'static', + controller: 'MetadataModalController as modal', + windowClass: 'modal-dialog-metadata' + }); + +})(); diff --git a/openstack_dashboard/static/app/core/metadata/modal/modal.module.spec.js b/openstack_dashboard/static/app/core/metadata/modal/modal.module.spec.js new file mode 100644 index 0000000000..5b5fffe925 --- /dev/null +++ b/openstack_dashboard/static/app/core/metadata/modal/modal.module.spec.js @@ -0,0 +1,25 @@ +/* + * Copyright 2015, Intel Corp. + * + * 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.app.core.metadata.modal', function () { + it('should be defined', function () { + expect(angular.module('horizon.app.core.metadata.modal')).toBeDefined(); + }); + }); + +})(); diff --git a/openstack_dashboard/static/app/core/metadata/modal/modal.service.js b/openstack_dashboard/static/app/core/metadata/modal/modal.service.js new file mode 100644 index 0000000000..396eaddca8 --- /dev/null +++ b/openstack_dashboard/static/app/core/metadata/modal/modal.service.js @@ -0,0 +1,71 @@ +/* + * Copyright 2015, Intel Corp. + * + * 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.app.core.metadata.modal') + .factory('horizon.app.core.metadata.modal.service', modalService); + + modalService.$inject = [ + '$modal', + 'horizon.app.core.basePath', + 'horizon.app.core.metadata.service', + 'horizon.app.core.metadata.modal.constants' + ]; + + /** + * @ngdoc service + * @name modalService + */ + function modalService($modal, path, metadataService, modalConstants) { + var service = { + open: open + }; + + return service; + + /** + * Open modal allowing to edit metadata + * + * @param {string} resource Metadata resource type + * @param {string} id Object identifier to retrieve metadata from + */ + function open(resource, id) { + function resolveAvailable() { + return metadataService.getNamespaces(resource); + } + function resolveExisting() { + return metadataService.getMetadata(resource, id); + } + function resolveParams() { + return {resource: resource, id: id}; + } + + var resolve = { + available: resolveAvailable, + existing: resolveExisting, + params: resolveParams + }; + var modalParams = { + resolve: resolve, + templateUrl: path + 'metadata/modal/modal.html' + }; + return $modal.open(angular.extend(modalParams, modalConstants)); + } + + } +})(); diff --git a/openstack_dashboard/static/app/core/metadata/modal/modal.service.spec.js b/openstack_dashboard/static/app/core/metadata/modal/modal.service.spec.js new file mode 100644 index 0000000000..055f401c1a --- /dev/null +++ b/openstack_dashboard/static/app/core/metadata/modal/modal.service.spec.js @@ -0,0 +1,62 @@ +/* + * (c) Copyright 2015 ThoughtWorks, Inc. + * + * 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.app.core.metadata.modal', function () { + + describe('service.modalservice', function(){ + var modalService, metadataService, $modal; + + beforeEach(module('ui.bootstrap', function($provide){ + $modal = jasmine.createSpyObj('$modal', ['open']); + + $provide.value('$modal', $modal); + })); + + beforeEach(module('horizon.app.core', function($provide) { + $provide.constant('horizon.app.core.basePath', '/a/sample/path/'); + })); + + beforeEach(module('horizon.app.core.metadata', function($provide){ + metadataService = jasmine.createSpyObj('metadataService', ['getMetadata', 'getNamespaces']); + $provide.value('horizon.app.core.metadata.service', metadataService); + })); + + beforeEach(module('horizon.app.core.metadata.modal')); + + beforeEach(inject(function($controller, $injector) { + modalService = $injector.get('horizon.app.core.metadata.modal.service'); + })); + + it('should define service.open()', function() { + expect(modalService.open).toBeDefined(); + }); + + it('should invoke $modal.open with correct params', function() { + modalService.open('resource', 'id'); + + expect($modal.open).toHaveBeenCalled(); + + var args = $modal.open.calls.argsFor(0)[0]; + expect(args.templateUrl).toEqual('/a/sample/path/metadata/modal/modal.html'); + expect(args.resolve.params()).toEqual({resource: 'resource', id: 'id'}); + }); + + }); + }); + +})(); diff --git a/openstack_dashboard/static/app/core/openstack-service-api/glance.service.js b/openstack_dashboard/static/app/core/openstack-service-api/glance.service.js index 480aa7a9da..74225c983d 100644 --- a/openstack_dashboard/static/app/core/openstack-service-api/glance.service.js +++ b/openstack_dashboard/static/app/core/openstack-service-api/glance.service.js @@ -33,6 +33,8 @@ function GlanceAPI(apiService, toastService) { var service = { getImage: getImage, + getImageProps: getImageProps, + editImageProps: editImageProps, getImages: getImages, getNamespaces: getNamespaces }; @@ -57,6 +59,40 @@ }); } + /** + * @name horizon.app.core.openstack-service-api.glance.getImageProps + * @description + * Get an image custom properties by image ID + * @param {string} id Specifies the id of the image to request. + */ + function getImageProps(id) { + return apiService.get('/api/glance/images/' + id + '/properties/') + .error(function () { + toastService.add('error', gettext('Unable to retrieve the image custom properties.')); + }); + } + + /** + * @name horizon.app.core.openstack-service-api.glance.editImageProps + * @description + * Update an image custom properties by image ID + * @param {string} id Specifies the id of the image to request. + * @param {object} updated New metadata definitions. + * @param {[]} removed Names of removed metadata definitions. + */ + function editImageProps(id, updated, removed) { + return apiService.patch( + '/api/glance/images/' + id + '/properties/', + { + updated: updated, + removed: removed + } + ) + .error(function () { + toastService.add('error', gettext('Unable to edit the image custom properties.')); + }); + } + /** * @name horizon.app.core.openstack-service-api.glance.getImages * @description diff --git a/openstack_dashboard/static/app/core/openstack-service-api/glance.service.spec.js b/openstack_dashboard/static/app/core/openstack-service-api/glance.service.spec.js index 621d0a5b26..f061862bc5 100644 --- a/openstack_dashboard/static/app/core/openstack-service-api/glance.service.spec.js +++ b/openstack_dashboard/static/app/core/openstack-service-api/glance.service.spec.js @@ -49,6 +49,28 @@ 42 ] }, + { + "func": "getImageProps", + "method": "get", + "path": "/api/glance/images/42/properties/", + "error": "Unable to retrieve the image custom properties.", + "testInput": [ + 42 + ] + }, + { + "func": "editImageProps", + "method": "patch", + "path": "/api/glance/images/42/properties/", + "data": { + "updated": {a: '1', b: '2'}, + "removed": ['c', 'd'] + }, + "error": "Unable to edit the image custom properties.", + "testInput": [ + 42, {a: '1', b: '2'}, ['c', 'd'] + ] + }, { "func": "getImages", "method": "get", diff --git a/openstack_dashboard/static/app/core/openstack-service-api/nova.service.js b/openstack_dashboard/static/app/core/openstack-service-api/nova.service.js index a6d97b45cc..75bb2e6d79 100644 --- a/openstack_dashboard/static/app/core/openstack-service-api/nova.service.js +++ b/openstack_dashboard/static/app/core/openstack-service-api/nova.service.js @@ -42,7 +42,10 @@ getExtensions: getExtensions, getFlavors: getFlavors, getFlavor: getFlavor, - getFlavorExtraSpecs: getFlavorExtraSpecs + getFlavorExtraSpecs: getFlavorExtraSpecs, + editFlavorExtraSpecs: editFlavorExtraSpecs, + getAggregateExtraSpecs: getAggregateExtraSpecs, + editAggregateExtraSpecs: editAggregateExtraSpecs }; return service; @@ -289,11 +292,65 @@ * Specifies the id of the flavor to request the extra specs. */ function getFlavorExtraSpecs(id) { - return apiService.get('/api/nova/flavors/' + id + '/extra-specs') + return apiService.get('/api/nova/flavors/' + id + '/extra-specs/') .error(function () { toastService.add('error', gettext('Unable to retrieve the flavor extra specs.')); }); } + + /** + * @name horizon.openstack-service-api.nova.editFlavorExtraSpecs + * @description + * Update a single flavor's extra specs by ID. + * @param {string} id + * @param {object} updated New extra specs. + * @param {[]} removed Names of removed extra specs. + */ + function editFlavorExtraSpecs(id, updated, removed) { + return apiService.patch( + '/api/nova/flavors/' + id + '/extra-specs/', + { + updated: updated, + removed: removed + } + ).error(function () { + toastService.add('error', gettext('Unable to edit the flavor extra specs.')); + }); + } + + /** + * @name horizon.openstack-service-api.nova.getAggregateExtraSpecs + * @description + * Get a single aggregate's extra specs by ID. + * @param {string} id + * Specifies the id of the flavor to request the extra specs. + */ + function getAggregateExtraSpecs(id) { + return apiService.get('/api/nova/aggregates/' + id + '/extra-specs/') + .error(function () { + toastService.add('error', gettext('Unable to retrieve the aggregate extra specs.')); + }); + } + + /** + * @name horizon.openstack-service-api.nova.editAggregateExtraSpecs + * @description + * Update a single aggregate's extra specs by ID. + * @param {string} id + * @param {object} updated New extra specs. + * @param {[]} removed Names of removed extra specs. + */ + function editAggregateExtraSpecs(id, updated, removed) { + return apiService.patch( + '/api/nova/aggregates/' + id + '/extra-specs/', + { + updated: updated, + removed: removed + } + ).error(function () { + toastService.add('error', gettext('Unable to edit the aggregate extra specs.')); + }); + } } }()); diff --git a/openstack_dashboard/static/app/core/openstack-service-api/nova.service.spec.js b/openstack_dashboard/static/app/core/openstack-service-api/nova.service.spec.js index c8b6e0f37f..858fb44353 100644 --- a/openstack_dashboard/static/app/core/openstack-service-api/nova.service.spec.js +++ b/openstack_dashboard/static/app/core/openstack-service-api/nova.service.spec.js @@ -201,13 +201,47 @@ { "func": "getFlavorExtraSpecs", "method": "get", - "path": "/api/nova/flavors/42/extra-specs", + "path": "/api/nova/flavors/42/extra-specs/", "error": "Unable to retrieve the flavor extra specs.", "testInput": [ 42 ] + }, + { + "func": "editFlavorExtraSpecs", + "method": "patch", + "path": "/api/nova/flavors/42/extra-specs/", + "data": { + "updated": {a: '1', b: '2'}, + "removed": ['c', 'd'] + }, + "error": "Unable to edit the flavor extra specs.", + "testInput": [ + 42, {a: '1', b: '2'}, ['c', 'd'] + ] + }, + { + "func": "getAggregateExtraSpecs", + "method": "get", + "path": "/api/nova/aggregates/42/extra-specs/", + "error": "Unable to retrieve the aggregate extra specs.", + "testInput": [ + 42 + ] + }, + { + "func": "editAggregateExtraSpecs", + "method": "patch", + "path": "/api/nova/aggregates/42/extra-specs/", + "data": { + "updated": {a: '1', b: '2'}, + "removed": ['c', 'd'] + }, + "error": "Unable to edit the aggregate extra specs.", + "testInput": [ + 42, {a: '1', b: '2'}, ['c', 'd'] + ] } - ]; // Iterate through the defined tests and apply as Jasmine specs. diff --git a/openstack_dashboard/test/api_tests/glance_rest_tests.py b/openstack_dashboard/test/api_tests/glance_rest_tests.py index b8e56b052c..e68901548f 100644 --- a/openstack_dashboard/test/api_tests/glance_rest_tests.py +++ b/openstack_dashboard/test/api_tests/glance_rest_tests.py @@ -28,6 +28,29 @@ class ImagesRestTestCase(test.TestCase): self.assertStatusCode(response, 200) gc.image_get.assert_called_once_with(request, "1") + @mock.patch.object(glance.api, 'glance') + def test_image_get_metadata(self, gc): + request = self.mock_rest_request() + gc.image_get.return_value.properties = {'a': '1', 'b': '2'} + + response = glance.ImageProperties().get(request, "1") + self.assertStatusCode(response, 200) + self.assertEqual(response.content, '{"a": "1", "b": "2"}') + gc.image_get.assert_called_once_with(request, "1") + + @mock.patch.object(glance.api, 'glance') + def test_image_edit_metadata(self, gc): + request = self.mock_rest_request( + body='{"updated": {"a": "1", "b": "2"}, "removed": ["c", "d"]}' + ) + + response = glance.ImageProperties().patch(request, '1') + self.assertStatusCode(response, 204) + self.assertEqual(response.content, '') + gc.image_update_properties.assert_called_once_with( + request, '1', ['c', 'd'], a='1', b='2' + ) + @mock.patch.object(glance.api, 'glance') def test_image_get_list_detailed(self, gc): kwargs = { diff --git a/openstack_dashboard/test/api_tests/nova_rest_tests.py b/openstack_dashboard/test/api_tests/nova_rest_tests.py index 1cd821952e..02e5bfecd6 100644 --- a/openstack_dashboard/test/api_tests/nova_rest_tests.py +++ b/openstack_dashboard/test/api_tests/nova_rest_tests.py @@ -266,10 +266,49 @@ class NovaRestTestCase(test.TestCase): self._test_flavor_list_extras(get_extras=None) @mock.patch.object(nova.api, 'nova') - def test_flavor_extra_specs(self, nc): + def test_flavor_get_extra_specs(self, nc): request = self.mock_rest_request() nc.flavor_get_extras.return_value.to_dict.return_value = {'foo': '1'} response = nova.FlavorExtraSpecs().get(request, "1") self.assertStatusCode(response, 200) nc.flavor_get_extras.assert_called_once_with(request, "1", raw=True) + + @mock.patch.object(nova.api, 'nova') + def test_flavor_edit_extra_specs(self, nc): + request = self.mock_rest_request( + body='{"updated": {"a": "1", "b": "2"}, "removed": ["c", "d"]}' + ) + + response = nova.FlavorExtraSpecs().patch(request, '1') + self.assertStatusCode(response, 204) + self.assertEqual(response.content, '') + nc.flavor_extra_set.assert_called_once_with( + request, '1', {'a': '1', 'b': '2'} + ) + nc.flavor_extra_delete.assert_called_once_with( + request, '1', ['c', 'd'] + ) + + @mock.patch.object(nova.api, 'nova') + def test_aggregate_get_extra_specs(self, nc): + request = self.mock_rest_request() + nc.aggregate_get.return_value.metadata = {'a': '1', 'b': '2'} + + response = nova.AggregateExtraSpecs().get(request, "1") + self.assertStatusCode(response, 200) + self.assertEqual(response.content, '{"a": "1", "b": "2"}') + nc.aggregate_get.assert_called_once_with(request, "1") + + @mock.patch.object(nova.api, 'nova') + def test_aggregate_edit_extra_specs(self, nc): + request = self.mock_rest_request( + body='{"updated": {"a": "1", "b": "2"}, "removed": ["c", "d"]}' + ) + + response = nova.AggregateExtraSpecs().patch(request, '1') + self.assertStatusCode(response, 204) + self.assertEqual(response.content, '') + nc.aggregate_set_metadata.assert_called_once_with( + request, '1', {'a': '1', 'b': '2', 'c': None, 'd': None} + )