summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.zuul.d/nodejs-jobs.yaml8
-rw-r--r--doc/source/install/install-debian.rst6
-rw-r--r--doc/source/install/install-obs.rst6
-rw-r--r--doc/source/install/install-rdo.rst6
-rw-r--r--doc/source/install/install-ubuntu.rst6
-rw-r--r--horizon/static/framework/widgets/metadata/tree/metadata-tree-item.controller.js8
-rw-r--r--horizon/static/framework/widgets/metadata/tree/tree.service.js2
-rw-r--r--horizon/test/webdriver.py4
-rw-r--r--openstack_dashboard/api/keystone.py17
-rw-r--r--openstack_dashboard/api/microversions.py1
-rw-r--r--openstack_dashboard/api/nova.py15
-rw-r--r--openstack_dashboard/dashboards/admin/flavors/workflows.py4
-rw-r--r--openstack_dashboard/dashboards/admin/networks/ports/tests.py10
-rw-r--r--openstack_dashboard/dashboards/project/instances/tests.py49
-rw-r--r--openstack_dashboard/dashboards/project/instances/utils.py20
-rw-r--r--openstack_dashboard/dashboards/project/networks/ports/tests.py8
-rw-r--r--openstack_dashboard/dashboards/project/networks/ports/workflows.py8
-rw-r--r--openstack_dashboard/dashboards/project/static/dashboard/project/workflow/launch-instance/source/source.controller.js13
-rw-r--r--openstack_dashboard/static/app/core/images/steps/create-image/create-image.html16
-rw-r--r--openstack_dashboard/static/app/core/images/steps/edit-image/edit-image.html4
-rw-r--r--openstack_dashboard/test/integration_tests/config.py6
-rw-r--r--openstack_dashboard/test/integration_tests/horizon.conf5
-rw-r--r--openstack_dashboard/test/integration_tests/pages/project/compute/imagespage.py246
-rw-r--r--openstack_dashboard/test/integration_tests/regions/forms.py31
-rw-r--r--openstack_dashboard/test/integration_tests/regions/tables.py157
-rw-r--r--openstack_dashboard/test/integration_tests/tests/test_images.py234
-rw-r--r--openstack_dashboard/test/integration_tests/tests/test_volumes.py33
-rw-r--r--openstack_dashboard/test/unit/api/test_keystone.py44
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)