diff options
28 files changed, 669 insertions, 298 deletions
diff --git a/.zuul.d/nodejs-jobs.yaml b/.zuul.d/nodejs-jobs.yaml index f9f56b2f0..bdfe83eb7 100644 --- a/.zuul.d/nodejs-jobs.yaml +++ b/.zuul.d/nodejs-jobs.yaml @@ -6,6 +6,10 @@ vars: node_version: 14 tox_constraints_file: "{{ ansible_user_dir }}/{{ zuul.projects['opendev.org/openstack/requirements'].src_dir }}/upper-constraints.txt" + # NOTE: This is stable branch (<=stable/zed) job and new tox 4 + # require some changes in tox.ini to be compatible with it. Let's + # pin tox <4 for stable branches testing (<=stable/zed). + ensure_tox_version: '<4' nodeset: ubuntu-focal pre-run: playbooks/horizon-nodejs/pre.yaml required-projects: @@ -20,6 +24,10 @@ vars: node_version: 14 tox_constraints_file: "{{ ansible_user_dir }}/{{ zuul.projects['opendev.org/openstack/requirements'].src_dir }}/upper-constraints.txt" + # NOTE: This is stable branch (<=stable/zed) job and new tox 4 + # require some changes in tox.ini to be compatible with it. Let's + # pin tox <4 for stable branches testing (<=stable/zed). + ensure_tox_version: '<4' pre-run: playbooks/horizon-nodejs/pre.yaml nodeset: ubuntu-focal required-projects: diff --git a/doc/source/install/install-debian.rst b/doc/source/install/install-debian.rst index aa6c2e509..982a28ef1 100644 --- a/doc/source/install/install-debian.rst +++ b/doc/source/install/install-debian.rst @@ -119,6 +119,12 @@ Install and configure components .. end + .. note:: + + In case your keystone run at 5000 port then you would mentioned + keystone port here as well i.e. + OPENSTACK_KEYSTONE_URL = "http://%s:5000/identity/v3" % OPENSTACK_HOST + * Enable support for domains: .. path /etc/openstack-dashboard/local_settings.py diff --git a/doc/source/install/install-obs.rst b/doc/source/install/install-obs.rst index 77f32dd39..9db8a2c72 100644 --- a/doc/source/install/install-obs.rst +++ b/doc/source/install/install-obs.rst @@ -105,6 +105,12 @@ Install and configure components .. end + .. note:: + + In case your keystone run at 5000 port then you would mentioned + keystone port here as well i.e. + OPENSTACK_KEYSTONE_URL = "http://%s:5000/identity/v3" % OPENSTACK_HOST + * Enable support for domains: .. path /srv/www/openstack-dashboard/openstack_dashboard/local/local_settings.py diff --git a/doc/source/install/install-rdo.rst b/doc/source/install/install-rdo.rst index 61acb3673..406e7adb4 100644 --- a/doc/source/install/install-rdo.rst +++ b/doc/source/install/install-rdo.rst @@ -96,6 +96,12 @@ Install and configure components .. end + .. note:: + + In case your keystone run at 5000 port then you would mentioned + keystone port here as well i.e. + OPENSTACK_KEYSTONE_URL = "http://%s:5000/identity/v3" % OPENSTACK_HOST + * Enable support for domains: .. path /etc/openstack-dashboard/local_settings diff --git a/doc/source/install/install-ubuntu.rst b/doc/source/install/install-ubuntu.rst index 7c618ec29..464af383a 100644 --- a/doc/source/install/install-ubuntu.rst +++ b/doc/source/install/install-ubuntu.rst @@ -93,6 +93,12 @@ Install and configure components .. end + .. note:: + + In case your keystone run at 5000 port then you would mentioned + keystone port here as well i.e. + OPENSTACK_KEYSTONE_URL = "http://%s:5000/identity/v3" % OPENSTACK_HOST + * Enable support for domains: .. path /etc/openstack-dashboard/local_settings.py diff --git a/horizon/static/framework/widgets/metadata/tree/metadata-tree-item.controller.js b/horizon/static/framework/widgets/metadata/tree/metadata-tree-item.controller.js index 1d0720f12..6234699db 100644 --- a/horizon/static/framework/widgets/metadata/tree/metadata-tree-item.controller.js +++ b/horizon/static/framework/widgets/metadata/tree/metadata-tree-item.controller.js @@ -16,6 +16,8 @@ (function () { 'use strict'; + var READONLY_PROPERTIES = ['os_hash_algo', 'os_hash_value']; + angular .module('horizon.framework.widgets.metadata.tree') .controller('MetadataTreeItemController', MetadataTreeItemController); @@ -32,6 +34,12 @@ ctrl.formatErrorMessage = formatErrorMessage; ctrl.opened = false; + if ('item' in ctrl && 'leaf' in ctrl.item && + READONLY_PROPERTIES.includes(ctrl.item.leaf.name)) { + ctrl.item.leaf.readonly = true; + ctrl.item.leaf.required = false; + } + if ('item' in ctrl && 'leaf' in ctrl.item && ctrl.item.leaf.type === 'array') { ctrl.values = ctrl.item.leaf.items.enum.filter(filter).sort(); diff --git a/horizon/static/framework/widgets/metadata/tree/tree.service.js b/horizon/static/framework/widgets/metadata/tree/tree.service.js index 1256985f5..47ba1a844 100644 --- a/horizon/static/framework/widgets/metadata/tree/tree.service.js +++ b/horizon/static/framework/widgets/metadata/tree/tree.service.js @@ -71,6 +71,8 @@ Property.prototype.setValue = function (value) { if (value === null) { this.value = this.type !== 'array' ? null : []; + // if the existing property is null, make the field not required + this.required = false; return; } diff --git a/horizon/test/webdriver.py b/horizon/test/webdriver.py index 3edb71dcb..824bf02e4 100644 --- a/horizon/test/webdriver.py +++ b/horizon/test/webdriver.py @@ -37,7 +37,7 @@ class WrapperFindOverride(object): """Mixin for overriding find_element methods.""" def find_element(self, by=by.By.ID, value=None): - repeat = range(2) + repeat = range(10) for i in repeat: try: web_el = super().find_element(by, value) @@ -48,7 +48,7 @@ class WrapperFindOverride(object): self) def find_elements(self, by=by.By.ID, value=None): - repeat = range(2) + repeat = range(10) for i in repeat: try: web_els = super().find_elements(by, value) diff --git a/openstack_dashboard/api/keystone.py b/openstack_dashboard/api/keystone.py index 38931e52d..dd6a2a97b 100644 --- a/openstack_dashboard/api/keystone.py +++ b/openstack_dashboard/api/keystone.py @@ -120,7 +120,7 @@ def _get_endpoint_url(request, endpoint_type, catalog=None): return url -def keystoneclient(request, admin=False): +def keystoneclient(request, admin=False, force_scoped=False): """Returns a client connected to the Keystone backend. Several forms of authentication are supported: @@ -152,7 +152,8 @@ def keystoneclient(request, admin=False): # If user is Cloud Admin, Domain Admin or Mixed Domain Admin and there # is no domain context specified, use domain scoped token - if is_domain_admin(request) and not is_domain_context_specified: + if (is_domain_admin(request) and not is_domain_context_specified and + not force_scoped): domain_token = request.session.get('domain_token') if domain_token: token_id = getattr(domain_token, 'auth_token', None) @@ -998,7 +999,17 @@ def application_credential_create(request, name, secret=None, roles=None, unrestricted=False, access_rules=None): user = request.user.id - manager = keystoneclient(request).application_credentials + # NOTE(ganso): users with domain admin role that are not cloud admins are + # not able to get scoped context and create an application credential with + # project_id, so only in this particular case we force a scoped context + force_scoped = False + if (request.user.project_id and request.session.get("domain_token") and + not policy.check( + (("identity", "identity:update_domain"),), request)): + force_scoped = True + + manager = keystoneclient( + request, force_scoped=force_scoped).application_credentials try: return manager.create(name=name, user=user, secret=secret, description=description, expires_at=expires_at, diff --git a/openstack_dashboard/api/microversions.py b/openstack_dashboard/api/microversions.py index 9d33952ec..d31712ce8 100644 --- a/openstack_dashboard/api/microversions.py +++ b/openstack_dashboard/api/microversions.py @@ -37,6 +37,7 @@ MICROVERSION_FEATURES = { "auto_allocated_network": ["2.37", "2.60"], "key_types": ["2.2", "2.9"], "key_type_list": ["2.9"], + "rescue_instance_volume_based": ["2.87", "2.93"], }, "cinder": { "groups": ["3.27", "3.43", "3.48", "3.58"], diff --git a/openstack_dashboard/api/nova.py b/openstack_dashboard/api/nova.py index 394d893e3..b18a1717d 100644 --- a/openstack_dashboard/api/nova.py +++ b/openstack_dashboard/api/nova.py @@ -483,7 +483,7 @@ def server_list_paged(request, deleted = request.session.pop('server_deleted', None) view_marker = 'possibly_deleted' if deleted and marker else 'ok' - search_opts['marker'] = deleted if deleted else marker + search_opts['marker'] = marker if marker or deleted else None search_opts['limit'] = page_size + 1 # NOTE(amotoki): It looks like the 'sort_keys' must be unique to make # the pagination in the nova API works as expected. Multiple servers @@ -505,7 +505,7 @@ def server_list_paged(request, servers = [Server(s, request) for s in nova_client.servers.list(detailed, - search_opts, + search_opts=search_opts, sort_keys=sort_keys, sort_dirs=['desc'] * 3)] if not servers: @@ -514,7 +514,7 @@ def server_list_paged(request, servers = [Server(s, request) for s in nova_client.servers.list(detailed, - search_opts, + search_opts=search_opts, sort_keys=sort_keys, sort_dirs=['asc'] * 3)] (servers, has_more_data, has_prev_data) = update_pagination( @@ -665,9 +665,12 @@ def server_metadata_delete(request, instance_id, keys): @profiler.trace def server_rescue(request, instance_id, password=None, image=None): - _nova.novaclient(request).servers.rescue(instance_id, - password=password, - image=image) + microversion = get_microversion(request, "rescue_instance_volume_based") + _nova.novaclient(request, version=microversion).servers.rescue( + instance_id, + password=password, + image=image + ) @profiler.trace diff --git a/openstack_dashboard/dashboards/admin/flavors/workflows.py b/openstack_dashboard/dashboards/admin/flavors/workflows.py index 5bec75503..a186ab716 100644 --- a/openstack_dashboard/dashboards/admin/flavors/workflows.py +++ b/openstack_dashboard/dashboards/admin/flavors/workflows.py @@ -30,7 +30,7 @@ class CreateFlavorInfoAction(workflows.Action): _flavor_id_regex = (r'^[a-zA-Z0-9. _-]+$') _flavor_id_help_text = _("flavor id can only contain alphanumeric " "characters, underscores, periods, hyphens, " - "spaces.") + "spaces. Use 'auto' to automatically generate id") name = forms.CharField( label=_("Name"), max_length=255) @@ -93,7 +93,7 @@ class CreateFlavorInfoAction(workflows.Action): error_msg = _('The name "%s" is already used by ' 'another flavor.') % name self._errors['name'] = self.error_class([error_msg]) - if flavor.id == flavor_id: + if (flavor.id != 'auto') and (flavor.id == flavor_id): error_msg = _('The ID "%s" is already used by ' 'another flavor.') % flavor_id self._errors['flavor_id'] = self.error_class([error_msg]) diff --git a/openstack_dashboard/dashboards/admin/networks/ports/tests.py b/openstack_dashboard/dashboards/admin/networks/ports/tests.py index bd2e9f8fb..cc8a6c619 100644 --- a/openstack_dashboard/dashboards/admin/networks/ports/tests.py +++ b/openstack_dashboard/dashboards/admin/networks/ports/tests.py @@ -480,7 +480,7 @@ class NetworkPortTests(test.BaseAdminViewTests): self.assertRedirectsNoFollow(res, redir_url) self.assert_mock_multiple_calls_with_same_arguments( - self.mock_port_get, 2, + self.mock_port_get, 3, mock.call(test.IsHttpRequest(), port.id)) self._check_is_extension_supported( {'binding': 1, @@ -495,6 +495,10 @@ class NetworkPortTests(test.BaseAdminViewTests): extension_kwargs['mac_learning_enabled'] = True if port_security: extension_kwargs['port_security_enabled'] = True + + if form_data.get('port_security_enabled') == port.port_security_enabled: + extension_kwargs.pop('port_security_enabled') + self.mock_port_update.assert_called_once_with( test.IsHttpRequest(), port.id, name=port.name, @@ -554,7 +558,7 @@ class NetworkPortTests(test.BaseAdminViewTests): self.assertRedirectsNoFollow(res, redir_url) self.assert_mock_multiple_calls_with_same_arguments( - self.mock_port_get, 2, + self.mock_port_get, 3, mock.call(test.IsHttpRequest(), port.id)) self._check_is_extension_supported( {'binding': 1, @@ -569,6 +573,8 @@ class NetworkPortTests(test.BaseAdminViewTests): extension_kwargs['mac_learning_enabled'] = True if port_security: extension_kwargs['port_security_enabled'] = True + if form_data.get('port_security_enabled') == port.port_security_enabled: + extension_kwargs.pop('port_security_enabled') self.mock_port_update.assert_called_once_with( test.IsHttpRequest(), port.id, name=port.name, diff --git a/openstack_dashboard/dashboards/project/instances/tests.py b/openstack_dashboard/dashboards/project/instances/tests.py index 57cf18325..29ae1789f 100644 --- a/openstack_dashboard/dashboards/project/instances/tests.py +++ b/openstack_dashboard/dashboards/project/instances/tests.py @@ -131,13 +131,9 @@ class InstanceTableTestMixin(object): shared=False), mock.call(helpers.IsHttpRequest(), shared=True), ]) - self.assertEqual(len(self.networks.list()), - self.mock_port_list_with_trunk_types.call_count) - self.mock_port_list_with_trunk_types( - [mock.call(helpers.IsHttpRequest(), - network_id=net.id, - tenant_id=self.tenant.id) - for net in self.networks.list()]) + self.mock_port_list_with_trunk_types.assert_called_once_with( + helpers.IsHttpRequest(), + tenant_id=self.tenant.id) def _mock_nova_lists(self): self.mock_flavor_list.return_value = self.flavors.list() @@ -2215,12 +2211,9 @@ class InstanceLaunchInstanceTests(InstanceTestBase, mock.call(helpers.IsHttpRequest(), shared=True), ]) self.assertEqual(4, self.mock_network_list.call_count) - self.mock_port_list_with_trunk_types.assert_has_calls( - [mock.call(helpers.IsHttpRequest(), - network_id=net.id, tenant_id=self.tenant.id) - for net in self.networks.list()]) - self.assertEqual(len(self.networks.list()), - self.mock_port_list_with_trunk_types.call_count) + self.mock_port_list_with_trunk_types.assert_called_once_with( + helpers.IsHttpRequest(), + tenant_id=self.tenant.id) self.mock_server_group_list.assert_called_once_with( helpers.IsHttpRequest()) self.mock_tenant_quota_usages.assert_called_once_with( @@ -2366,10 +2359,9 @@ class InstanceLaunchInstanceTests(InstanceTestBase, mock.call(helpers.IsHttpRequest(), shared=True), ]) self.assertEqual(4, self.mock_network_list.call_count) - self.mock_port_list_with_trunk_types.assert_has_calls( - [mock.call(helpers.IsHttpRequest(), - network_id=net.id, tenant_id=self.tenant.id) - for net in self.networks.list()]) + self.mock_port_list_with_trunk_types.assert_called_once_with( + helpers.IsHttpRequest(), + tenant_id=self.tenant.id) self.mock_server_group_list.assert_called_once_with( helpers.IsHttpRequest()) self.mock_tenant_quota_usages.assert_called_once_with( @@ -2449,10 +2441,9 @@ class InstanceLaunchInstanceTests(InstanceTestBase, mock.call(helpers.IsHttpRequest(), shared=True), ]) self.assertEqual(4, self.mock_network_list.call_count) - self.mock_port_list_with_trunk_types.assert_has_calls( - [mock.call(helpers.IsHttpRequest(), - network_id=net.id, tenant_id=self.tenant.id) - for net in self.networks.list()]) + self.mock_port_list_with_trunk_types.assert_called_once_with( + helpers.IsHttpRequest(), + tenant_id=self.tenant.id) self.mock_server_group_list.assert_called_once_with( helpers.IsHttpRequest()) self.mock_tenant_quota_usages.assert_called_once_with( @@ -3624,13 +3615,9 @@ class InstanceLaunchInstanceTests(InstanceTestBase, helpers.IsHttpRequest(), shared=True), ]) - self.assertEqual(len(self.networks.list()), - self.mock_port_list_with_trunk_types.call_count) - self.mock_port_list_with_trunk_types.assert_has_calls( - [mock.call(helpers.IsHttpRequest(), - network_id=net.id, - tenant_id=self.tenant.id) - for net in self.networks.list()]) + self.mock_port_list_with_trunk_types.assert_called_once_with( + helpers.IsHttpRequest(), + tenant_id=self.tenant.id) self.mock_volume_list.assert_has_calls([ mock.call(helpers.IsHttpRequest(), search_opts=VOLUME_SEARCH_OPTS), @@ -5103,7 +5090,8 @@ class ConsoleManagerTests(helpers.ResetImageAPIVersionMixin, helpers.TestCase): self.assertRaises(exceptions.NotAvailable, console.get_console, None, 'FAKE', None) - @helpers.create_mocks({api.neutron: ('network_list_for_tenant',)}) + @helpers.create_mocks({api.neutron: ('network_list_for_tenant', + 'port_list_with_trunk_types',)}) def test_interface_attach_get(self): server = self.servers.first() self.mock_network_list_for_tenant.side_effect = [ @@ -5123,7 +5111,8 @@ class ConsoleManagerTests(helpers.ResetImageAPIVersionMixin, helpers.TestCase): ]) self.assertEqual(2, self.mock_network_list_for_tenant.call_count) - @helpers.create_mocks({api.neutron: ('network_list_for_tenant',), + @helpers.create_mocks({api.neutron: ('network_list_for_tenant', + 'port_list_with_trunk_types',), api.nova: ('interface_attach',)}) def test_interface_attach_post(self): fixed_ip = '10.0.0.10' diff --git a/openstack_dashboard/dashboards/project/instances/utils.py b/openstack_dashboard/dashboards/project/instances/utils.py index 0639690db..005ce12b2 100644 --- a/openstack_dashboard/dashboards/project/instances/utils.py +++ b/openstack_dashboard/dashboards/project/instances/utils.py @@ -197,7 +197,7 @@ def port_field_data(request, with_network=False): port_name = "{} ({})".format( port.name_or_id, ",".join( [ip['ip_address'] for ip in port['fixed_ips']])) - if with_network and network: + if with_network: port_name += " - {}".format(network.name_or_id) return port_name @@ -205,14 +205,16 @@ def port_field_data(request, with_network=False): if api.base.is_service_enabled(request, 'network'): network_list = api.neutron.network_list_for_tenant( request, request.user.tenant_id) - for network in network_list: - ports.extend( - [(port.id, add_more_info_port_name(port, network)) - for port in api.neutron.port_list_with_trunk_types( - request, network_id=network.id, - tenant_id=request.user.tenant_id) - if (not port.device_owner and - not isinstance(port, api.neutron.PortTrunkSubport))]) + network_dict = dict((n.id, n) for n in network_list) + ports = [ + (port.id, + add_more_info_port_name(port, network_dict[port.network_id])) + for port + in api.neutron.port_list_with_trunk_types( + request, tenant_id=request.user.tenant_id) + if (not port.device_owner and + not isinstance(port, api.neutron.PortTrunkSubport)) + ] ports.sort(key=lambda obj: obj[1]) return ports diff --git a/openstack_dashboard/dashboards/project/networks/ports/tests.py b/openstack_dashboard/dashboards/project/networks/ports/tests.py index a41936653..7fed538d0 100644 --- a/openstack_dashboard/dashboards/project/networks/ports/tests.py +++ b/openstack_dashboard/dashboards/project/networks/ports/tests.py @@ -185,13 +185,15 @@ class NetworkPortTests(test.TestCase): self.assertRedirectsNoFollow(res, redir_url) self.assert_mock_multiple_calls_with_same_arguments( - self.mock_port_get, 2, + self.mock_port_get, 3, mock.call(test.IsHttpRequest(), port.id)) self._check_is_extension_supported({'binding': 1, 'mac-learning': 1, 'port-security': 1}) self.mock_security_group_list.assert_called_once_with( test.IsHttpRequest(), tenant_id=self.tenant.id) + if form_data.get('port_security_enabled') == port.port_security_enabled: + extension_kwargs.pop('port_security_enabled') self.mock_port_update.assert_called_once_with( test.IsHttpRequest(), port.id, name=port.name, admin_state_up=port.admin_state_up, @@ -244,7 +246,7 @@ class NetworkPortTests(test.TestCase): self.assertRedirectsNoFollow(res, redir_url) self.assert_mock_multiple_calls_with_same_arguments( - self.mock_port_get, 2, + self.mock_port_get, 3, mock.call(test.IsHttpRequest(), port.id)) self._check_is_extension_supported({'binding': 1, 'mac-learning': 1, @@ -259,6 +261,8 @@ class NetworkPortTests(test.TestCase): if port_security: extension_kwargs['port_security_enabled'] = True extension_kwargs['security_groups'] = sg_ids + if form_data.get('port_security_enabled') == port.port_security_enabled: + extension_kwargs.pop('port_security_enabled') self.mock_port_update.assert_called_once_with( test.IsHttpRequest(), port.id, name=port.name, admin_state_up=port.admin_state_up, diff --git a/openstack_dashboard/dashboards/project/networks/ports/workflows.py b/openstack_dashboard/dashboards/project/networks/ports/workflows.py index 8638d60ff..425b6240c 100644 --- a/openstack_dashboard/dashboards/project/networks/ports/workflows.py +++ b/openstack_dashboard/dashboards/project/networks/ports/workflows.py @@ -405,10 +405,18 @@ class UpdatePort(workflows.Workflow): name = self.context['name'] or self.context['port_id'] return message % name + def _port_security_unchanged(self, request, port_id, params): + new = params.get('port_security_enabled') + port = api.neutron.port_get(request, port_id) + existing = port.get('port_security_enabled') + return existing == new + def handle(self, request, data): port_id = self.context['port_id'] LOG.debug('params = %s', data) params = self._construct_parameters(data) + if self._port_security_unchanged(request, port_id, params): + params.pop('port_security_enabled') try: api.neutron.port_update(request, port_id, **params) return True diff --git a/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/source/source.controller.js b/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/source/source.controller.js index 3a22b26df..24b949144 100644 --- a/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/source/source.controller.js +++ b/openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/source/source.controller.js @@ -410,7 +410,7 @@ ); // When the allowedboot list changes, change the source_type - // and update the table for the new source selection. The devault value is + // and update the table for the new source selection. The default value is // set by the DEFAULT_BOOT_SOURCE config option. // The boot source is changed only if the selected value is not included // in the updated list (newValue) @@ -418,7 +418,7 @@ function getAllowedBootSources() { return $scope.model.allowedBootSources; }, - function changeBootSource(newValue) { + function updateBootSource(newValue) { if (angular.isArray(newValue) && newValue.length > 0 ) { var opt = newValue[0]; for (var index = 0; index < newValue.length; index++) { @@ -484,8 +484,17 @@ updateFacets(key); } + // Update the initial boot source selection when launching from a preselected source function updateDataSource(key, preSelection) { if (preSelection) { + for (var index = 0; index < $scope.model.allowedBootSources.length; index++) { + if ($scope.model.allowedBootSources[index].type === key) { + $scope.model.allowedBootSources[index].selected = true; + } + else { + $scope.model.allowedBootSources[index].selected = false; + } + } ctrl.selection.length = 0; push.apply(selection, preSelection); } diff --git a/openstack_dashboard/static/app/core/images/steps/create-image/create-image.html b/openstack_dashboard/static/app/core/images/steps/create-image/create-image.html index 26aa31034..71f70ffbe 100644 --- a/openstack_dashboard/static/app/core/images/steps/create-image/create-image.html +++ b/openstack_dashboard/static/app/core/images/steps/create-image/create-image.html @@ -76,15 +76,9 @@ <label class="control-label" for="imageForm-image_url"> <translate>File</translate><span class="hz-icon-required fa fa-asterisk"></span> </label> - <div class="input-group" ng-hide="ctrl.uploadProgress > -1"> - <span class="input-group-btn"> - <button class="btn btn-primary" ng-model="image_file" - ngf-select="ctrl.prepareUpload(image_file)" - name="image_file" ng-required="true" - ng-disabled="viewModel.isSubmitting" - id="imageForm-image_file" translate>Browse...</button> - </span> - <input type="text" class="form-control" readonly ng-model="image_file.name"> + <div ng-hide="ctrl.uploadProgress > -1"> + <input type="file" ng-model="image_file" ngf-select="ctrl.prepareUpload(image_file)" + name="image_file" ng-required="true" ng-disabled="viewModel.isSubmitting"> </div> <div ng-hide="ctrl.uploadProgress < 0" class="progress-text"> <uib-progressbar value="ctrl.uploadProgress"></uib-progressbar> @@ -239,7 +233,7 @@ <translate>Visibility</translate> </label> <div class="form-field"> - <div class="btn-group"> + <div class="btn-group" name="visibility"> <label class="btn btn-default" ng-repeat="option in ctrl.imageVisibilityOptions" ng-model="ctrl.image.visibility" @@ -254,7 +248,7 @@ <translate>Protected</translate> </label> <div class="form-field"> - <div class="btn-group"> + <div class="btn-group" name="protected"> <label class="btn btn-default" ng-repeat="option in ctrl.imageProtectedOptions" ng-model="ctrl.image.protected" 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 index ce75e8e2e..46d493227 100644 --- 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 @@ -126,7 +126,7 @@ <div class="form-group"> <label class="control-label required" translate>Visibility</label> <div class="form-field"> - <div class="btn-group"> + <div class="btn-group" name="visibility"> <label class="btn btn-default" ng-repeat="option in ctrl.imageVisibilityOptions" ng-model="ctrl.image.visibility" @@ -139,7 +139,7 @@ <div class="form-group"> <label class="control-label required" translate>Protected</label> <div class="form-field"> - <div class="btn-group"> + <div class="btn-group" name="protected"> <label class="btn btn-default" ng-repeat="option in ctrl.imageProtectedOptions" ng-model="ctrl.image.protected" diff --git a/openstack_dashboard/test/integration_tests/config.py b/openstack_dashboard/test/integration_tests/config.py index fa90b1152..475bf098b 100644 --- a/openstack_dashboard/test/integration_tests/config.py +++ b/openstack_dashboard/test/integration_tests/config.py @@ -75,11 +75,11 @@ ImageGroup = [ default='angular', help='type/version of images panel'), cfg.StrOpt('http_image', - default='http://download.cirros-cloud.net/0.3.1/' - 'cirros-0.3.1-x86_64-uec.tar.gz', + default='http://download.cirros-cloud.net/0.5.2/' + 'cirros-0.5.2-x86_64-uec.tar.gz', help='http accessible image'), cfg.ListOpt('images_list', - default=['cirros-0.3.5-x86_64-disk'], + default=['cirros-0.5.2-x86_64-disk'], help='default list of images') ] diff --git a/openstack_dashboard/test/integration_tests/horizon.conf b/openstack_dashboard/test/integration_tests/horizon.conf index 1b0301e6d..52db2a69c 100644 --- a/openstack_dashboard/test/integration_tests/horizon.conf +++ b/openstack_dashboard/test/integration_tests/horizon.conf @@ -43,8 +43,9 @@ panel_type=legacy [image] # http accessible image (string value) panel_type=angular -http_image=http://download.cirros-cloud.net/0.3.1/cirros-0.3.1-x86_64-uec.tar.gz -images_list=cirros-0.3.5-x86_64-disk +http_image=http://download.cirros-cloud.net/0.5.2/cirros-0.5.2-x86_64-uec.tar.gz +images_list=cirros-0.5.2-x86_64-disk + [identity] # Username to use for non-admin API requests. (string value) diff --git a/openstack_dashboard/test/integration_tests/pages/project/compute/imagespage.py b/openstack_dashboard/test/integration_tests/pages/project/compute/imagespage.py index 304d506d8..117c64b5c 100644 --- a/openstack_dashboard/test/integration_tests/pages/project/compute/imagespage.py +++ b/openstack_dashboard/test/integration_tests/pages/project/compute/imagespage.py @@ -13,120 +13,133 @@ from selenium.webdriver.common import by from openstack_dashboard.test.integration_tests.pages import basepage from openstack_dashboard.test.integration_tests.regions import forms +from openstack_dashboard.test.integration_tests.regions import menus from openstack_dashboard.test.integration_tests.regions import tables from openstack_dashboard.test.integration_tests.pages.project.compute.\ instancespage import InstancesPage -from openstack_dashboard.test.integration_tests.pages.project.volumes.\ - volumespage import VolumesPage # TODO(bpokorny): Set the default source back to 'url' once Glance removes # the show_multiple_locations option, and if the default devstack policies # allow setting locations. DEFAULT_IMAGE_SOURCE = 'file' -DEFAULT_IMAGE_FORMAT = 'qcow2' +DEFAULT_IMAGE_FORMAT = 'string:raw' DEFAULT_ACCESSIBILITY = False DEFAULT_PROTECTION = False -IMAGES_TABLE_NAME_COLUMN = 'name' -IMAGES_TABLE_STATUS_COLUMN = 'status' -IMAGES_TABLE_FORMAT_COLUMN = 'disk_format' +IMAGES_TABLE_NAME_COLUMN = 'Name' +IMAGES_TABLE_STATUS_COLUMN = 'Status' +IMAGES_TABLE_FORMAT_COLUMN = 'Disk Format' -class ImagesTable(tables.TableRegion): +class ImagesTable(tables.TableRegionNG): name = "images" CREATE_IMAGE_FORM_FIELDS = ( - "name", "description", "image_file", "kernel", "ramdisk", - "disk_format", "architecture", "min_disk", "min_ram", - "is_public", "protected" + "name", "description", "image_file", "kernel", "ramdisk", "format", + "architecture", "min_disk", "min_ram", "visibility", "protected" ) CREATE_VOLUME_FROM_IMAGE_FORM_FIELDS = ( - "name", "description", "image_source", - "type", "size", "availability_zone") - - LAUNCH_INSTANCE_FROM_FIELDS = (( - "availability_zone", "name", "flavor", - "count", "source_type", "instance_snapshot_id", - "volume_id", "volume_snapshot_id", "image_id", "volume_size", - "vol_delete_on_instance_delete"), - ("keypair", "groups"), - ("script_source", "script_upload", "script_data"), - ("disk_config", "config_drive") + "name", "description", + "volume_size", + "availability_zone") + + LAUNCH_INSTANCE_FORM_FIELDS = ( + ("name", "count", "availability_zone"), + ("boot_source_type", "volume_size"), + { + 'flavor': menus.InstanceFlavorMenuRegion + }, + { + 'network': menus.InstanceAvailableResourceMenuRegion + }, ) EDIT_IMAGE_FORM_FIELDS = ( - "name", "description", "disk_format", "min_disk", - "min_ram", "public", "protected" + "name", "description", "format", "min_disk", + "min_ram", "visibility", "protected" ) - @tables.bind_table_action('create') + @tables.bind_table_action_ng('Create Image') def create_image(self, create_button): create_button.click() - return forms.FormRegion(self.driver, self.conf, - field_mappings=self.CREATE_IMAGE_FORM_FIELDS) + return forms.FormRegionNG(self.driver, self.conf, + field_mappings=self.CREATE_IMAGE_FORM_FIELDS) - @tables.bind_table_action('delete') + @tables.bind_table_action_ng('Delete Images') def delete_image(self, delete_button): delete_button.click() return forms.BaseFormRegion(self.driver, self.conf) - @tables.bind_row_action('create_volume_from_image') - def create_volume(self, create_volume, row): - create_volume.click() - return forms.FormRegion( - self.driver, self.conf, - field_mappings=self.CREATE_VOLUME_FROM_IMAGE_FORM_FIELDS) - - @tables.bind_row_action('launch_image') - def launch_instance(self, launch_instance, row): - launch_instance.click() - return forms.TabbedFormRegion( - self.driver, self.conf, - field_mappings=self.LAUNCH_INSTANCE_FROM_FIELDS) - - @tables.bind_row_action('update_metadata') - def update_metadata(self, metadata_button, row): - metadata_button.click() - return forms.MetadataFormRegion(self.driver, self.conf) - - @tables.bind_row_action('delete') + @tables.bind_row_action_ng('Delete Image') def delete_image_via_row_action(self, delete_button, row): delete_button.click() return forms.BaseFormRegion(self.driver, self.conf) - @tables.bind_row_action('edit') + @tables.bind_row_action_ng('Edit Image') def edit_image(self, edit_button, row): edit_button.click() - return forms.FormRegion(self.driver, self.conf, - field_mappings=self.EDIT_IMAGE_FORM_FIELDS) + return forms.FormRegionNG(self.driver, self.conf, + field_mappings=self.EDIT_IMAGE_FORM_FIELDS) + + @tables.bind_row_action_ng('Update Metadata') + def update_metadata(self, metadata_button, row): + metadata_button.click() + return forms.MetadataFormRegion(self.driver, self.conf) - @tables.bind_row_anchor_column(IMAGES_TABLE_NAME_COLUMN) + @tables.bind_row_anchor_column_ng(IMAGES_TABLE_NAME_COLUMN) def go_to_image_description_page(self, row_link, row): row_link.click() return forms.ItemTextDescription(self.driver, self.conf) + @tables.bind_row_action_ng('Create Volume') + def create_volume(self, create_volume, row): + create_volume.click() + return forms.FormRegion( + self.driver, self.conf, + field_mappings=self.CREATE_VOLUME_FROM_IMAGE_FORM_FIELDS) + + @tables.bind_row_action_ng('Launch') + def launch_instance(self, launch_instance, row): + launch_instance.click() + return forms.WizardFormRegion( + self.driver, self.conf, self.LAUNCH_INSTANCE_FORM_FIELDS) + class ImagesPage(basepage.BaseNavigationPage): + _resource_page_header_locator = (by.By.CSS_SELECTOR, + 'hz-resource-panel hz-page-header h1') + _default_form_locator = (by.By.CSS_SELECTOR, 'div.modal-dialog') + _search_field_locator = (by.By.CSS_SELECTOR, + 'magic-search.form-control input.search-input') + _search_button_locator = (by.By.CSS_SELECTOR, + 'hz-magic-search-bar span.fa-search') + _search_option_locator = (by.By.CSS_SELECTOR, + 'magic-search.form-control span.search-entry') def __init__(self, driver, conf): super().__init__(driver, conf) self._page_title = "Images" - def _get_row_with_image_name(self, name): - return self.images_table.get_row(IMAGES_TABLE_NAME_COLUMN, name) + @property + def header(self): + return self._get_element(*self._resource_page_header_locator) @property def images_table(self): return ImagesTable(self.driver, self.conf) + def wizard_getter(self): + return self._get_element(*self._default_form_locator) + + def _get_row_with_image_name(self, name): + return self.images_table.get_row(IMAGES_TABLE_NAME_COLUMN, name) + def create_image(self, name, description=None, image_source_type=DEFAULT_IMAGE_SOURCE, location=None, image_file=None, - image_format=DEFAULT_IMAGE_FORMAT, - is_public=DEFAULT_ACCESSIBILITY, - is_protected=DEFAULT_PROTECTION): + image_format=DEFAULT_IMAGE_FORMAT): create_image_form = self.images_table.create_image() create_image_form.name.text = name if description is not None: @@ -142,41 +155,27 @@ class ImagesPage(basepage.BaseNavigationPage): create_image_form.image_url.text = location else: create_image_form.image_file.choose(image_file) - create_image_form.disk_format.value = image_format - if is_public: - create_image_form.is_public.mark() - if is_protected: - create_image_form.protected.mark() + create_image_form.format.value = image_format create_image_form.submit() + self.wait_till_element_disappears(self.wizard_getter) def delete_image(self, name): row = self._get_row_with_image_name(name) row.mark() confirm_delete_images_form = self.images_table.delete_image() confirm_delete_images_form.submit() + self.wait_till_spinner_disappears() - def add_custom_metadata(self, name, metadata): - row = self._get_row_with_image_name(name) - update_metadata_form = self.images_table.update_metadata(row) - for field_name, value in metadata.items(): - update_metadata_form.add_custom_field(field_name, value) - update_metadata_form.submit() - - def check_image_details(self, name, dict_with_details): - row = self._get_row_with_image_name(name) - matches = [] - description_page = self.images_table.go_to_image_description_page(row) - content = description_page.get_content() - - for name, value in content.items(): - if name in dict_with_details: - if dict_with_details[name] in value: - matches.append(True) - return matches + def delete_images(self, images_names): + for image_name in images_names: + self._get_row_with_image_name(image_name).mark() + confirm_delete_images_form = self.images_table.delete_image() + confirm_delete_images_form.submit() + self.wait_till_spinner_disappears() def edit_image(self, name, new_name=None, description=None, min_disk=None, min_ram=None, - public=None, protected=None): + visibility=None, protected=None): row = self._get_row_with_image_name(name) confirm_edit_images_form = self.images_table.edit_image(row) @@ -192,15 +191,17 @@ class ImagesPage(basepage.BaseNavigationPage): if min_ram is not None: confirm_edit_images_form.min_ram.value = min_ram - if public is True: - confirm_edit_images_form.public.mark() - elif public is False: - confirm_edit_images_form.public.unmark() + if visibility is not None: + if visibility is True: + confirm_edit_images_form.visibility.pick('Shared') + elif visibility is False: + confirm_edit_images_form.visibility.pick('Private') - if protected is True: - confirm_edit_images_form.protected.mark() - elif protected is False: - confirm_edit_images_form.protected.unmark() + if protected is not None: + if protected is True: + confirm_edit_images_form.protected.pick('Yes') + elif protected is False: + confirm_edit_images_form.protected.pick('No') confirm_edit_images_form.submit() @@ -209,6 +210,25 @@ class ImagesPage(basepage.BaseNavigationPage): delete_image_form = self.images_table.delete_image_via_row_action(row) delete_image_form.submit() + def add_custom_metadata(self, name, metadata): + row = self._get_row_with_image_name(name) + update_metadata_form = self.images_table.update_metadata(row) + for field_name, value in metadata.items(): + update_metadata_form.add_custom_field(field_name, value) + update_metadata_form.submit() + + def check_image_details(self, name, dict_with_details): + row = self._get_row_with_image_name(name) + matches = [] + description_page = self.images_table.go_to_image_description_page(row) + content = description_page.get_content() + + for name, value in content.items(): + if name in dict_with_details: + if dict_with_details[name] in value: + matches.append(True) + return matches + def is_image_present(self, name): return bool(self._get_row_with_image_name(name)) @@ -222,10 +242,27 @@ class ImagesPage(basepage.BaseNavigationPage): def wait_until_image_active(self, name): self._wait_until(lambda x: self.is_image_active(name)) + def wait_until_image_present(self, name): + self._wait_until(lambda x: self.is_image_present(name)) + def get_image_format(self, name): row = self._get_row_with_image_name(name) return row.cells[IMAGES_TABLE_FORMAT_COLUMN].text + def filter(self, value): + self._set_search_field(value) + self._click_search_btn() + self.driver.implicitly_wait(5) + + def _set_search_field(self, value): + srch_field = self._get_element(*self._search_field_locator) + srch_field.clear() + srch_field.send_keys(value) + + def _click_search_btn(self): + btn = self._get_element(*self._search_button_locator) + btn.click() + def create_volume_from_image(self, name, volume_name=None, description=None, volume_size=None): @@ -236,32 +273,29 @@ class ImagesPage(basepage.BaseNavigationPage): if description is not None: create_volume_form.description.text = description create_volume_form.image_source = name - create_volume_form.size.value = volume_size if volume_size \ + create_volume_form.volume_size.value = volume_size if volume_size \ else self.conf.volume.volume_size create_volume_form.availability_zone.value = \ self.conf.launch_instances.available_zone create_volume_form.submit() - return VolumesPage(self.driver, self.conf) def launch_instance_from_image(self, name, instance_name, instance_count=1, flavor=None): + instance_page = InstancesPage(self.driver, self.conf) row = self._get_row_with_image_name(name) - launch_instance = self.images_table.launch_instance(row) - launch_instance.availability_zone.value = \ + instance_form = self.images_table.launch_instance(row) + instance_form.availability_zone.value = \ self.conf.launch_instances.available_zone - launch_instance.name.text = instance_name + instance_form.name.text = instance_name + instance_form.count.value = instance_count + instance_form.switch_to(instance_page.SOURCE_STEP_INDEX) + instance_page.vol_delete_on_instance_delete_click() + instance_form.switch_to(instance_page.FLAVOR_STEP_INDEX) if flavor is None: flavor = self.conf.launch_instances.flavor - launch_instance.flavor.text = flavor - launch_instance.count.value = instance_count - launch_instance.submit() - return InstancesPage(self.driver, self.conf) - - -class ImagesPageNG(ImagesPage): - _resource_page_header_locator = (by.By.CSS_SELECTOR, - 'hz-resource-panel hz-page-header h1') - - @property - def header(self): - return self._get_element(*self._resource_page_header_locator) + instance_form.flavor.transfer_available_resource(flavor) + instance_form.switch_to(instance_page.NETWORKS_STEP_INDEX) + instance_form.network.transfer_available_resource( + instance_page.DEFAULT_NETWORK_TYPE) + instance_form.submit() + instance_form.wait_till_wizard_disappears() diff --git a/openstack_dashboard/test/integration_tests/regions/forms.py b/openstack_dashboard/test/integration_tests/regions/forms.py index b738fb941..2df2c50f8 100644 --- a/openstack_dashboard/test/integration_tests/regions/forms.py +++ b/openstack_dashboard/test/integration_tests/regions/forms.py @@ -231,6 +231,22 @@ class SelectFormFieldRegion(BaseFormFieldRegion): self.driver.execute_script(js_cmd) +class ButtonGroupFormFieldRegion(BaseFormFieldRegion): + """Select button group.""" + + _element_locator_str_suffix = 'div.btn-group' + _button_label_locator = (by.By.CSS_SELECTOR, 'label.btn') + + @property + def options(self): + options = self._get_elements(*self._button_label_locator) + results = {opt.text: opt for opt in options} + return results + + def pick(self, option): + return self.options[option].click() + + class ThemableSelectFormFieldRegion(BaseFormFieldRegion): """Select box field.""" @@ -298,8 +314,7 @@ class ThemableSelectFormFieldRegion(BaseFormFieldRegion): class BaseFormRegion(baseregion.BaseRegion): """Base class for forms.""" - _submit_locator = (by.By.CSS_SELECTOR, '*.btn.btn-primary') - _submit_danger_locator = (by.By.CSS_SELECTOR, '*.btn.btn-danger') + _submit_locator = (by.By.CSS_SELECTOR, '*.btn.btn-primary,*.btn.btn-danger') _cancel_locator = (by.By.CSS_SELECTOR, '*.btn.cancel') _default_form_locator = (by.By.CSS_SELECTOR, 'div.modal-dialog') @@ -315,10 +330,7 @@ class BaseFormRegion(baseregion.BaseRegion): @property def _submit_element(self): - try: - submit_element = self._get_element(*self._submit_locator) - except exceptions.NoSuchElementException: - submit_element = self._get_element(*self._submit_danger_locator) + submit_element = self._get_element(*self._submit_locator) return submit_element def submit(self): @@ -426,6 +438,13 @@ class FormRegion(BaseFormRegion): return self._get_form_fields() +class FormRegionNG(FormRegion): + """Angular-based form.""" + + _fields_locator = (by.By.CSS_SELECTOR, 'div.content') + _submit_locator = (by.By.CSS_SELECTOR, '*.btn.btn-primary.finish') + + class TabbedFormRegion(FormRegion): """Forms that are divided with tabs. diff --git a/openstack_dashboard/test/integration_tests/regions/tables.py b/openstack_dashboard/test/integration_tests/regions/tables.py index 9778950b3..6d9334edd 100644 --- a/openstack_dashboard/test/integration_tests/regions/tables.py +++ b/openstack_dashboard/test/integration_tests/regions/tables.py @@ -45,6 +45,12 @@ class RowRegion(baseregion.BaseRegion): chck_box.click() +class RowRegionNG(RowRegion): + """Angular-based table row.""" + + _cell_locator = (by.By.CSS_SELECTOR, 'td > hz-cell') + + class TableRegion(baseregion.BaseRegion): """Basic class representing table object.""" @@ -253,6 +259,38 @@ class TableRegion(baseregion.BaseRegion): self.assertDictEqual(actual_table, expected_table_definition) +class TableRegionNG(TableRegion): + """Basic class representing angular-based table object.""" + + _heading_locator = (by.By.CSS_SELECTOR, + 'hz-resource-panel hz-page-header h1') + _empty_table_locator = (by.By.CSS_SELECTOR, 'tbody > tr > td.no-rows-help') + + def _table_locator(self, table_name): + return by.By.CSS_SELECTOR, 'hz-dynamic-table' + + @property + def _next_locator(self): + return by.By.CSS_SELECTOR, 'a[ng-click^="selectPage(currentPage + 1)"]' + + @property + def _prev_locator(self): + return by.By.CSS_SELECTOR, 'a[ng-click^="selectPage(currentPage - 1)"]' + + @property + def column_names(self): + names = [] + for element in self._get_elements(*self._columns_names_locator): + if element.text: + names.append(element.text) + return names + + def _get_rows(self, *args): + return [RowRegionNG(self.driver, self.conf, elem, self.column_names) + for elem in self._get_elements(*self._rows_locator) + if elem.text and elem.text != ''] + + def bind_table_action(action_name): """Decorator to bind table region method to an actual table action button. @@ -290,6 +328,44 @@ def bind_table_action(action_name): return decorator +def bind_table_action_ng(action_name): + """Decorator to bind table region method to an actual table action button. + + This decorator works with angular-based tables. + Many table actions when started (by clicking a corresponding button + in UI) lead to some form showing up. To further interact with this form, + a Python/ Selenium wrapper needs to be created for it. It is very + convenient to return this newly created wrapper in the same method that + initiates clicking an actual table action button. Binding the method to a + button is performed behind the scenes in this decorator. + + .. param:: action_name + + Part of the action button id which is specific to action itself. It + is safe to use action `name` attribute from the dashboard tables.py + code. + """ + _actions_locator = (by.By.CSS_SELECTOR, + 'actions.hz-dynamic-table-actions > action-list') + + def decorator(method): + @functools.wraps(method) + def wrapper(table): + actions = table._get_elements(*_actions_locator) + action_element = None + for action in actions: + if action.text == action_name: + action_element = action + break + if action_element is None: + msg = "Could not bind method '%s' to action control '%s'" % ( + method.__name__, action_name) + raise ValueError(msg) + return method(table, action_element) + return wrapper + return decorator + + def bind_row_action(action_name): """A decorator to bind table region method to an actual row action button. @@ -347,11 +423,67 @@ def bind_row_action(action_name): return decorator +def bind_row_action_ng(action_name): + """A decorator to bind table region method to an actual row action button. + + This decorator works with angular-based tables. + Many table actions when started (by clicking a corresponding button + in UI) lead to some form showing up. To further interact with this form, + a Python/ Selenium wrapper needs to be created for it. It is very + convenient to return this newly created wrapper in the same method that + initiates clicking an actual action button. Row action could be + either primary (if its name is written right away on row action + button) or secondary (if its name is inside of a button drop-down). Binding + the method to a button and toggling the button drop-down open (in case + a row action is secondary) is performed behind the scenes in this + decorator. + + .. param:: action_name + + Part of the action button id which is specific to action itself. It + is safe to use action `name` attribute from the dashboard tables.py + code. + """ + primary_action_locator = ( + by.By.CSS_SELECTOR, + 'td.actions_column > actions > action-list > button.split-button') + secondary_actions_opener_locator = ( + by.By.CSS_SELECTOR, + 'td.actions_column > actions > action-list > button.split-caret') + secondary_actions_locator = ( + by.By.CSS_SELECTOR, + 'td.actions_column > actions > action-list > ul > li > a') + + def decorator(method): + @functools.wraps(method) + def wrapper(table, row): + def find_action(element): + pattern = action_name + return element.text.endswith(pattern) + + action_element = row._get_element(*primary_action_locator) + if not find_action(action_element): + action_element = None + row._get_element(*secondary_actions_opener_locator).click() + for element in row._get_elements(*secondary_actions_locator): + if find_action(element): + action_element = element + break + + if action_element is None: + msg = "Could not bind method '%s' to action control '%s'" % ( + method.__name__, action_name) + raise ValueError(msg) + return method(table, action_element, row) + return wrapper + return decorator + + def bind_row_anchor_column(column_name): """A decorator to bind table region method to a anchor in a column. - Typical examples of such tables are Project -> Compute -> Images, Admin - -> System -> Flavors, Project -> Compute -> Instancies. + Typical examples of such tables are Project -> Compute -> Instances, Admin + -> System -> Flavors. The method can be used to follow the link in the anchor by the click. """ @@ -365,3 +497,24 @@ def bind_row_anchor_column(column_name): return wrapper return decorator + + +def bind_row_anchor_column_ng(column_name): + """A decorator to bind table region method to a anchor in a column. + + This decorator works with angular-based tables. + Typical examples of such tables are Project -> Compute -> Images, + Admin -> Compute -> Images. + The method can be used to follow the link in the anchor by the click. + """ + + def decorator(method): + @functools.wraps(method) + def wrapper(table, row): + cell = row.cells[column_name] + action_element = cell.find_element( + by.By.CSS_SELECTOR, 'td > hz-cell > a') + return method(table, action_element, row) + + return wrapper + return decorator diff --git a/openstack_dashboard/test/integration_tests/tests/test_images.py b/openstack_dashboard/test/integration_tests/tests/test_images.py index cd71ca2a6..8065eaa92 100644 --- a/openstack_dashboard/test/integration_tests/tests/test_images.py +++ b/openstack_dashboard/test/integration_tests/tests/test_images.py @@ -11,14 +11,16 @@ # under the License. import pytest -from openstack_dashboard.test.integration_tests import decorators from openstack_dashboard.test.integration_tests import helpers from openstack_dashboard.test.integration_tests.regions import messages +from openstack_dashboard.test.integration_tests.pages.project.\ + compute.instancespage import InstancesPage +from openstack_dashboard.test.integration_tests.pages.project.\ + volumes.volumespage import VolumesPage -@decorators.config_option_required('image.panel_type', 'legacy', - message="Angular Panels not tested") -class TestImagesLegacy(helpers.TestCase): + +class TestImagesBasicAngular(helpers.TestCase): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.IMAGE_NAME = helpers.gen_random_resource_name("image") @@ -27,30 +29,10 @@ class TestImagesLegacy(helpers.TestCase): def images_page(self): return self.home_pg.go_to_project_compute_imagespage() - -@decorators.config_option_required('image.panel_type', 'angular', - message="Legacy Panels not tested") -class TestImagesAngular(helpers.TestCase): - @property - def images_page(self): - # FIXME(tsufiev): had to return angularized version of Images Page - # object with the horrendous hack below because it's not so easy to - # wire into the Navigation machinery and tell it to return an '*NG' - # version of ImagesPage class if one adds '_ng' suffix to - # 'go_to_compute_imagespage()' method. Yet that's how it should work - # (or rewrite Navigation module completely). - from openstack_dashboard.test.integration_tests.pages.project.\ - compute.imagespage import ImagesPageNG - self.home_pg.go_to_project_compute_imagespage() - return ImagesPageNG(self.driver, self.CONFIG) - def test_basic_image_browse(self): images_page = self.images_page self.assertEqual(images_page.header.text, 'Images') - -class TestImagesBasic(TestImagesLegacy): - """Login as demo user""" def image_create(self, local_file=None, **kwargs): images_page = self.images_page if local_file: @@ -58,8 +40,10 @@ class TestImagesBasic(TestImagesLegacy): image_file=local_file, **kwargs) else: - images_page.create_image(self.IMAGE_NAME, **kwargs) - self.assertTrue(images_page.find_message_and_dismiss(messages.INFO)) + images_page.create_image(self.IMAGE_NAME, + image_source_type='url', + **kwargs) + self.assertTrue(images_page.find_message_and_dismiss(messages.SUCCESS)) self.assertFalse(images_page.find_message_and_dismiss(messages.ERROR)) self.assertTrue(images_page.is_image_present(self.IMAGE_NAME)) self.assertTrue(images_page.is_image_active(self.IMAGE_NAME)) @@ -72,30 +56,30 @@ class TestImagesBasic(TestImagesLegacy): self.assertFalse(images_page.find_message_and_dismiss(messages.ERROR)) self.assertFalse(images_page.is_image_present(self.IMAGE_NAME)) - @pytest.mark.skip(reason="Bug 1595335") - def test_image_create_delete(self): + def test_image_create_delete_from_local_file(self): """tests the image creation and deletion functionalities: - * creates a new image from horizon.conf http_image + * creates a new image from a generated file * verifies the image appears in the images table as active * deletes the newly created image * verifies the image does not appear in the table after deletion """ - self.image_create() - self.image_delete(self.IMAGE_NAME) + with helpers.gen_temporary_file() as file_name: + self.image_create(local_file=file_name) + self.image_delete(self.IMAGE_NAME) - def test_image_create_delete_from_local_file(self): + # Run when Glance configuration and policies allow setting locations. + @pytest.mark.skip(reason="IMAGES_ALLOW_LOCATION = False") + def test_image_create_delete_from_url(self): """tests the image creation and deletion functionalities: - * downloads image from horizon.conf stated in http_image - * creates the image from the downloaded file + * creates a new image from horizon.conf http_image * verifies the image appears in the images table as active * deletes the newly created image * verifies the image does not appear in the table after deletion """ - with helpers.gen_temporary_file() as file_name: - self.image_create(local_file=file_name) - self.image_delete(self.IMAGE_NAME) + self.image_create() + self.image_delete(self.IMAGE_NAME) def test_images_pagination(self): """This test checks images pagination @@ -115,23 +99,51 @@ class TestImagesBasic(TestImagesLegacy): 9) Click 'Prev' and check results (should be the same as for step5) 10) Go to user settings page and restore 'Items Per Page' """ + default_image_list = self.CONFIG.image.images_list + + images_page = self.images_page + + # delete any old images except default ones + images_page.wait_until_image_present(default_image_list[0]) + image_list = images_page.images_table.get_column_data( + name_column='Name') + garbage = [i for i in image_list if i not in default_image_list] + if garbage: + images_page.delete_images(garbage) + self.assertTrue( + images_page.find_message_and_dismiss(messages.SUCCESS)) + items_per_page = 1 + images_count = 2 + images_names = ["{0}_{1}".format(self.IMAGE_NAME, item) + for item in range(images_count)] + for image_name in images_names: + with helpers.gen_temporary_file() as file_name: + images_page.create_image(image_name, image_file=file_name) + self.assertTrue( + images_page.find_message_and_dismiss(messages.SUCCESS)) + self.assertFalse( + images_page.find_message_and_dismiss(messages.ERROR)) + self.assertTrue(images_page.is_image_present(image_name)) + first_page_definition = {'Next': True, 'Prev': False, 'Count': items_per_page, 'Names': [default_image_list[0]]} second_page_definition = {'Next': True, 'Prev': True, 'Count': items_per_page, - 'Names': [default_image_list[1]]} + 'Names': [images_names[0]]} third_page_definition = {'Next': False, 'Prev': True, 'Count': items_per_page, - 'Names': [default_image_list[2]]} + 'Names': [images_names[1]]} settings_page = self.home_pg.go_to_settings_usersettingspage() settings_page.change_pagesize(items_per_page) settings_page.find_message_and_dismiss(messages.SUCCESS) images_page = self.images_page + if not images_page.is_image_present(default_image_list[0]): + images_page.wait_until_image_present(default_image_list[0]) images_page.images_table.assert_definition(first_page_definition) images_page.images_table.turn_next_page() @@ -150,6 +162,20 @@ class TestImagesBasic(TestImagesLegacy): settings_page.change_pagesize() settings_page.find_message_and_dismiss(messages.SUCCESS) + images_page = self.images_page + images_page.wait_until_image_present(default_image_list[0]) + images_page.delete_images(images_names) + self.assertTrue(images_page.find_message_and_dismiss(messages.SUCCESS)) + self.assertFalse(images_page.find_message_and_dismiss(messages.ERROR)) + + +class TestImagesAdminAngular(helpers.AdminTestCase, TestImagesBasicAngular): + """Login as admin user""" + + @property + def images_page(self): + return self.home_pg.go_to_admin_compute_imagespage() + def test_update_image_metadata(self): """Test update image metadata @@ -168,9 +194,6 @@ class TestImagesBasic(TestImagesLegacy): 'metadata2': helpers.gen_random_resource_name("value")} with helpers.gen_temporary_file() as file_name: - # TODO(tsufiev): had to add non-empty description to an image, - # because description is now considered a metadata and we want - # the metadata in a newly created image to be valid images_page = self.image_create(local_file=file_name, description='test description') images_page.add_custom_metadata(self.IMAGE_NAME, new_metadata) @@ -203,21 +226,24 @@ class TestImagesBasic(TestImagesLegacy): # Check that Delete action is not available in the action list. # The below action will generate exception since the bind fails. # But only ValueError with message below is expected here. - with self.assertRaisesRegex(ValueError, 'Could not bind method'): + message = "Could not bind method 'delete_image_via_row_action' " \ + "to action control 'Delete Image'" + with self.assertRaisesRegex(ValueError, message): images_page.delete_image_via_row_action(self.IMAGE_NAME) - # Try to delete image. That should not be possible now. - images_page.delete_image(self.IMAGE_NAME) - self.assertFalse( - images_page.find_message_and_dismiss(messages.SUCCESS)) - self.assertTrue( - images_page.find_message_and_dismiss(messages.ERROR)) - self.assertTrue(images_page.is_image_present(self.IMAGE_NAME)) + # Edit image to make it not protected again and delete it. + images_page = self.images_page images_page.edit_image(self.IMAGE_NAME, protected=False) self.assertTrue( images_page.find_message_and_dismiss(messages.SUCCESS)) + self.assertFalse( + images_page.find_message_and_dismiss(messages.ERROR)) + self.image_delete(self.IMAGE_NAME) + self.assertFalse( + images_page.find_message_and_dismiss(messages.ERROR)) + self.assertFalse(images_page.is_image_present(self.IMAGE_NAME)) def test_edit_image_description_and_name(self): """tests that image description is editable @@ -264,9 +290,53 @@ class TestImagesBasic(TestImagesLegacy): self.assertSequenceTrue(results) self.image_delete(new_image_name) + self.assertFalse( + images_page.find_message_and_dismiss(messages.ERROR)) + self.assertFalse(images_page.is_image_present(self.IMAGE_NAME)) + def test_filter_images(self): + """This test checks filtering of images + + Steps: + 1) Login to Horizon dashboard as admin user + 2) Go to Admin -> Compute -> Images + 3) Use filter by Image Name + 4) Check that filtered table has one image only (which name is + equal to filter value) + 5) Check that no other images in the table + 6) Clear filter and set nonexistent image name. Check that 0 rows + are displayed + """ + default_image_list = self.CONFIG.image.images_list + images_page = self.images_page + + images_page.filter(default_image_list[0]) + self.assertTrue(images_page.is_image_present(default_image_list[0])) + for image in default_image_list[1:]: + self.assertFalse(images_page.is_image_present(image)) + + images_page = self.images_page + nonexistent_image_name = "{0}_test".format(self.IMAGE_NAME) + images_page.filter(nonexistent_image_name) + self.assertEqual(images_page.images_table.rows, []) + + images_page.filter('') + + +class TestImagesAdvancedAngular(helpers.TestCase): + + @property + def images_page(self): + return self.home_pg.go_to_project_compute_imagespage() + + def volumes_page(self): + self.home_pg.go_to_project_volumes_volumespage() + return VolumesPage(self.driver, self.CONFIG) + + def instances_page(self): + self.home_pg.go_to_project_compute_instancespage() + return InstancesPage(self.driver, self.CONFIG) -class TestImagesAdvanced(TestImagesLegacy): """Login as demo user""" def test_create_volume_from_image(self): """This test case checks create volume from image functionality: @@ -282,18 +352,23 @@ class TestImagesAdvanced(TestImagesLegacy): source_image = self.CONFIG.image.images_list[0] target_volume = "created_from_{0}".format(source_image) - volumes_page = images_page.create_volume_from_image( + images_page.create_volume_from_image( source_image, volume_name=target_volume) self.assertTrue( - volumes_page.find_message_and_dismiss(messages.INFO)) + images_page.find_message_and_dismiss(messages.INFO)) self.assertFalse( - volumes_page.find_message_and_dismiss(messages.ERROR)) + images_page.find_message_and_dismiss(messages.ERROR)) + + volumes_page = self.volumes_page() + self.assertTrue(volumes_page.is_volume_present(target_volume)) self.assertTrue(volumes_page.is_volume_status(target_volume, 'Available')) volumes_page.delete_volume(target_volume) volumes_page.find_message_and_dismiss(messages.SUCCESS) volumes_page.find_message_and_dismiss(messages.ERROR) + + volumes_page = self.volumes_page() self.assertTrue(volumes_page.is_volume_deleted(target_volume)) def test_launch_instance_from_image(self): @@ -310,57 +385,22 @@ class TestImagesAdvanced(TestImagesLegacy): images_page = self.images_page source_image = self.CONFIG.image.images_list[0] target_instance = "created_from_{0}".format(source_image) - instances_page = images_page.launch_instance_from_image( - source_image, target_instance) + + images_page.launch_instance_from_image(source_image, target_instance) self.assertTrue( - instances_page.find_message_and_dismiss(messages.SUCCESS)) + images_page.find_message_and_dismiss(messages.INFO)) self.assertFalse( - instances_page.find_message_and_dismiss(messages.ERROR)) + images_page.find_message_and_dismiss(messages.ERROR)) + + instances_page = self.instances_page() self.assertTrue(instances_page.is_instance_active(target_instance)) + instances_page = self.instances_page() actual_image_name = instances_page.get_image_name(target_instance) self.assertEqual(source_image, actual_image_name) instances_page.delete_instance(target_instance) self.assertTrue( - instances_page.find_message_and_dismiss(messages.SUCCESS)) + instances_page.find_message_and_dismiss(messages.INFO)) self.assertFalse( instances_page.find_message_and_dismiss(messages.ERROR)) self.assertTrue(instances_page.is_instance_deleted(target_instance)) - - -class TestImagesAdmin(helpers.AdminTestCase, TestImagesLegacy): - """Login as admin user""" - @property - def images_page(self): - return self.home_pg.go_to_admin_compute_imagespage() - - @pytest.mark.skip(reason="Bug 1774697") - def test_image_create_delete(self): - super().test_image_create_delete() - - def test_filter_images(self): - """This test checks filtering of images - - Steps: - 1) Login to Horizon dashboard as admin user - 2) Go to Admin -> Compute -> Images - 3) Use filter by Image Name - 4) Check that filtered table has one image only (which name is - equal to filter value) - 5) Check that no other images in the table - 6) Clear filter and set nonexistent image name. Check that 0 rows - are displayed - """ - images_list = self.CONFIG.image.images_list - images_page = self.images_page - - images_page.images_table.filter(images_list[0]) - self.assertTrue(images_page.is_image_present(images_list[0])) - for image in images_list[1:]: - self.assertFalse(images_page.is_image_present(image)) - - nonexistent_image_name = "{0}_test".format(self.IMAGE_NAME) - images_page.images_table.filter(nonexistent_image_name) - self.assertEqual(images_page.images_table.rows, []) - - images_page.images_table.filter('') diff --git a/openstack_dashboard/test/integration_tests/tests/test_volumes.py b/openstack_dashboard/test/integration_tests/tests/test_volumes.py index 95108134b..d3c4b926c 100644 --- a/openstack_dashboard/test/integration_tests/tests/test_volumes.py +++ b/openstack_dashboard/test/integration_tests/tests/test_volumes.py @@ -101,6 +101,15 @@ class TestVolumesBasic(helpers.TestCase): 12) Delete created volumes """ volumes_page = self.home_pg.go_to_project_volumes_volumespage() + + # delete any old instances + garbage = volumes_page.volumes_table.get_column_data( + name_column='Name') + if garbage: + volumes_page.delete_volumes(garbage) + self.assertTrue( + volumes_page.find_message_and_dismiss(messages.INFO)) + count = 3 items_per_page = 1 volumes_names = ["{0}_{1}".format(self.VOLUME_NAME, i) for i in @@ -242,6 +251,10 @@ class TestVolumesActions(helpers.TestCase): def volumes_page(self): return self.home_pg.go_to_project_volumes_volumespage() + @property + def images_page(self): + return self.home_pg.go_to_project_compute_imagespage() + def setUp(self): super().setUp() volumes_page = self.volumes_page @@ -288,7 +301,6 @@ class TestVolumesActions(helpers.TestCase): new_size = volumes_page.get_size(self.VOLUME_NAME) self.assertLess(orig_size, new_size) - @pytest.mark.skip(reason="Bug 1847715") def test_volume_upload_to_image(self): """This test case checks upload volume to image functionality: @@ -299,29 +311,28 @@ class TestVolumesActions(helpers.TestCase): 4. Delete the image 5. Repeat actions for all disk formats """ - self.volumes_page = self.home_pg.go_to_project_volumes_volumespage() - all_formats = {"qcow2": 'QCOW2', "raw": 'Raw', "vdi": 'VDI', + volumes_page = self.volumes_page + all_formats = {"qcow2": 'QCOW2', "raw": 'RAW', "vdi": 'VDI', "vmdk": 'VMDK'} for disk_format in all_formats: - self.volumes_page.upload_volume_to_image(self.VOLUME_NAME, - self.IMAGE_NAME, - disk_format) + volumes_page.upload_volume_to_image( + self.VOLUME_NAME, self.IMAGE_NAME, disk_format) self.assertFalse( - self.volumes_page.find_message_and_dismiss(messages.ERROR)) - self.assertTrue(self.volumes_page.is_volume_status( + volumes_page.find_message_and_dismiss(messages.ERROR)) + self.assertTrue(volumes_page.is_volume_status( self.VOLUME_NAME, 'Available')) - images_page = self.home_pg.go_to_project_compute_imagespage() + images_page = self.images_page self.assertTrue(images_page.is_image_present(self.IMAGE_NAME)) self.assertTrue(images_page.is_image_active(self.IMAGE_NAME)) self.assertEqual(images_page.get_image_format(self.IMAGE_NAME), all_formats[disk_format]) images_page.delete_image(self.IMAGE_NAME) self.assertTrue(images_page.find_message_and_dismiss( - messages.INFO)) + messages.SUCCESS)) self.assertFalse(images_page.find_message_and_dismiss( messages.ERROR)) self.assertFalse(images_page.is_image_present(self.IMAGE_NAME)) - self.volumes_page = \ + volumes_page = \ self.home_pg.go_to_project_volumes_volumespage() @pytest.mark.skip(reason="Bug 1774697") diff --git a/openstack_dashboard/test/unit/api/test_keystone.py b/openstack_dashboard/test/unit/api/test_keystone.py index 4598c2d50..71e8a6314 100644 --- a/openstack_dashboard/test/unit/api/test_keystone.py +++ b/openstack_dashboard/test/unit/api/test_keystone.py @@ -21,6 +21,7 @@ from unittest import mock from django.test.utils import override_settings from openstack_dashboard import api +from openstack_dashboard import policy from openstack_dashboard.test import helpers as test @@ -164,3 +165,46 @@ class APIVersionTests(test.APIMockTestCase): keystoneclient.session.get_endpoint_data.assert_called_once_with( service_type='identity') self.assertEqual((3, 10), api_version) + + +class ApplicationCredentialsAPITests(test.APIMockTestCase): + + @mock.patch.object(policy, 'check') + @mock.patch.object(api.keystone, 'keystoneclient') + def test_application_credential_create_domain_token_removed( + self, mock_keystoneclient, mock_policy): + self.request.session['domain_token'] = 'some_token' + mock_policy.return_value = False + api.keystone.application_credential_create(self.request, None) + mock_keystoneclient.assert_called_once_with( + self.request, force_scoped=True) + + @mock.patch.object(policy, 'check') + @mock.patch.object(api.keystone, 'keystoneclient') + def test_application_credential_create_domain_token_not_removed_policy_true( + self, mock_keystoneclient, mock_policy): + self.request.session['domain_token'] = 'some_token' + mock_policy.return_value = True + api.keystone.application_credential_create(self.request, None) + mock_keystoneclient.assert_called_once_with( + self.request, force_scoped=False) + + @mock.patch.object(policy, 'check') + @mock.patch.object(api.keystone, 'keystoneclient') + def test_application_credential_create_domain_token_not_removed_no_token( + self, mock_keystoneclient, mock_policy): + mock_policy.return_value = True + api.keystone.application_credential_create(self.request, None) + mock_keystoneclient.assert_called_once_with( + self.request, force_scoped=False) + + @mock.patch.object(policy, 'check') + @mock.patch.object(api.keystone, 'keystoneclient') + def test_application_credential_create_domain_token_not_removed_no_project( + self, mock_keystoneclient, mock_policy): + self.request.session['domain_token'] = 'some_token' + mock_policy.return_value = True + self.request.user.project_id = None + api.keystone.application_credential_create(self.request, None) + mock_keystoneclient.assert_called_once_with( + self.request, force_scoped=False) |