diff --git a/openstack_dashboard/api/rest/nova.py b/openstack_dashboard/api/rest/nova.py index 68a3f0eb9e..682cc52224 100644 --- a/openstack_dashboard/api/rest/nova.py +++ b/openstack_dashboard/api/rest/nova.py @@ -1,4 +1,3 @@ - # Copyright 2014, Rackspace, US, Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); @@ -239,7 +238,7 @@ class Servers(generic.View): ) except KeyError as e: raise rest_utils.AjaxError(400, 'missing required parameter ' - "'%s'" % e.args[0]) + "'%s'" % e.args[0]) kw = {} for name in self._optional_create: if name in request.DATA: @@ -352,6 +351,33 @@ class Flavors(generic.View): result['items'].append(d) return result + @rest_utils.ajax(data_required=True) + def post(self, request): + flavor_access = request.DATA.get('flavor_access', []) + flavor_id = request.DATA['id'] + is_public = not flavor_access + + flavor = api.nova.flavor_create(request, + name=request.DATA['name'], + memory=request.DATA['ram'], + vcpu=request.DATA['vcpus'], + disk=request.DATA['disk'], + ephemeral=request + .DATA['OS-FLV-EXT-DATA:ephemeral'], + swap=request.DATA['swap'], + flavorid=flavor_id, + is_public=is_public + ) + + for project in flavor_access: + api.nova.add_tenant_to_flavor( + request, flavor.id, project.get('id')) + + return rest_utils.CreatedResponse( + '/api/nova/flavors/%s' % flavor.id, + flavor.to_dict() + ) + @urls.register class Flavor(generic.View): @@ -368,14 +394,64 @@ class Flavor(generic.View): Example GET: http://localhost/api/nova/flavors/1 """ - get_extras = request.GET.get('get_extras') - get_extras = bool(get_extras and get_extras.lower() == 'true') + get_extras = self.extract_boolean(request, 'get_extras') + get_access_list = self.extract_boolean(request, 'get_access_list') flavor = api.nova.flavor_get(request, flavor_id, get_extras=get_extras) + result = flavor.to_dict() + # Bug: nova API stores and returns empty string when swap equals 0 + # https://bugs.launchpad.net/nova/+bug/1408954 + if 'swap' in result and result['swap'] == '': + result['swap'] = 0 if get_extras: result['extras'] = flavor.extras + + if get_access_list and not flavor.is_public: + access_list = [item.tenant_id for item in + api.nova.flavor_access_list(request, flavor_id)] + result['access-list'] = access_list return result + @rest_utils.ajax() + def delete(self, request, flavor_id): + api.nova.flavor_delete(request, flavor_id) + + @rest_utils.ajax(data_required=True) + def patch(self, request, flavor_id): + flavor_access = request.DATA.get('flavor_access', []) + is_public = not flavor_access + + # Grab any existing extra specs, because flavor edit is currently + # implemented as a delete followed by a create. + extras_dict = api.nova.flavor_get_extras(request, flavor_id, raw=True) + # Mark the existing flavor as deleted. + api.nova.flavor_delete(request, flavor_id) + # Then create a new flavor with the same name but a new ID. + # This is in the same try/except block as the delete call + # because if the delete fails the API will error out because + # active flavors can't have the same name. + flavor = api.nova.flavor_create(request, + name=request.DATA['name'], + memory=request.DATA['ram'], + vcpu=request.DATA['vcpus'], + disk=request.DATA['disk'], + ephemeral=request + .DATA['OS-FLV-EXT-DATA:ephemeral'], + swap=request.DATA['swap'], + flavorid=flavor_id, + is_public=is_public + ) + for project in flavor_access: + api.nova.add_tenant_to_flavor( + request, flavor.id, project.get('id')) + + if extras_dict: + api.nova.flavor_extra_set(request, flavor.id, extras_dict) + + def extract_boolean(self, request, name): + bool_string = request.GET.get(name) + return bool(bool_string and bool_string.lower() == 'true') + @urls.register class FlavorExtraSpecs(generic.View): 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 852be90b65..5f055700aa 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 @@ -53,7 +53,10 @@ getInstanceMetadata: getInstanceMetadata, editInstanceMetadata: editInstanceMetadata, getCreateKeypairUrl: getCreateKeypairUrl, - getRegenerateKeypairUrl: getRegenerateKeypairUrl + getRegenerateKeypairUrl: getRegenerateKeypairUrl, + createFlavor: createFlavor, + updateFlavor: updateFlavor, + deleteFlavor: deleteFlavor }; return service; @@ -314,15 +317,66 @@ * @param {boolean} getExtras (optional) * Also retrieve the extra specs for the flavor. */ - function getFlavor(id, getExtras) { + function getFlavor(id, getExtras, getAccessList) { var config = {'params': {}}; if (getExtras) { config.params.get_extras = 'true'; } - return apiService.get('/api/nova/flavors/' + id, config) + if (getAccessList) { config.params.get_access_list = 'true'; } + return apiService.get('/api/nova/flavors/' + id + '/' , config) .error(function () { toastService.add('error', gettext('Unable to retrieve the flavor.')); }); } + /** + * @name horizon.app.core.openstack-service-api.nova.createFlavor + * @description + * Create a single flavor. + * @param {flavor} flavor + * Flavor to create + */ + function createFlavor(flavor) { + return apiService.post('/api/nova/flavors/', flavor) + .error(function () { + toastService.add('error', gettext('Unable to create the flavor.')); + }); + } + + /** + * @name horizon.app.core.openstack-service-api.nova.updateFlavor + * @description + * Update a single flavor. + * @param {flavor} flavor + * Flavor to update + */ + function updateFlavor(flavor) { + return apiService.patch('/api/nova/flavors/' + flavor.id + '/', flavor) + .error(function () { + toastService.add('error', gettext('Unable to update the flavor.')); + }); + } + + /** + * @name horizon.app.core.openstack-service-api.nova.deleteFlavor + * @description + * Delete a single flavor by ID. + * + * @param {String} flavorId + * Flavor to delete + * + * @param {boolean} suppressError + * If passed in, this will not show the default error handling + * (horizon alert). The glance API may not have metadata definitions + * enabled. + */ + function deleteFlavor(flavorId, suppressError) { + var promise = apiService.delete('/api/nova/flavors/' + flavorId + '/'); + + return suppressError ? promise : promise.error(function() { + toastService.add('error', gettext('Unable to delete the flavor with id: ') + flavorId); + }); + + } + /** * @name horizon.app.core.openstack-service-api.nova.getFlavorExtraSpecs * @description 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 55d3bf4c18..00a458b252 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 @@ -185,7 +185,7 @@ { "func": "getFlavor", "method": "get", - "path": "/api/nova/flavors/42", + "path": "/api/nova/flavors/42/", "data": { "params": { "get_extras": "true" @@ -200,7 +200,24 @@ { "func": "getFlavor", "method": "get", - "path": "/api/nova/flavors/42", + "path": "/api/nova/flavors/42/", + "data": { + "params": { + "get_extras": "true", + "get_access_list": "true" + } + }, + "error": "Unable to retrieve the flavor.", + "testInput": [ + 42, + true, + true + ] + }, + { + "func": "getFlavor", + "method": "get", + "path": "/api/nova/flavors/42/", "data": { "params": {} }, @@ -275,6 +292,37 @@ "testInput": [ 42, {a: '1', b: '2'}, ['c', 'd'] ] + }, + { + "func": "createFlavor", + "method": "post", + "path": "/api/nova/flavors/", + "data": 42, + "error": "Unable to create the flavor.", + "testInput": [ + 42 + ] + }, + { + "func": "updateFlavor", + "method": "patch", + "path": "/api/nova/flavors/42/", + "data": { + id: 42 + }, + "error": "Unable to update the flavor.", + "testInput": [ + { + id: 42 + } + ] + }, + { + "func": "deleteFlavor", + "method": "delete", + "path": "/api/nova/flavors/42/", + "error": "Unable to delete the flavor with id: 42", + "testInput": [42] } ]; diff --git a/openstack_dashboard/test/api_tests/nova_rest_tests.py b/openstack_dashboard/test/api_tests/nova_rest_tests.py index 228e73d892..df47cbc037 100644 --- a/openstack_dashboard/test/api_tests/nova_rest_tests.py +++ b/openstack_dashboard/test/api_tests/nova_rest_tests.py @@ -14,6 +14,7 @@ import mock from django.conf import settings +from json import loads as to_json from openstack_dashboard import api from openstack_dashboard.api.rest import nova @@ -281,6 +282,27 @@ class NovaRestTestCase(test.TestCase): # # Flavors # + + @mock.patch.object(nova.api, 'nova') + def test_flavor_get_single_with_access_list(self, nc): + request = self.mock_rest_request(GET={'get_access_list': 'tRuE'}) + nc.flavor_get.return_value.to_dict.return_value = {'name': '1'} + nc.flavor_get.return_value.is_public = False + + nc.flavor_access_list.return_value = [ + mock.Mock(**{'tenant_id': '11'}), + mock.Mock(**{'tenant_id': '22'}), + ] + + response = nova.Flavor().get(request, "1") + + self.assertStatusCode(response, 200) + self.assertEqual(to_json(response.content.decode('utf-8')), + to_json('{"access-list": ["11", "22"], "name": "1"}')) + + nc.flavor_get.assert_called_once_with(request, "1", + get_extras=False) + def test_get_extras_no(self): self._test_flavor_get_single(get_extras=False) @@ -310,6 +332,197 @@ class NovaRestTestCase(test.TestCase): nc.flavor_get.assert_called_once_with(request, "1", get_extras=get_extras) + @mock.patch.object(nova.api, 'nova') + def test_flavor_get_single_with_swap_set_to_empty(self, nc): + request = self.mock_rest_request() + nc.flavor_get.return_value\ + .to_dict.return_value = {'name': '1', 'swap': ''} + + response = nova.Flavor().get(request, "1") + + self.assertStatusCode(response, 200) + self.assertEqual(to_json(response.content.decode('utf-8')), + to_json('{"name": "1", "swap": 0}')) + + @mock.patch.object(nova.api, 'nova') + def test_flavor_delete(self, nc): + request = self.mock_rest_request() + nova.Flavor().delete(request, "1") + nc.flavor_delete.assert_called_once_with(request, "1") + + @mock.patch.object(nova.api, 'nova') + def test_flavor_create(self, nc): + flavor_req_data = '{"name": "flavor", ' \ + '"ram": 12, ' \ + '"vcpus": 1, ' \ + '"disk": 2, ' \ + '"OS-FLV-EXT-DATA:ephemeral": 3, ' \ + '"swap": 4, ' \ + '"id": "123"' \ + '}' + + nc.flavor_create.return_value = mock.Mock(**{ + 'id': '123', + 'to_dict.return_value': {'id': '123', 'name': 'flavor'} + }) + + flavor_data = {'name': 'flavor', + 'memory': 12, + 'vcpu': 1, + 'disk': 2, + 'ephemeral': 3, + 'swap': 4, + 'flavorid': '123', + 'is_public': True} + + request = self.mock_rest_request(body=flavor_req_data) + response = nova.Flavors().post(request) + + self.assertStatusCode(response, 201) + self.assertEqual(response['location'], '/api/nova/flavors/123') + + nc.flavor_create.assert_called_once_with(request, **flavor_data) + + @mock.patch.object(nova.api, 'nova') + def test_flavor_create_with_access_list(self, nc): + flavor_req_data = '{"name": "flavor", ' \ + '"ram": 12, ' \ + '"vcpus": 1, ' \ + '"disk": 2, ' \ + '"OS-FLV-EXT-DATA:ephemeral": 3, ' \ + '"swap": 4, ' \ + '"id": "123", ' \ + '"flavor_access": [{"id":"1", "name":"test"}]' \ + '}' + + nc.flavor_create.return_value = mock.Mock(**{ + 'id': '1234', + 'to_dict.return_value': {'id': '1234', 'name': 'flavor'} + }) + + flavor_data = {'name': 'flavor', + 'memory': 12, + 'vcpu': 1, + 'disk': 2, + 'ephemeral': 3, + 'swap': 4, + 'flavorid': '123', + 'is_public': False} + + request = self.mock_rest_request(body=flavor_req_data) + response = nova.Flavors().post(request) + + self.assertStatusCode(response, 201) + self.assertEqual(response['location'], '/api/nova/flavors/1234') + + nc.flavor_create.assert_called_once_with(request, **flavor_data) + nc.add_tenant_to_flavor.assert_called_once_with(request, '1234', '1') + + @mock.patch.object(nova.api, 'nova') + def test_flavor_update(self, nc): + flavor_req_data = '{"name": "flavor", ' \ + '"ram": 12, ' \ + '"vcpus": 1, ' \ + '"disk": 2, ' \ + '"OS-FLV-EXT-DATA:ephemeral": 3, ' \ + '"swap": 4' \ + '}' + + nc.flavor_create.return_value = mock.Mock(**{ + 'id': '123', + 'to_dict.return_value': {'id': '123', 'name': 'flavor'} + }) + + flavor_data = {'name': 'flavor', + 'memory': 12, + 'vcpu': 1, + 'disk': 2, + 'ephemeral': 3, + 'swap': 4, + 'flavorid': '123', + 'is_public': True} + + request = self.mock_rest_request(body=flavor_req_data) + response = nova.Flavor().patch(request, '123') + + self.assertStatusCode(response, 204) + + nc.flavor_delete.assert_called_once_with(request, '123') + nc.flavor_create.assert_called_once_with(request, **flavor_data) + + @mock.patch.object(nova.api, 'nova') + def test_flavor_update_with_extras(self, nc): + flavor_req_data = '{"name": "flavor", ' \ + '"ram": 12, ' \ + '"vcpus": 1, ' \ + '"disk": 2, ' \ + '"OS-FLV-EXT-DATA:ephemeral": 3, ' \ + '"swap": 4' \ + '}' + + extra_dict = mock.Mock() + + nc.flavor_get_extras.return_value = extra_dict + + nc.flavor_create.return_value = mock.Mock(**{ + 'id': '1234', + 'to_dict.return_value': {'id': '1234', 'name': 'flavor'} + }) + + flavor_data = {'name': 'flavor', + 'memory': 12, + 'vcpu': 1, + 'disk': 2, + 'ephemeral': 3, + 'swap': 4, + 'flavorid': '123', + 'is_public': True} + + request = self.mock_rest_request(body=flavor_req_data) + response = nova.Flavor().patch(request, '123') + + self.assertStatusCode(response, 204) + + nc.flavor_delete.assert_called_once_with(request, '123') + nc.flavor_create.assert_called_once_with(request, **flavor_data) + nc.flavor_get_extras.assert_called_once_with(request, '123', raw=True) + nc.flavor_extra_set.assert_called_once_with(request, '1234', + extra_dict) + + @mock.patch.object(nova.api, 'nova') + def test_flavor_update_with_access_list(self, nc): + flavor_req_data = '{"name": "flavor", ' \ + '"ram": 12, ' \ + '"vcpus": 1, ' \ + '"disk": 2, ' \ + '"OS-FLV-EXT-DATA:ephemeral": 3, ' \ + '"swap": 4, ' \ + '"flavor_access": [{"id":"1", "name":"test"}]' \ + '}' + + nc.flavor_create.return_value = mock.Mock(**{ + 'id': '1234', + 'to_dict.return_value': {'id': '1234', 'name': 'flavor'} + }) + + flavor_data = {'name': 'flavor', + 'memory': 12, + 'vcpu': 1, + 'disk': 2, + 'ephemeral': 3, + 'swap': 4, + 'flavorid': '123', + 'is_public': False} + + request = self.mock_rest_request(body=flavor_req_data) + response = nova.Flavor().patch(request, '123') + + self.assertStatusCode(response, 204) + + nc.flavor_delete.assert_called_once_with(request, '123') + nc.flavor_create.assert_called_once_with(request, **flavor_data) + nc.add_tenant_to_flavor.assert_called_once_with(request, '1234', '1') + @mock.patch.object(nova.api, 'nova') def _test_flavor_list_public(self, nc, is_public=None): if is_public: