From 5b06cda1131cd5cb230e7459705d745bb4bec23e Mon Sep 17 00:00:00 2001 From: Rajat Vig Date: Fri, 9 Oct 2015 23:25:13 -0700 Subject: [PATCH] Adding Edit Image Action to angular images panel Adds the ability to edit an image from the table. To test set DISABLED = False in _1051_project_ng_images_panel.py Also, ensure in your local_settings.py that you set: REST_API_REQUIRED_SETTINGS to include 'OPENSTACK_IMAGE_FORMATS' Co-Authored-By: Rajat Vig Co-Authored-By: Nathan Zeplowitz Co-Authored-By: Matt Borland Co-Authored-By: Kyle Olivo Change-Id: I046eca486ebc0d2d980ee706105e202bf9b15ba8 Partially-Implements: blueprint angularize-images-table --- openstack_dashboard/api/rest/glance.py | 15 +- .../local/local_settings.py.example | 3 +- openstack_dashboard/settings.py | 5 + .../app/core/images/actions/actions.module.js | 9 + .../images/actions/edit.action.service.js | 176 ++++++++++++ .../actions/edit.action.service.spec.js | 264 ++++++++++++++++++ .../images/actions/edit.workflow.service.js | 57 ++++ .../actions/edit.workflow.service.spec.js | 54 ++++ .../static/app/core/images/images.module.js | 40 ++- .../app/core/images/images.module.spec.js | 12 + .../steps/edit-image/edit-image.controller.js | 120 ++++++++ .../edit-image/edit-image.controller.spec.js | 169 +++++++++++ .../steps/edit-image/edit-image.help.html | 4 + .../images/steps/edit-image/edit-image.html | 147 ++++++++++ .../update-metadata.controller.js | 80 ++++++ .../update-metadata.controller.spec.js | 123 ++++++++ .../update-metadata/update-metadata.help.html | 16 ++ .../update-metadata/update-metadata.html | 6 + .../core/images/table/images.controller.js | 8 + .../images/table/images.controller.spec.js | 38 ++- 20 files changed, 1337 insertions(+), 9 deletions(-) create mode 100644 openstack_dashboard/static/app/core/images/actions/edit.action.service.js create mode 100644 openstack_dashboard/static/app/core/images/actions/edit.action.service.spec.js create mode 100644 openstack_dashboard/static/app/core/images/actions/edit.workflow.service.js create mode 100644 openstack_dashboard/static/app/core/images/actions/edit.workflow.service.spec.js create mode 100644 openstack_dashboard/static/app/core/images/steps/edit-image/edit-image.controller.js create mode 100644 openstack_dashboard/static/app/core/images/steps/edit-image/edit-image.controller.spec.js create mode 100644 openstack_dashboard/static/app/core/images/steps/edit-image/edit-image.help.html create mode 100644 openstack_dashboard/static/app/core/images/steps/edit-image/edit-image.html create mode 100644 openstack_dashboard/static/app/core/images/steps/update-metadata/update-metadata.controller.js create mode 100644 openstack_dashboard/static/app/core/images/steps/update-metadata/update-metadata.controller.spec.js create mode 100644 openstack_dashboard/static/app/core/images/steps/update-metadata/update-metadata.help.html create mode 100644 openstack_dashboard/static/app/core/images/steps/update-metadata/update-metadata.html diff --git a/openstack_dashboard/api/rest/glance.py b/openstack_dashboard/api/rest/glance.py index f9e1627162..8bf2b09937 100644 --- a/openstack_dashboard/api/rest/glance.py +++ b/openstack_dashboard/api/rest/glance.py @@ -297,10 +297,13 @@ def create_image_metadata(data): 'container_format': data.get('container_format'), 'properties': {}} - # 'description' and 'architecture' will be directly mapped + # 'architecture' will be directly mapped # into the .properties by the handle_unknown_properties function. # 'kernel' and 'ramdisk' need to get specifically mapped for backwards # compatibility. + props = data.get('properties') + if props and props.get('description'): + meta['properties']['description'] = props.get('description') if data.get('kernel'): meta['properties']['kernel_id'] = data.get('kernel') if data.get('ramdisk'): @@ -320,9 +323,13 @@ def handle_unknown_properties(data, meta): # existing horizon api wrapper, we need this function. This way, the # client REST mirrors the Glance API. known_props = ['visibility', 'protected', 'disk_format', - 'container_format', 'min_disk', 'min_ram', - 'name', 'properties', 'kernel', 'ramdisk', - 'tags', 'import_data', 'source', 'image_url', 'source_type'] + 'container_format', 'min_disk', 'min_ram', 'name', + 'properties', 'kernel', 'ramdisk', + 'tags', 'import_data', 'source', + 'image_url', 'source_type', + 'checksum', 'created_at', 'deleted', 'is_copying', + 'deleted_at', 'is_public', 'virtual_size', + 'status', 'size', 'owner', 'id', 'updated_at'] other_props = {k: v for (k, v) in data.items() if k not in known_props} meta['properties'].update(other_props) diff --git a/openstack_dashboard/local/local_settings.py.example b/openstack_dashboard/local/local_settings.py.example index 83bef29464..3ec347d858 100644 --- a/openstack_dashboard/local/local_settings.py.example +++ b/openstack_dashboard/local/local_settings.py.example @@ -720,7 +720,8 @@ SECURITY_GROUP_RULES = { # You should not add settings to this list for out of tree extensions. # See: https://wiki.openstack.org/wiki/Horizon/RESTAPI REST_API_REQUIRED_SETTINGS = ['OPENSTACK_HYPERVISOR_FEATURES', - 'LAUNCH_INSTANCE_DEFAULTS'] + 'LAUNCH_INSTANCE_DEFAULTS', + 'OPENSTACK_IMAGE_FORMATS'] # Additional settings can be made available to the client side for # extensibility by specifying them in REST_API_ADDITIONAL_SETTINGS diff --git a/openstack_dashboard/settings.py b/openstack_dashboard/settings.py index 2a926a0678..82960120e4 100644 --- a/openstack_dashboard/settings.py +++ b/openstack_dashboard/settings.py @@ -305,6 +305,11 @@ if os.path.exists(LOCAL_SETTINGS_DIR_PATH): logging.exception( "Can not exec settings snippet %s" % filename) +# The purpose of OPENSTACK_IMAGE_FORMATS is to provide a simple object +# that does not contain the lazy-loaded translations, so the list can +# be sent as JSON to the client-side (Angular). +OPENSTACK_IMAGE_FORMATS = [fmt for (fmt, name) + in OPENSTACK_IMAGE_BACKEND['image_formats']] if not WEBROOT.endswith('/'): WEBROOT += '/' diff --git a/openstack_dashboard/static/app/core/images/actions/actions.module.js b/openstack_dashboard/static/app/core/images/actions/actions.module.js index 3df7f0b16f..8c163e48ff 100644 --- a/openstack_dashboard/static/app/core/images/actions/actions.module.js +++ b/openstack_dashboard/static/app/core/images/actions/actions.module.js @@ -29,6 +29,7 @@ registerImageActions.$inject = [ 'horizon.framework.conf.resource-type-registry.service', + 'horizon.app.core.images.actions.edit.service', 'horizon.app.core.images.actions.create-volume.service', 'horizon.app.core.images.actions.delete-image.service', 'horizon.app.core.images.actions.launch-instance.service', @@ -38,6 +39,7 @@ function registerImageActions( registry, + editService, createVolumeService, deleteImageService, launchInstanceService, @@ -60,6 +62,13 @@ text: gettext('Create Volume') } }) + .append({ + id: 'editAction', + service: editService, + template: { + text: gettext('Edit Image') + } + }) .append({ id: 'updateMetadataService', service: updateMetadataService, diff --git a/openstack_dashboard/static/app/core/images/actions/edit.action.service.js b/openstack_dashboard/static/app/core/images/actions/edit.action.service.js new file mode 100644 index 0000000000..641e315642 --- /dev/null +++ b/openstack_dashboard/static/app/core/images/actions/edit.action.service.js @@ -0,0 +1,176 @@ +/** + * 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.images') + .factory('horizon.app.core.images.actions.edit.service', editService); + + editService.$inject = [ + '$q', + 'horizon.app.core.images.events', + 'horizon.app.core.images.actions.editWorkflow', + 'horizon.app.core.metadata.service', + 'horizon.app.core.openstack-service-api.glance', + 'horizon.app.core.openstack-service-api.policy', + 'horizon.app.core.openstack-service-api.userSession', + 'horizon.framework.util.q.extensions', + 'horizon.framework.widgets.modal.wizard-modal.service', + 'horizon.framework.widgets.toast.service' + ]; + + /** + * @ngDoc factory + * @name horizon.app.core.images.actions.editService + * @Description A service to open the user wizard. + */ + function editService( + $q, + events, + editWorkflow, + metadataService, + glance, + policy, + userSessionService, + $qExtensions, + wizardModalService, + toast + ) { + var message = { + success: gettext('Image %s was successfully updated.'), + successMetadata: gettext('Image metadata %s was successfully updated.') + }; + var modifyImagePolicyCheck, scope; + + var model = { + image: {}, + metadata: {} + }; + + var service = { + initScope: initScope, + allowed: allowed, + perform: perform + }; + + return service; + + ////////////// + + // include this function in your service + // if you plan to emit events to the parent controller + function initScope($scope) { + var watchImageChange = $scope.$on(events.IMAGE_CHANGED, onImageChange); + var watchMetadataChange = $scope.$on(events.IMAGE_METADATA_CHANGED, onMetadataChange); + + scope = $scope; + modifyImagePolicyCheck = policy.ifAllowed({rules: [['image', 'modify_image']]}); + + $scope.$on('$destroy', destroy); + + function destroy() { + watchImageChange(); + watchMetadataChange(); + } + } + + function onImageChange(e, image) { + model.image = image; + e.stopPropagation(); + } + + function onMetadataChange(e, metadata) { + model.metadata = metadata; + e.stopPropagation(); + } + + function allowed(image) { + return $q.all([ + modifyImagePolicyCheck, + userSessionService.isCurrentProject(image.owner), + isActive(image) + ]); + } + + function perform(image) { + var deferred = glance.getImage(image.id); + deferred.then(onLoad); + scope.imagePromise = deferred; + + function onLoad(response) { + var localImage = response.data; + model.image = localImage; + } + + return wizardModalService.modal({ + scope: scope, + workflow: editWorkflow, + submit: submit + }).result; + } + + function submit() { + return saveMetadata().then(onSaveMetadata, onFailMetadata); + } + + function onFailMetadata() { + glance.updateImage(model.image).then(onUpdateImageSuccess, onUpdateImageFail); + } + + function onSaveMetadata() { + toast.add('success', interpolate(message.successMetadata, [model.image.name])); + glance.updateImage(model.image).then(onUpdateImageSuccess, onUpdateImageFail); + } + + function onUpdateImageSuccess() { + toast.add('success', interpolate(message.success, [model.image.name])); + scope.$emit(events.UPDATE_SUCCESS, model.image); + return { + // This will be filled out with useful information as it is + // decided upon. + }; + } + + function onUpdateImageFail() { + scope.$emit(events.UPDATE_SUCCESS, model.image); + } + + function saveMetadata() { + var imageId = model.image.id; + var deferred = $q.defer(); + + metadataService.getMetadata('image', imageId).then(onMetadataGet); + + function onMetadataGet(response) { + var removed = angular.copy(response.data); + angular.forEach(model.metadata, function(value, key) { + delete removed[key]; + }); + + deferred.resolve( + metadataService.editMetadata('image', imageId, model.metadata, Object.keys(removed)) + ); + } + + return deferred.promise; + } + + function isActive(image) { + return $qExtensions.booleanAsPromise(image.status === 'active'); + } + + } // end of editService +})(); // end of IIFE diff --git a/openstack_dashboard/static/app/core/images/actions/edit.action.service.spec.js b/openstack_dashboard/static/app/core/images/actions/edit.action.service.spec.js new file mode 100644 index 0000000000..576896d049 --- /dev/null +++ b/openstack_dashboard/static/app/core/images/actions/edit.action.service.spec.js @@ -0,0 +1,264 @@ +/** + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use self 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.images.actions.edit.service', function() { + var existingMetadata = {p1: '1', p2: '2'}; + + var metadataService = { + getMetadata: function() { + return { + then: function(callback) { + callback({ + data: existingMetadata + }); + } + }; + }, + editMetadata: function() { + return { + then: function(callback) { + callback(); + } + }; + } + }; + + var wizardModalService = { + modal: function () { + return { result: {} }; + } + }; + + var glanceAPI = { + updateImage: function(image) { + return { + then: function(callback) { + callback({data: image}); + } + }; + }, + getImage: function() { + var imageLoad = $q.defer(); + imageLoad.resolve({data: testImage}); + return imageLoad.promise; + } + }; + + var policyAPI = { + ifAllowed: function() { + return { + success: function(callback) { + callback({allowed: true}); + } + }; + } + }; + + var userSession = { + isCurrentProject: function() { + deferred.resolve(); + return deferred.promise; + } + }; + + var service, events, $scope, $q, toast, deferred, testImage, $timeout; + + /////////////////////// + + beforeEach(module('horizon.framework')); + beforeEach(module('horizon.app.core')); + + beforeEach(module(function($provide) { + $provide.value('horizon.app.core.openstack-service-api.glance', glanceAPI); + $provide.value('horizon.app.core.openstack-service-api.userSession', userSession); + $provide.value('horizon.app.core.openstack-service-api.policy', policyAPI); + $provide.value('horizon.app.core.metadata.service', metadataService); + $provide.value('horizon.framework.widgets.modal.wizard-modal.service', wizardModalService); + })); + + beforeEach(inject(function($injector, _$rootScope_, _$q_, _$timeout_) { + $scope = _$rootScope_.$new(); + $q = _$q_; + service = $injector.get('horizon.app.core.images.actions.edit.service'); + events = $injector.get('horizon.app.core.images.events'); + toast = $injector.get('horizon.framework.widgets.toast.service'); + service.initScope($scope); + deferred = $q.defer(); + $timeout = _$timeout_; + })); + + describe('perform', function() { + it('should open the modal with the correct parameters', function() { + spyOn(wizardModalService, 'modal').and.callThrough(); + + testImage = {id: '12'}; + service.initScope($scope); + service.perform(testImage); + $timeout.flush(); + + expect(wizardModalService.modal).toHaveBeenCalled(); + expect($scope.imagePromise).toBeDefined(); + + var modalArgs = wizardModalService.modal.calls.argsFor(0)[0]; + expect(modalArgs.scope).toEqual($scope); + expect(modalArgs.workflow).toBeDefined(); + }); + + it('should update image in glance, update metadata and raise event', function() { + testImage = { name: 'Test', id: '2' }; + var newImage = { name: 'Test2', id: '2' }; + var newMetadata = {p1: '11', p3: '3'}; + + spyOn($scope, '$emit').and.callThrough(); + spyOn(glanceAPI, 'updateImage').and.callThrough(); + spyOn(metadataService, 'editMetadata').and.callThrough(); + spyOn(toast, 'add').and.callThrough(); + spyOn(wizardModalService, 'modal').and.callThrough(); + + service.initScope($scope); + service.perform(testImage); + $timeout.flush(); + + $scope.$emit(events.IMAGE_CHANGED, newImage); + $scope.$emit(events.IMAGE_METADATA_CHANGED, newMetadata); + + var modalArgs = wizardModalService.modal.calls.argsFor(0)[0]; + modalArgs.submit(); + $scope.$apply(); + + expect(glanceAPI.updateImage).toHaveBeenCalledWith(newImage); + expect(metadataService.editMetadata) + .toHaveBeenCalledWith('image', '2', newMetadata, ['p2']); + expect(toast.add) + .toHaveBeenCalledWith('success', 'Image Test2 was successfully updated.'); + expect(toast.add.calls.count()).toBe(2); + expect($scope.$emit) + .toHaveBeenCalledWith('horizon.app.core.images.UPDATE_SUCCESS', newImage); + }); + + it('should raise event even if update meta data fails', function() { + var image = { name: 'Test', id: '2' }; + var newImage = { name: 'Test2', id: '2' }; + var newMetadata = {prop1: '11', prop3: '3'}; + + var failedPromise = function() { + return { + then: function(callback, errorCallback) { + errorCallback(); + } + }; + }; + + spyOn(wizardModalService, 'modal').and.callThrough(); + spyOn(glanceAPI, 'updateImage').and.callThrough(); + spyOn(metadataService, 'editMetadata').and.callFake(failedPromise); + spyOn($scope, '$emit').and.callThrough(); + spyOn(toast, 'add').and.callThrough(); + + service.initScope($scope); + service.perform(image); + $scope.$apply(); + + $scope.$emit(events.IMAGE_CHANGED, newImage); + $scope.$emit(events.IMAGE_METADATA_CHANGED, newMetadata); + + var modalArgs = wizardModalService.modal.calls.argsFor(0)[0]; + modalArgs.submit(); + $scope.$apply(); + + expect(toast.add.calls.count()).toBe(1); + expect($scope.$emit) + .toHaveBeenCalledWith('horizon.app.core.images.UPDATE_SUCCESS', newImage); + }); + + it('should destroy the event watchers', function() { + testImage = { name: 'Test', id: '2' }; + var newImage = { name: 'Test2', id: '2' }; + var newMetadata = {p1: '11', p3: '3'}; + + spyOn(wizardModalService, 'modal').and.callThrough(); + spyOn(glanceAPI, 'updateImage').and.callThrough(); + spyOn(metadataService, 'editMetadata').and.callThrough(); + spyOn(toast, 'add').and.callThrough(); + + service.initScope($scope); + service.perform(testImage); + $scope.$apply(); + + $scope.$emit('$destroy'); + $scope.$emit(events.IMAGE_CHANGED, newImage); + $scope.$emit(events.IMAGE_METADATA_CHANGED, newMetadata); + + var modalArgs = wizardModalService.modal.calls.argsFor(0)[0]; + modalArgs.submit(); + $scope.$apply(); + + expect(glanceAPI.updateImage).toHaveBeenCalledWith(testImage); + expect(metadataService.editMetadata) + .toHaveBeenCalledWith('image', testImage.id, {}, ['p1', 'p2']); + expect(toast.add.calls.count()).toBe(2); + }); + }); + + describe('edit', function() { + it('should allow edit if image can be edited', function() { + var image = {owner: 'project', status: 'active'}; + var allowed = service.allowed(image); + permissionShouldPass(allowed); + $scope.$apply(); + }); + + it('should not allow edit if image is not owned by user', function() { + deferred.reject(); + var image = {owner: 'doesnt_matter', status: 'active'}; + var allowed = service.allowed(image); + permissionShouldFail(allowed); + $scope.$apply(); + }); + + it('should not allow edit if image status is not active', function() { + var image = {owner: 'project', status: 'not_active'}; + var allowed = service.allowed(image); + permissionShouldFail(allowed); + $scope.$apply(); + }); + + function permissionShouldFail(permissions) { + permissions.then( + function() { + expect(false).toBe(true); + }, + function() { + expect(true).toBe(true); + }); + } + + function permissionShouldPass(permissions) { + permissions.then( + function() { + expect(true).toBe(true); + }, + function() { + expect(false).toBe(true); + }); + } + + }); + + }); + +})(); diff --git a/openstack_dashboard/static/app/core/images/actions/edit.workflow.service.js b/openstack_dashboard/static/app/core/images/actions/edit.workflow.service.js new file mode 100644 index 0000000000..c29b1bfafb --- /dev/null +++ b/openstack_dashboard/static/app/core/images/actions/edit.workflow.service.js @@ -0,0 +1,57 @@ +/** + * + * 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.images') + .factory('horizon.app.core.images.actions.editWorkflow', editWorkflow); + + editWorkflow.$inject = [ + 'horizon.app.core.images.basePath', + 'horizon.app.core.workflow.factory', + 'horizon.framework.util.i18n.gettext' + ]; + + /** + * @ngdoc factory + * @name horizon.app.core.images.editWorkflow + * @description A workflow for the edit image action. + */ + function editWorkflow(basePath, workflowService, gettext) { + var workflow = workflowService({ + title: gettext('Edit Image'), + btnText: { finish: gettext('Update Image') }, + steps: [ + { + title: gettext('Image Details'), + templateUrl: basePath + 'steps/edit-image/edit-image.html', + helpUrl: basePath + 'steps/edit-image/edit-image.help.html', + formName: 'imageForm' + }, + { + title: gettext('Metadata'), + templateUrl: basePath + 'steps/update-metadata/update-metadata.html', + helpUrl: basePath + 'steps/update-metadata/update-metadata.help.html', + formName: 'updateMetadataForm' + } + ] + }); + + return workflow; + } + +})(); diff --git a/openstack_dashboard/static/app/core/images/actions/edit.workflow.service.spec.js b/openstack_dashboard/static/app/core/images/actions/edit.workflow.service.spec.js new file mode 100644 index 0000000000..2ae96cd63b --- /dev/null +++ b/openstack_dashboard/static/app/core/images/actions/edit.workflow.service.spec.js @@ -0,0 +1,54 @@ +/** + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use self 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.images.actions.editWorkflow', function() { + + var mockWorkflow = function(params) { + return params; + }; + + var service; + + /////////////////////// + + beforeEach(module('horizon.framework.util')); + + beforeEach(module('horizon.app.core')); + beforeEach(module('horizon.app.core.workflow', function($provide) { + $provide.value('horizon.app.core.workflow.factory', mockWorkflow); + })); + + beforeEach(module('horizon.app.core.images', function($provide) { + $provide.constant('horizon.app.core.images.basePath', '/dummy/'); + })); + + beforeEach(inject(function($injector) { + service = $injector.get('horizon.app.core.images.actions.editWorkflow'); + })); + + it('create the workflow for editing image', function() { + expect(service.title).toEqual('Edit Image'); + var steps = service.steps; + expect(steps.length).toEqual(2); + expect(steps[0].templateUrl).toEqual('/dummy/steps/edit-image/edit-image.html'); + expect(steps[1].templateUrl).toEqual('/dummy/steps/update-metadata/update-metadata.html'); + }); + + }); + +})(); diff --git a/openstack_dashboard/static/app/core/images/images.module.js b/openstack_dashboard/static/app/core/images/images.module.js index 227c8fb3d2..6611b1b7c8 100644 --- a/openstack_dashboard/static/app/core/images/images.module.js +++ b/openstack_dashboard/static/app/core/images/images.module.js @@ -29,6 +29,8 @@ .module('horizon.app.core.images', ['ngRoute', 'horizon.app.core.images.actions']) .constant('horizon.app.core.images.events', events()) .constant('horizon.app.core.images.non_bootable_image_types', ['aki', 'ari']) + .constant('horizon.app.core.images.validationRules', validationRules()) + .constant('horizon.app.core.images.imageFormats', imageFormats()) .constant('horizon.app.core.images.resourceType', 'OS::Glance::Image') .run(registerImageType) .config(config); @@ -107,6 +109,39 @@ }); } + /** + * @ngdoc constant + * @name horizon.app.core.images.validationRules + * @description constants for use in validation fields + */ + function validationRules() { + return { + integer: /^[0-9]+$/, + fieldMaxLength: 255 + }; + } + + /** + * @ngdoc constant + * @name horizon.app.core.images.imageFormats + * @description constants for list of image types in dropdowns + */ + function imageFormats() { + return { + iso: gettext('ISO - Optical Disk Image'), + ova: gettext('OVA - Open Virtual Appliance'), + qcow2: gettext('QCOW2 - QEMU Emulator'), + raw: gettext('Raw'), + vdi: gettext('VDI - Virtual Disk Image'), + vhd: gettext('VHD - Virtual Hard Disk'), + vmdk: gettext('VMDK - Virtual Machine Disk'), + aki: gettext('AKI - Amazon Kernel Image'), + ami: gettext('AMI - Amazon Machine Image'), + ari: gettext('ARI - Amazon Ramdisk Image'), + docker: gettext('Docker') + }; + } + /** * @ngdoc value * @name horizon.app.core.images.events @@ -116,7 +151,10 @@ return { DELETE_SUCCESS: 'horizon.app.core.images.DELETE_SUCCESS', VOLUME_CHANGED: 'horizon.app.core.images.VOLUME_CHANGED', - UPDATE_METADATA_SUCCESS: 'horizon.app.core.images.UPDATE_METADATA_SUCCESS' + UPDATE_METADATA_SUCCESS: 'horizon.app.core.images.UPDATE_METADATA_SUCCESS', + UPDATE_SUCCESS: 'horizon.app.core.images.UPDATE_SUCCESS', + IMAGE_CHANGED: 'horizon.app.core.images.IMAGE_CHANGED', + IMAGE_METADATA_CHANGED: 'horizon.app.core.images.IMAGE_METADATA_CHANGED' }; } diff --git a/openstack_dashboard/static/app/core/images/images.module.spec.js b/openstack_dashboard/static/app/core/images/images.module.spec.js index 0df3a56333..8fe40f6b44 100644 --- a/openstack_dashboard/static/app/core/images/images.module.spec.js +++ b/openstack_dashboard/static/app/core/images/images.module.spec.js @@ -89,7 +89,19 @@ { templateUrl: staticUrl + 'app/core/images/detail/image-detail.html'} ]); }); + }); + describe('horizon.app.core.images.imageFormats constant', function() { + var imageFormats; + + beforeEach(module('horizon.app.core.images')); + beforeEach(inject(function ($injector) { + imageFormats = $injector.get('horizon.app.core.images.imageFormats'); + })); + + it('should be defined', function() { + expect(Object.keys(imageFormats).length).toEqual(11); + }); }); })(); diff --git a/openstack_dashboard/static/app/core/images/steps/edit-image/edit-image.controller.js b/openstack_dashboard/static/app/core/images/steps/edit-image/edit-image.controller.js new file mode 100644 index 0000000000..237c40b1d4 --- /dev/null +++ b/openstack_dashboard/static/app/core/images/steps/edit-image/edit-image.controller.js @@ -0,0 +1,120 @@ +/** + * + * 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.images') + .controller('horizon.app.core.images.steps.EditImageController', EditImageController); + + EditImageController.$inject = [ + '$scope', + 'horizon.app.core.images.events', + 'horizon.app.core.images.imageFormats', + 'horizon.app.core.images.validationRules', + 'horizon.app.core.openstack-service-api.settings' + ]; + + /** + * @ngdoc controller + * @name horizon.app.core.images.steps.EditImageController + * @description + * This controller is use for updating an image. + */ + function EditImageController( + $scope, + events, + imageFormats, + validationRules, + settings + ) { + var ctrl = this; + + settings.getSettings().then(getConfiguredFormats); + ctrl.diskFormats = []; + ctrl.validationRules = validationRules; + + ctrl.imageProtectedOptions = [ + { label: gettext('Yes'), value: true }, + { label: gettext('No'), value: false } + ]; + + ctrl.imageVisibilityOptions = [ + { label: gettext('Public'), value: 'public'}, + { label: gettext('Private'), value: 'private' } + ]; + + ctrl.setFormats = setFormats; + + $scope.imagePromise.then(init); + + var imageChangedWatcher; + + $scope.$on('$destroy', function() { + imageChangedWatcher(); + }); + + /////////////////////////// + + function getConfiguredFormats(response) { + var settingsFormats = response.OPENSTACK_IMAGE_FORMATS; + var dupe = angular.copy(imageFormats); + angular.forEach(dupe, function stripUnknown(name, key) { + if (settingsFormats.indexOf(key) === -1) { + delete dupe[key]; + } + }); + + ctrl.imageFormats = dupe; + } + + function init(response) { + ctrl.image = response.data; + ctrl.image.kernel = ctrl.image.properties.kernel_id; + ctrl.image.ramdisk = ctrl.image.properties.ramdisk_id; + ctrl.image.architecture = ctrl.image.properties.architecture; + ctrl.image.visibility = ctrl.image.is_public ? 'public' : 'private'; + ctrl.image_format = ctrl.image.disk_format; + if (ctrl.image.container_format === 'docker') { + ctrl.image_format = 'docker'; + ctrl.image.disk_format = 'raw'; + } + setFormats(); + imageChangedWatcher = $scope.$watchCollection('ctrl.image', watchImageCollection); + } + + // emits new data to parent listeners + function watchImageCollection(newValue, oldValue) { + if (newValue !== oldValue) { + $scope.$emit(events.IMAGE_CHANGED, newValue); + } + } + + function setFormats() { + ctrl.image.container_format = 'bare'; + if (['aki', 'ami', 'ari'].indexOf(ctrl.image_format) > -1) { + ctrl.image.container_format = ctrl.image_format; + } + ctrl.image.disk_format = ctrl.image_format; + if (ctrl.image_format === 'docker') { + ctrl.image.container_format = 'docker'; + ctrl.image.disk_format = 'raw'; + } + } + + } // end of controller + +})(); diff --git a/openstack_dashboard/static/app/core/images/steps/edit-image/edit-image.controller.spec.js b/openstack_dashboard/static/app/core/images/steps/edit-image/edit-image.controller.spec.js new file mode 100644 index 0000000000..38ab9e16dd --- /dev/null +++ b/openstack_dashboard/static/app/core/images/steps/edit-image/edit-image.controller.spec.js @@ -0,0 +1,169 @@ +/** + * (c) Copyright 2016 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'; + + describe('horizon.app.core.images edit image controller', function() { + + var controller, $scope, events, $q, settingsCall, $timeout; + + /////////////////////// + + beforeEach(module('horizon.framework')); + + beforeEach(module('horizon.app.core')); + beforeEach(module('horizon.app.core.images')); + + beforeEach(inject(function ($injector, _$rootScope_, _$q_, _$timeout_) { + $scope = _$rootScope_.$new(); + $q = _$q_; + $timeout = _$timeout_; + + events = $injector.get('horizon.app.core.images.events'); + controller = $injector.get('$controller'); + })); + + function createController() { + var settings = { + getSettings: function() { + settingsCall = $q.defer(); + return settingsCall.promise; + } + }; + return controller('horizon.app.core.images.steps.EditImageController as ctrl', { + $scope: $scope, + events: events, + 'horizon.app.core.openstack-service-api.settings': settings + }); + } + + function setImagePromise(image) { + var deferred = $q.defer(); + deferred.resolve({data: image}); + $scope.imagePromise = deferred.promise; + } + + it('should have options for visibility and protected', function() { + setImagePromise({id: '1', container_format: 'bare', is_public: false, properties: []}); + var ctrl = createController(); + + expect(ctrl.imageVisibilityOptions.length).toEqual(2); + expect(ctrl.imageProtectedOptions.length).toEqual(2); + }); + + it('should map is_public', function() { + setImagePromise({id: '1', container_format: 'bare', is_public: false, properties: []}); + var ctrl = createController(); + $timeout.flush(); + + expect(ctrl.image.visibility).toEqual('private'); + }); + + it('reads the data format settings', function() { + setImagePromise({id: '1', container_format: 'bare', is_public: false, properties: []}); + var ctrl = createController(); + settingsCall.resolve({OPENSTACK_IMAGE_FORMATS: ['aki', 'ami']}); + $timeout.flush(); + expect(ctrl.imageFormats.aki).toBeDefined(); + expect(ctrl.imageFormats.ami).toBeDefined(); + expect(Object.keys(ctrl.imageFormats).length).toBe(2); + }); + + it('should initialize image values correctly', function() { + setImagePromise({ + id: '1', + container_format: 'bare', + disk_format: 'ova', + is_public: true, + properties: [] + }); + var ctrl = createController(); + $timeout.flush(); + + expect(ctrl.image.disk_format).toEqual('ova'); + expect(ctrl.image.visibility).toEqual('public'); + }); + + it('should set local image_format to docker when container is docker', function() { + setImagePromise({id: '1', disk_format: 'raw', container_format: 'docker', + is_public: false, properties: []}); + var ctrl = createController(); + $timeout.flush(); + + expect(ctrl.image_format).toEqual('docker'); + }); + + it('should set container to aki when disk format is same', function() { + setImagePromise({id: '1', disk_format: 'aki', container_format: '', + is_public: false, properties: []}); + var ctrl = createController(); + $timeout.flush(); + + expect(ctrl.image.container_format).toEqual('aki'); + }); + + it('should set container to ami when disk format is same', function() { + setImagePromise({id: '1', disk_format: 'ami', container_format: '', + is_public: false, properties: []}); + var ctrl = createController(); + $timeout.flush(); + + expect(ctrl.image.container_format).toEqual('ami'); + }); + + it('should set container to ari when disk format is same', function() { + setImagePromise({id: '1', disk_format: 'ari', container_format: '', + is_public: false, properties: []}); + var ctrl = createController(); + $timeout.flush(); + + expect(ctrl.image.container_format).toEqual('ari'); + }); + + it('should emit events on image change', function() { + spyOn($scope, '$emit').and.callThrough(); + + setImagePromise({id: '1', container_format: 'bare', properties: []}); + var ctrl = createController(); + ctrl.image = 1; + $scope.$apply(); + + ctrl.image = 2; + $scope.$apply(); + + expect($scope.$emit).toHaveBeenCalledWith('horizon.app.core.images.IMAGE_CHANGED', 2); + }); + + it("should destroy the image changed watcher when the controller is destroyed", function() { + setImagePromise({id: '1', container_format: 'bare', properties: []}); + spyOn($scope, '$emit').and.callThrough(); + + var ctrl = createController(); + ctrl.image = 1; + $scope.$apply(); + + $scope.$emit("$destroy"); + $scope.$emit.calls.reset(); + + ctrl.image = 2; + $scope.$apply(); + + expect($scope.$emit).not.toHaveBeenCalled(); + }); + + }); +})(); diff --git a/openstack_dashboard/static/app/core/images/steps/edit-image/edit-image.help.html b/openstack_dashboard/static/app/core/images/steps/edit-image/edit-image.help.html new file mode 100644 index 0000000000..b0cd31df52 --- /dev/null +++ b/openstack_dashboard/static/app/core/images/steps/edit-image/edit-image.help.html @@ -0,0 +1,4 @@ +
+

Description

+

Use this page to edit information about the image.

+
diff --git a/openstack_dashboard/static/app/core/images/steps/edit-image/edit-image.html b/openstack_dashboard/static/app/core/images/steps/edit-image/edit-image.html new file mode 100644 index 0000000000..0309010f01 --- /dev/null +++ b/openstack_dashboard/static/app/core/images/steps/edit-image/edit-image.html @@ -0,0 +1,147 @@ +
+ +
+ +

Image Detail

+ +
+
+
+
+ + +

+ An image name less than 256 characters is required. +

+
+
+
+
+ + +

+ An image description less than 256 characters is required. +

+
+
+
+
+ +
+
+
+
+ + +
+
+
+
+ +

Image Requirements

+
+ +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ +

Image Sharing

+
+ +
+
+
+
+ +
+
+ +
+
+
+
+
+
+ +
+
+ +
+
+
+
+
+ +
+ +
+ +
diff --git a/openstack_dashboard/static/app/core/images/steps/update-metadata/update-metadata.controller.js b/openstack_dashboard/static/app/core/images/steps/update-metadata/update-metadata.controller.js new file mode 100644 index 0000000000..e137e42c4c --- /dev/null +++ b/openstack_dashboard/static/app/core/images/steps/update-metadata/update-metadata.controller.js @@ -0,0 +1,80 @@ +/* + * 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.images') + .controller('horizon.app.core.images.steps.UpdateMetadataController', UpdateMetadataController); + + UpdateMetadataController.$inject = [ + '$scope', + '$q', + 'horizon.app.core.images.events', + 'horizon.app.core.metadata.service', + 'horizon.framework.widgets.metadata.tree.service' + ]; + + /** + * @ngdoc controller + * @name MetadataController + * @description + * Controller used by Image for Update Metadata + */ + function UpdateMetadataController( + $scope, + $q, + events, + metadataService, + metadataTreeService + ) { + var ctrl = this; + + ctrl.tree = new metadataTreeService.Tree([], []); + + /* eslint-enable angular/ng_controller_as */ + $scope.$watchCollection(getTree, onMetadataChanged); + /* eslint-enable angular/ng_controller_as */ + + $scope.imagePromise.then(init); + + //////////////////////////////// + + function init(response) { + var image = response.data; + $q.all({ + available: metadataService.getNamespaces('image'), + existing: metadataService.getMetadata('image', image.id) + }).then(onMetadataGet); + } + + function onMetadataGet(response) { + ctrl.tree = new metadataTreeService.Tree( + response.available.data.items, + response.existing.data + ); + } + + function getTree() { + return ctrl.tree.getExisting(); + } + + function onMetadataChanged(newValue, oldValue) { + if (newValue !== oldValue) { + $scope.$emit(events.IMAGE_METADATA_CHANGED, newValue); + } + } + + } +})(); diff --git a/openstack_dashboard/static/app/core/images/steps/update-metadata/update-metadata.controller.spec.js b/openstack_dashboard/static/app/core/images/steps/update-metadata/update-metadata.controller.spec.js new file mode 100644 index 0000000000..60ce3cba8b --- /dev/null +++ b/openstack_dashboard/static/app/core/images/steps/update-metadata/update-metadata.controller.spec.js @@ -0,0 +1,123 @@ +/* + * + * 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.images', function () { + + describe('controller.UpdateMetadataController', function () { + var mockTree = { + getExisting: function() {} + }; + + var availableMetadata = {id: 'availableMetadata'}; + var existingMetadata = {id: 'existingMetadata'}; + + var metadataService = { + getNamespaces: function() { + return { + then: function(callback) { + callback({ + data: { + items: availableMetadata + } + }); + } + }; + }, + getMetadata: function() { + return { + then: function(callback) { + callback({ + data: existingMetadata + }); + } + }; + } + }; + + var metadataTreeService = { + Tree: function() {} + }; + + var $controller, $scope, $q; + + beforeEach(module('horizon.framework')); + beforeEach(module('horizon.framework.widgets')); + beforeEach(module('horizon.framework.widgets.metadata.tree')); + beforeEach(module('horizon.app.core.images')); + + beforeEach(module(function($provide) { + $provide.value('horizon.framework.widgets.metadata.tree.service', metadataTreeService); + $provide.value('horizon.app.core.metadata.service', metadataService); + })); + + beforeEach(inject(function($injector, _$rootScope_, _$q_) { + $controller = $injector.get('$controller'); + $scope = _$rootScope_.$new(); + $q = _$q_; + })); + + it('should setup up the metadata tree', function() { + var deferred = $q.defer(); + deferred.resolve({data: {id: '1'}}); + $scope.imagePromise = deferred.promise; + + spyOn(metadataTreeService, 'Tree').and.returnValue(mockTree); + spyOn(metadataService, 'getNamespaces').and.callThrough(); + spyOn(metadataService, 'getMetadata').and.callThrough(); + + var ctrl = createController(); + $scope.$apply(); + + expect(ctrl.tree).toEqual(mockTree); + expect(metadataTreeService.Tree).toHaveBeenCalledWith([], []); + expect(metadataTreeService.Tree).toHaveBeenCalledWith(availableMetadata, existingMetadata); + }); + + it('should emit imageMetadataChanged event when metadata changes', function() { + var deferred = $q.defer(); + deferred.resolve({data: {id: '1'}}); + $scope.imagePromise = deferred.promise; + spyOn(metadataTreeService, 'Tree').and.returnValue(mockTree); + + createController(); + + spyOn($scope, '$emit').and.callThrough(); + var mockGetExisting = spyOn(mockTree, 'getExisting'); + + mockGetExisting.and.returnValue('1'); + $scope.$apply(); + + mockGetExisting.and.returnValue('2'); + $scope.$apply(); + + expect($scope.$emit).toHaveBeenCalledWith( + 'horizon.app.core.images.IMAGE_METADATA_CHANGED', + '2' + ); + }); + + function createController() { + return $controller('horizon.app.core.images.steps.UpdateMetadataController', { + '$scope': $scope, + 'metadataService': metadataService, + 'metadataTreeService': metadataTreeService + }); + } + + }); + }); +})(); diff --git a/openstack_dashboard/static/app/core/images/steps/update-metadata/update-metadata.help.html b/openstack_dashboard/static/app/core/images/steps/update-metadata/update-metadata.help.html new file mode 100644 index 0000000000..a7217de552 --- /dev/null +++ b/openstack_dashboard/static/app/core/images/steps/update-metadata/update-metadata.help.html @@ -0,0 +1,16 @@ +
+

Metadata Help

+

You can add arbitrary metadata to your image.

+

+ Metadata is used to provide additional information about the + image. Sometimes this information is only used for sorting and viewing. + In some installations this information may affect how the instance is + deployed or behaves. +

+

+ Metadata is a collection of key-value pairs associated with an instance. + The maximum length for each metadata key and value is 255 characters. +

+
+ + diff --git a/openstack_dashboard/static/app/core/images/steps/update-metadata/update-metadata.html b/openstack_dashboard/static/app/core/images/steps/update-metadata/update-metadata.html new file mode 100644 index 0000000000..8f5cac6a36 --- /dev/null +++ b/openstack_dashboard/static/app/core/images/steps/update-metadata/update-metadata.html @@ -0,0 +1,6 @@ +
+

Image Metadata

+
+ +
+
diff --git a/openstack_dashboard/static/app/core/images/table/images.controller.js b/openstack_dashboard/static/app/core/images/table/images.controller.js index d69064759b..2338c416d2 100644 --- a/openstack_dashboard/static/app/core/images/table/images.controller.js +++ b/openstack_dashboard/static/app/core/images/table/images.controller.js @@ -65,6 +65,7 @@ ctrl.imageResourceType = typeRegistry.getResourceType(imageResourceType); var deleteWatcher = $scope.$on(events.DELETE_SUCCESS, onDeleteSuccess); + var updateWatcher = $scope.$on(events.UPDATE_SUCCESS, onUpdateSuccess); $scope.$on('$destroy', destroy); @@ -95,6 +96,12 @@ applyMetadataDefinitions(); } + function onUpdateSuccess(e, image) { + e.stopPropagation(); + ctrl.imagesSrc = difference(ctrl.imagesSrc, [image.id], 'id'); + ctrl.imagesSrc.push(image); + } + function onDeleteSuccess(e, removedImageIds) { ctrl.imagesSrc = difference(ctrl.imagesSrc, removedImageIds, 'id'); e.stopPropagation(); @@ -115,6 +122,7 @@ } function destroy() { + updateWatcher(); deleteWatcher(); } diff --git a/openstack_dashboard/static/app/core/images/table/images.controller.spec.js b/openstack_dashboard/static/app/core/images/table/images.controller.spec.js index 4169bde50d..9200dc5747 100644 --- a/openstack_dashboard/static/app/core/images/table/images.controller.spec.js +++ b/openstack_dashboard/static/app/core/images/table/images.controller.spec.js @@ -18,15 +18,20 @@ 'use strict'; describe('horizon.app.core.images table controller', function() { + var images = [{id: '1', visibility: 'public', filtered_visibility: 'Public'}, + {id: '2', is_public: false, owner: 'not_me', filtered_visibility: 'Shared with Me'}]; var glanceAPI = { getImages: function () { return { data: { items: [ - {id: '1', visibility: 'public'}, - {id: '2', is_public: false, owner: 'not_me'} + {id: '1', visibility: 'public', filtered_visibility: 'Public'}, + {id: '2', is_public: false, owner: 'not_me', filtered_visibility: 'Shared with Me'} ] + }, + success: function(callback) { + callback({items : angular.copy(images)}); } }; }, @@ -133,7 +138,16 @@ expect($scope.$emit).toHaveBeenCalledWith('hzTable:clearSelected'); }); - it('should destroy the event watchers', function() { + it('should refresh images after update', function() { + var ctrl = createController(); + expect(ctrl.imagesSrc).toEqual(images); + + $scope.$emit(events.UPDATE_SUCCESS, {id: '1', name: 'name_new'}); + + expect(ctrl.imagesSrc.filter(function (x) { return x.id === '1'; })[0].name).toBe('name_new'); + }); + + it('should destroy the event watcher for delete', function() { var ctrl = createController(); $scope.$emit('$destroy'); @@ -145,5 +159,23 @@ ]); }); + it('should destroy the event watcher for update', function() { + var ctrl = createController(); + + $scope.$emit('$destroy'); + $scope.$emit(events.UPDATE_SUCCESS, {id: '1', name: 'name_new'}); + + expect(ctrl.imagesSrc).toEqual(images); + }); + + it('should destroy the event watcher for create', function() { + var ctrl = createController(); + + $scope.$emit('$destroy'); + $scope.$emit(events.createSuccess, {id: '3'}); + + expect(ctrl.imagesSrc).toEqual(images); + }); + }); })();