From 93d8a1e160ef2f3be1a6dcb53cc6256cd25a17de Mon Sep 17 00:00:00 2001 From: Aleksey Nakoryakov Date: Wed, 19 Jul 2017 10:21:43 +0300 Subject: [PATCH] Show resource usages for application Show them in the description section right under the Flavor field title (as quota usages + predicted increment progress bar). Co-Authored-By: Timur Sufiev Co-Authored-By: Artem Tiumentcev Change-Id: I842cbce209ea90ab715d2e50824296a19c202a76 --- muranodashboard/catalog/views.py | 123 ++++++++++++++++++ muranodashboard/dynamic_ui/fields.py | 57 +++++--- muranodashboard/dynamic_ui/forms.py | 5 +- muranodashboard/dynamic_ui/helpers.py | 12 ++ muranodashboard/dynamic_ui/services.py | 13 +- .../templates/services/_wizard_create.html | 119 ++++++++++++++++- muranodashboard/tests/test_fields.py | 22 ++-- .../tests/unit/catalog/test_views.py | 38 +++++- .../tests/unit/dynamic_ui/test_fields.py | 44 ++++--- .../tests/unit/dynamic_ui/test_forms.py | 4 +- .../notes/show-resource-91a1f73cdb5d74ab.yaml | 6 + 11 files changed, 375 insertions(+), 68 deletions(-) create mode 100644 releasenotes/notes/show-resource-91a1f73cdb5d74ab.yaml diff --git a/muranodashboard/catalog/views.py b/muranodashboard/catalog/views.py index df6f51354..d9d45b74e 100644 --- a/muranodashboard/catalog/views.py +++ b/muranodashboard/catalog/views.py @@ -44,6 +44,9 @@ from horizon.forms import views from horizon import messages from horizon import tabs from horizon import views as generic_views +from novaclient import exceptions as nova_exceptions +from openstack_dashboard.api import nova +from openstack_dashboard.usage import quotas from oslo_log import log as logging import six @@ -358,6 +361,8 @@ class Wizard(generic_views.PageTitleMixin, views.ModalFormMixin, LazyWizard): storage = attributes.setdefault('?', {}).setdefault( consts.DASHBOARD_ATTRS_KEY, {}) storage['name'] = app_name + attributes['?']['resourceUsages'] = self.aggregate_usages( + self.init_usages()[1:]) do_redirect = self.get_wizard_flag('do_redirect') wm_form_data = service.cleaned_data.get('workflowManagement') @@ -440,6 +445,122 @@ class Wizard(generic_views.PageTitleMixin, views.ModalFormMixin, LazyWizard): value = self._get_wizard_param(key) return utils.ensure_python_obj(value) + def get_flavors(self): + try: + flavors = nova.flavor_list(self.request) + except nova_exceptions.ClientException: + message = _("Failed to get list of flavors.") + exceptions.handle(self.request, message) + LOG.exception(message) + flavors = [] + + def extract(flavor): + info = flavor._info + return {k: v for (k, v) in info.items() if k != 'links'} + flavors = [extract(f) for f in flavors] + self.storage.extra_data['flavors'] = flavors + return json.dumps(flavors) + + def get_flavor_usages(self, form): + selected_flavor = form.cleaned_data['flavor'] + for flavor in self.storage.extra_data['flavors']: + if flavor['name'] == selected_flavor: + return {'ram': flavor['ram'], + 'vcpus': flavor['vcpus'], + 'instances': 1} + + def init_usages(self): + stored_data = self.storage.extra_data + step_usages = stored_data.get('step_usages') + if step_usages is None: + step_usages = [ + collections.defaultdict(dict) + for step in self.steps.all + ] + stored_data['step_usages'] = step_usages + + environment_id = self.kwargs.get('environment_id') + environment_id = utils.ensure_python_obj(environment_id) + if environment_id is not None: + session_id = env_api.Session.get(self.request, environment_id) + client = api.muranoclient(self.request) + all_services = client.environments.get( + environment_id, session_id).services + env_usages = self.aggregate_usages(map( + lambda svc: svc['?'].get('resourceUsages', {}), + all_services)) + else: + env_usages = collections.defaultdict(dict) + step_usages.insert(0, env_usages) + + return step_usages + + def process_step(self, form): + data = super(Wizard, self).process_step(form) + region = form.region or self.request.user.services_region + step_usages = self.init_usages() + if 'flavor' in form.cleaned_data: + usages = self.get_flavor_usages(form) + step_usages[self.steps.step0 + 1][region].update({ + 'ram': usages['ram'], + 'vcpus': usages['vcpus'], + 'instances': usages['instances'] + }) + else: + step_usages[self.steps.step0 + 1][region].update({ + 'ram': 0, + 'vcpus': 0, + 'instances': 0 + }) + + return data + + def update_usages(self, form, context): + data = self.init_usages() + usages = quotas.tenant_quota_usages(self.request).usages + region = self.request.user.services_region + inf = float('inf') + + def get_usage(group, name, default): + return usages.get(group, {}).get(name, default) + + context.update({ + 'usages': { + 'maxTotalInstances': get_usage('instances', 'quota', inf), + 'totalInstancesUsed': get_usage('instances', 'used', 0), + 'maxTotalCores': get_usage('cores', 'quota', inf), + 'totalCoresUsed': get_usage('cores', 'used', 0), + 'maxTotalRAMSize': get_usage('ram', 'quota', inf), + 'totalRAMUsed': get_usage('ram', 'used', 0), + }, + 'other_usages': {}, + 'flavors': self.get_flavors(), + 'contexts': ['', 'info', 'success'] + }) + for step in range(self.steps.step0 + 1): + + def sum_usage(context_key, data_key): + if context_key not in context['other_usages']: + context['other_usages'][context_key] = 0 + context['other_usages'][context_key] += \ + data[step][region].get(data_key, 0) + sum_usage('totalInstancesUsed', 'instances') + sum_usage('totalCoresUsed', 'vcpus') + sum_usage('totalRAMUsed', 'ram') + + return context + + @staticmethod + def aggregate_usages(steps): + result = collections.defaultdict(dict) + for step in steps: + for region, region_usages in six.iteritems(step): + for metric, value in six.iteritems(region_usages): + if metric not in result[region]: + result[region][metric] = 0 + result[region][metric] += value + return result + def get_context_data(self, form, **kwargs): context = super(Wizard, self).get_context_data(form=form, **kwargs) mc = api.muranoclient(self.request) @@ -477,6 +598,8 @@ class Wizard(generic_views.PageTitleMixin, views.ModalFormMixin, LazyWizard): 'field_descriptions': field_descr, 'extended_descriptions': extended_descr, }) + with helpers.current_region(self.request, form.region): + context = self.update_usages(form, context) return context diff --git a/muranodashboard/dynamic_ui/fields.py b/muranodashboard/dynamic_ui/fields.py index 6fbd3696e..f9c316d79 100644 --- a/muranodashboard/dynamic_ui/fields.py +++ b/muranodashboard/dynamic_ui/fields.py @@ -39,6 +39,7 @@ from yaql import legacy from muranodashboard.api import packages as pkg_api from muranodashboard.common import net +from muranodashboard.dynamic_ui import helpers from muranodashboard.environments import api as env_api @@ -106,13 +107,14 @@ def wrap_regex_validator(validator, message): return _validator -def get_murano_images(request): +def get_murano_images(request, region=None): images = [] try: # https://bugs.launchpad.net/murano/+bug/1339261 - glance # client version change alters the API. Other tuple values # are _more and _prev (in recent glance client) - images = glance.image_list_detailed(request)[0] + with helpers.current_region(request, region): + images = glance.image_list_detailed(request)[0] except Exception: LOG.error("Error to request image list from glance ") exceptions.handle(request, _("Unable to retrieve public images.")) @@ -355,20 +357,31 @@ class DynamicChoiceField(hz_forms.DynamicChoiceField, CustomPropertiesField): pass +class FlavorWidget(widgets.Select): + def __init__(self, *args, **kwargs): + super(FlavorWidget, self).__init__(*args, **kwargs) + self.attrs['class'] = self.attrs.get('class', '') + ' flavor' + self.attrs['id'] = 'id_flavor' + + class FlavorChoiceField(ChoiceField): + widget = FlavorWidget + def __init__(self, *args, **kwargs): if 'requirements' in kwargs: self.requirements = kwargs.pop('requirements') super(FlavorChoiceField, self).__init__(*args, **kwargs) @with_request - def update(self, request, **kwargs): + def update(self, request, form=None, **kwargs): choices = [] - flavors = nova.novaclient(request).flavors.list() + with helpers.current_region(request, + getattr(form, 'region', None)): + flavors = nova.novaclient(request).flavors.list() # If no requirements are present, return all the flavors. if not hasattr(self, 'requirements'): - choices = [(flavor.name, flavor.name) for flavor in flavors] + choices = [(flavor.id, flavor.name) for flavor in flavors] else: for flavor in flavors: # If a flavor doesn't meet a minimum requirement, @@ -389,7 +402,7 @@ class FlavorChoiceField(ChoiceField): if 'max_memory_mb' in self.requirements: if flavor.ram > self.requirements['max_memory_mb']: continue - choices.append((flavor.name, flavor.name)) + choices.append((flavor.id, flavor.name)) choices.sort(key=lambda e: e[1]) self.choices = choices @@ -401,20 +414,27 @@ class FlavorChoiceField(ChoiceField): self.initial = kwargs["form"]["flavor"].value() else: # Search through selected flavors - for flavor_name, flavor_name in self.choices: + for flavor_id, flavor_name in self.choices: if 'medium' in flavor_name: - self.initial = flavor_name + self.initial = flavor_id break + def clean(self, value): + for flavor_id, flavor_name in self.choices: + if flavor_id == value: + return flavor_name + return value + class KeyPairChoiceField(DynamicChoiceField): """This widget allows to select keypair for VMs""" @with_request - def update(self, request, **kwargs): + def update(self, request, form=None, **kwargs): self.choices = [('', _('No keypair'))] - for keypair in sorted( - nova.novaclient(request).keypairs.list(), - key=lambda e: e.name): + with helpers.current_region(request, + getattr(form, 'region', None)): + keypairs = nova.novaclient(request).keypairs.list() + for keypair in sorted(keypairs, key=lambda e: e.name): self.choices.append((keypair.name, keypair.name)) @@ -450,9 +470,10 @@ class ImageChoiceField(ChoiceField): super(ImageChoiceField, self).__init__(*args, **kwargs) @with_request - def update(self, request, **kwargs): + def update(self, request, form=None, **kwargs): image_map, image_choices = {}, [] - murano_images = get_murano_images(request) + murano_images = get_murano_images( + request, getattr(form, 'region', None)) for image in murano_images: murano_data = image.murano_property title = murano_data.get('title', image.name) @@ -528,10 +549,12 @@ class NetworkChoiceField(ChoiceField): class AZoneChoiceField(ChoiceField): @with_request - def update(self, request, **kwargs): + def update(self, request, form=None, **kwargs): try: - availability_zones = nova.novaclient( - request).availability_zones.list(detailed=False) + with helpers.current_region(request, + getattr(form, 'region', None)): + availability_zones = nova.novaclient( + request).availability_zones.list(detailed=False) except Exception: availability_zones = [] exceptions.handle(request, diff --git a/muranodashboard/dynamic_ui/forms.py b/muranodashboard/dynamic_ui/forms.py index c98aab5cb..c986542c2 100644 --- a/muranodashboard/dynamic_ui/forms.py +++ b/muranodashboard/dynamic_ui/forms.py @@ -223,10 +223,11 @@ class ServiceConfigurationForm(UpdatableFieldsForm): field.compare(name, cleaned_data) if hasattr(field, 'postclean'): - value = field.postclean(self, cleaned_data) + value = field.postclean(self, name, cleaned_data) if value: cleaned_data[name] = value - LOG.debug("Update cleaned data in postclean method") + LOG.debug("Update '%s' data in postclean method" % + name) self.service.update_cleaned_data(cleaned_data, form=self) return cleaned_data diff --git a/muranodashboard/dynamic_ui/helpers.py b/muranodashboard/dynamic_ui/helpers.py index 404d17b2c..ca1cda9e5 100644 --- a/muranodashboard/dynamic_ui/helpers.py +++ b/muranodashboard/dynamic_ui/helpers.py @@ -12,6 +12,7 @@ # License for the specific language governing permissions and limitations # under the License. +import contextlib import re import string import types @@ -159,3 +160,14 @@ def to_str(text): elif isinstance(text, six.binary_type): text = text.decode('utf-8') return text + + +@contextlib.contextmanager +def current_region(request, region): + orig_region = request.user.services_region + if region is not None: + request.user.services_region = region + try: + yield + finally: + request.user.services_region = orig_region diff --git a/muranodashboard/dynamic_ui/services.py b/muranodashboard/dynamic_ui/services.py index 96739c650..9d107d7c9 100644 --- a/muranodashboard/dynamic_ui/services.py +++ b/muranodashboard/dynamic_ui/services.py @@ -87,11 +87,12 @@ class Service(object): setattr(self, key, value) for form in forms: - name, field_specs, validators = self.extract_form_data(form) + (name, field_specs, validators, + region) = self.extract_form_data(form) # NOTE(kzaitsev) should be str (not unicode) under python2 # however it also works as str under python3 name = helpers.to_str(name) - self._add_form(name, field_specs, validators) + self._add_form(name, field_specs, validators, region) # Add ManageWorkflowForm workflow_form = catalog_forms.WorkflowManagementForm() @@ -104,7 +105,8 @@ class Service(object): workflow_form.field_specs, workflow_form.validators) - def _add_form(self, _name, _specs, _validators, _verbose_name=None): + def _add_form(self, _name, _specs, _validators, _verbose_name=None, + _region=None): import muranodashboard.dynamic_ui.forms as forms class Form(six.with_metaclass(forms.DynamicFormMetaclass, @@ -114,14 +116,15 @@ class Service(object): verbose_name = _verbose_name field_specs = _specs validators = _validators + region = _region self.forms.append(Form) @staticmethod def extract_form_data(data): for form_name, form_data in six.iteritems(data): - return form_name, form_data['fields'], form_data.get('validators', - []) + return (form_name, form_data['fields'], + form_data.get('validators', []), form_data.get('region')) def extract_attributes(self): context = self.context.create_child_context() diff --git a/muranodashboard/templates/services/_wizard_create.html b/muranodashboard/templates/services/_wizard_create.html index 0973e05f5..41a8b1571 100644 --- a/muranodashboard/templates/services/_wizard_create.html +++ b/muranodashboard/templates/services/_wizard_create.html @@ -1,5 +1,5 @@ {% extends "horizon/common/_modal_form.html" %} -{% load i18n humanize %} +{% load i18n horizon humanize bootstrap %} {% block form_action %} {% url 'horizon:app-catalog:catalog:add' app_id environment_id do_redirect drop_wm_form %} {% endblock %} @@ -60,6 +60,63 @@

{% endfor %} + {% if usages %} + + {% endif %} {% endblock %} {% block modal-footer %} @@ -115,14 +172,14 @@ $button = elem.tagName == 'SELECT' && $elem.next().find('a'), bindHandler = function($el) { $el.blur(function() { - $descEntry.children('i').remove() - $descEntry.removeClass('selected-field') + $descEntry.children('i').remove(); + $descEntry.removeClass('selected-field'); }).focus(function() { // remove if previous form without submit - $descEntry.children('i').remove() - $descEntry.addClass('selected-field') + $descEntry.children('i').remove(); + $descEntry.addClass('selected-field'); $descEntry.prepend( - "") + ""); }) }; bindHandler($elem); @@ -131,6 +188,56 @@ bindHandler($button); } }).filter(':first').trigger('focus'); + + // Update flavor specs in a description area + var $flavorElem = $modal.find('.form-group select.flavor'); + if ($flavorElem.length) { + var name = $flavorElem.attr('name'), + $flavorTitle = $modal.find('strong[data-field-name*="'+name+'"]').closest('p'), + $flavorSpecs = $flavorTitle.find('.flavor-specs'); + + if ($flavorSpecs.length == 0) { + $flavorTitle.append('
'); + $flavorSpecs = $flavorTitle.find('.flavor-specs'); + } + var flavors = {{ flavors|safe|default:"{}" }}; + if (!$flavorSpecs.find('.progress').length && flavors.length) { + $flavorSpecs.append($('#quota_bars').html()); + horizon.Quota.initWithFlavors(flavors); + } + + // Update quota titles according to the selected flavor + var updateQuotaTitles = function() { + var appendVal = function(elem, value) { + var origTitle = elem.data('orig-title'); + if (!origTitle) { + elem.data('orig-title', origTitle = elem.text()); + } + elem.text(value + ' + ' + origTitle); + }; + var selFlavor = $.grep(flavors, function(flavor) { + return flavor.id === $flavorElem.val(); + })[0]; + $flavorSpecs.find('.quota_title span').each(function(idx) { + switch (idx) { + // instance count title case + case 0: + appendVal($(this), 1); + break; + // VCPU count title case + case 1: + appendVal($(this), selFlavor.vcpus); + break; + // RAM amount title case + case 2: + appendVal($(this), selFlavor.ram); + break; + } + }); + }; + updateQuotaTitles(); + $flavorElem.on('change', updateQuotaTitles); + } }); // show full name on text overflow $('.modal-dialog h3').each(function () { diff --git a/muranodashboard/tests/test_fields.py b/muranodashboard/tests/test_fields.py index c1b81424c..f7b98cba1 100644 --- a/muranodashboard/tests/test_fields.py +++ b/muranodashboard/tests/test_fields.py @@ -24,19 +24,20 @@ class TestFlavorField(helpers.APITestCase): super(TestFlavorField, self).setUp() class FlavorFlave(object): - def __init__(self, name, vcpus, disk, ram): + def __init__(self, id, name, vcpus, disk, ram): self.name = name self.vcpus = vcpus self.disk = disk self.ram = ram + self.id = id novaclient = self.stub_novaclient() novaclient.flavors = self.mox.CreateMockAnything() # Set up the Flavor list novaclient.flavors.list().MultipleTimes().AndReturn( - [FlavorFlave('small', vcpus=1, disk=50, ram=1000), - FlavorFlave('medium', vcpus=2, disk=100, ram=2000), - FlavorFlave('large', vcpus=3, disk=750, ram=4000)]) + [FlavorFlave('id1', 'small', vcpus=1, disk=50, ram=1000), + FlavorFlave('id2', 'medium', vcpus=2, disk=100, ram=2000), + FlavorFlave('id3', 'large', vcpus=3, disk=750, ram=4000)]) def test_no_filter(self): """Check that all flavors are returned.""" @@ -48,9 +49,9 @@ class TestFlavorField(helpers.APITestCase): initial_request = {} f.update(initial_request, self.request) self.assertEqual([ - ('large', 'large'), - ('medium', 'medium'), - ('small', 'small') + ('id3', 'large'), + ('id2', 'medium'), + ('id1', 'small') ], f.choices) def test_multiple_filter(self): @@ -60,9 +61,8 @@ class TestFlavorField(helpers.APITestCase): # Fake a requirement for 2 CPUs, should return medium and large f = fields.FlavorChoiceField(requirements={'min_vcpus': 2}) - initial_request = {} - f.update(initial_request, self.request) - self.assertEqual([('large', 'large'), ('medium', 'medium')], f.choices) + f.update({}, self.request) + self.assertEqual([('id3', 'large'), ('id2', 'medium')], f.choices) def test_single_filter(self): """Check that one flavor is returned.""" @@ -73,7 +73,7 @@ class TestFlavorField(helpers.APITestCase): requirements={'min_vcpus': 2, 'min_disk': 200}) initial_request = {} f.update(initial_request, self.request) - self.assertEqual([('large', 'large')], f.choices) + self.assertEqual([('id3', 'large')], f.choices) def test_no_matches_filter(self): """Check that no flavors are returned.""" diff --git a/muranodashboard/tests/unit/catalog/test_views.py b/muranodashboard/tests/unit/catalog/test_views.py index 2c4d14c78..17958838e 100644 --- a/muranodashboard/tests/unit/catalog/test_views.py +++ b/muranodashboard/tests/unit/catalog/test_views.py @@ -365,13 +365,31 @@ class TestWizard(testtools.TestCase): for key, val in expected.items(): self.assertEqual(val, result[key]) + @mock.patch.object( + views, 'nova', + mock.MagicMock(side_effect=views.nova_exceptions.ClientException)) + def test_get_flavors(self): + result = self.wizard.get_flavors() + + self.assertEqual('[]', result) + views.nova.flavor_list.assert_called_once_with(self.wizard.request) + + @mock.patch.object(views, 'nova') + @mock.patch.object(views, 'quotas') @mock.patch.object(views, 'services') @mock.patch.object(views, 'api') - def test_get_context_data(self, mock_api, mock_services): + def test_get_context_data(self, mock_api, mock_services, mock_quotas, + mock_nova): mock_api.muranoclient().environments.get().name = 'foo_env_name' mock_services.get_app_field_descriptions.return_value = [ 'foo_field_descr', 'foo_extended_descr' ] + mock_nova.flavor_list.return_value = [ + type('FakeFlavor%s' % k, (object, ), + {'id': 'fake_id_%s' % k, 'name': 'fake_name_%s' % k, + '_info': {'foo': 'bar'}}) + for k in (1, 2) + ] form = mock.Mock() app = mock.Mock(fully_qualified_name='foo_app_fqn') @@ -380,7 +398,7 @@ class TestWizard(testtools.TestCase): self.wizard.request.GET = {} self.wizard.request.POST = {} self.wizard.storage.extra_data.get.return_value = app - self.wizard.steps = mock.Mock(index='foo_step_index') + self.wizard.steps = mock.Mock(index='foo_step_index', step0=-1) self.wizard.prefix = 'foo_prefix' self.wizard.kwargs['do_redirect'] = 'foo_do_redirect' self.wizard.kwargs['drop_wm_form'] = 'foo_drop_wm_form' @@ -407,13 +425,17 @@ class TestWizard(testtools.TestCase): 'foo_env_id') mock_services.get_app_field_descriptions.assert_called_once_with( self.wizard.request, 'foo_app_id', 'foo_step_index') + mock_nova.flavor_list.assert_called_once_with(self.wizard.request) + @mock.patch.object(views, 'nova') + @mock.patch.object(views, 'quotas') @mock.patch.object(views, 'env_api') @mock.patch.object(views, 'utils') @mock.patch.object(views, 'services') @mock.patch.object(views, 'api') def test_get_context_data_alternate_control_flow( - self, mock_api, mock_services, mock_utils, mock_env_api): + self, mock_api, mock_services, mock_utils, mock_env_api, + mock_quatas, mock_nova): form = mock.Mock() app = mock.Mock(fully_qualified_name='foo_app_fqn') app.configure_mock(name='foo_app') @@ -425,11 +447,18 @@ class TestWizard(testtools.TestCase): ] mock_utils.ensure_python_obj.return_value = None mock_env_api.environments_list.return_value = [] + mock_nova.flavor_list.return_value = [ + type('FakeFlavor%s' % k, (object, ), + {'id': 'fake_id_%s' % k, 'name': 'fake_name_%s' % k, + '_info': {'foo': 'bar'}}) + for k in (1, 2) + ] self.wizard.request.GET = {} self.wizard.request.POST = {'wizard_id': 'foo_wizard_id'} self.wizard.storage.extra_data = {} - self.wizard.steps = mock.Mock(index='foo_step_index') + self.wizard.steps = mock.Mock(index='foo_step_index', step0=0) + self.wizard.steps.all = [] self.wizard.prefix = 'foo_prefix' context = self.wizard.get_context_data(form) @@ -456,6 +485,7 @@ class TestWizard(testtools.TestCase): mock_api.muranoclient().environments.get.assert_called_once_with() mock_services.get_app_field_descriptions.assert_called_once_with( self.wizard.request, 'foo_app_id', 'foo_step_index') + mock_nova.flavor_list.assert_called_once_with(self.wizard.request) class TestIndexView(testtools.TestCase): diff --git a/muranodashboard/tests/unit/dynamic_ui/test_fields.py b/muranodashboard/tests/unit/dynamic_ui/test_fields.py index e470fc48d..e2b978834 100644 --- a/muranodashboard/tests/unit/dynamic_ui/test_fields.py +++ b/muranodashboard/tests/unit/dynamic_ui/test_fields.py @@ -27,7 +27,9 @@ class TestFields(testtools.TestCase): def setUp(self): super(TestFields, self).setUp() - self.request = {'request': mock.Mock()} + self.request = mock.Mock() + self.request.user.service_region = None + self.request.is_ajax = mock.Mock(side_effect=False) self.addCleanup(mock.patch.stopall) @mock.patch.object(fields, 'LOG') @@ -220,7 +222,7 @@ class TestFields(testtools.TestCase): self.assertEqual('DynamicSelect', dynamic_select_cls.__name__) dynamic_select = dynamic_select_cls(empty_value_message='Foo') - dynamic_select.update(self.request, environment_id='foo_env_id') + dynamic_select.update({}, self.request, environment_id='foo_env_id') self.assertTrue( hasattr(dynamic_select.widget.add_item_link, '__call__')) @@ -228,9 +230,9 @@ class TestFields(testtools.TestCase): self.assertIsNone(dynamic_select.initial) mock_pkg_api.app_by_fqn.assert_called_once_with( - self.request['request'], 'foo_class_fqn') + self.request, 'foo_class_fqn') mock_env_api.service_list_by_fqns.assert_called_once_with( - self.request['request'], 'foo_env_id', + self.request, 'foo_env_id', ['foo_class_fqn', 'bar_class_fqn'] ) @@ -252,15 +254,15 @@ class TestFields(testtools.TestCase): dynamic_select_cls = fields.make_select_cls('foo_class_fqn') dynamic_select = dynamic_select_cls(empty_value_message='Foo') - dynamic_select.update(self.request, environment_id='foo_env_id') + dynamic_select.update({}, self.request, environment_id='foo_env_id') self.assertEqual(expected_choices, dynamic_select.choices) self.assertEqual('foo_app_id', dynamic_select.initial) mock_pkg_api.app_by_fqn.assert_called_once_with( - self.request['request'], 'foo_class_fqn') + self.request, 'foo_class_fqn') mock_env_api.service_list_by_fqns.assert_called_once_with( - self.request['request'], 'foo_env_id', ['foo_class_fqn'] + self.request, 'foo_env_id', ['foo_class_fqn'] ) @mock.patch.object(fields, 'env_api') @@ -274,15 +276,15 @@ class TestFields(testtools.TestCase): dynamic_select_cls = fields.make_select_cls('foo_class_fqn') dynamic_select = dynamic_select_cls(empty_value_message='Foo') - dynamic_select.update(self.request, environment_id='foo_env_id') + dynamic_select.update({}, self.request, environment_id='foo_env_id') self.assertEqual(expected_choices, dynamic_select.choices) self.assertIsNone(dynamic_select.initial) mock_pkg_api.app_by_fqn.assert_called_once_with( - self.request['request'], 'foo_class_fqn') + self.request, 'foo_class_fqn') mock_env_api.service_list_by_fqns.assert_called_once_with( - self.request['request'], 'foo_env_id', []) + self.request, 'foo_env_id', []) @mock.patch.object(fields, 'reverse') @mock.patch.object(fields, 'env_api') @@ -296,7 +298,7 @@ class TestFields(testtools.TestCase): dynamic_select_cls = fields.make_select_cls('foo_class_fqn') dynamic_select = dynamic_select_cls(empty_value_message='Foo') - dynamic_select.update(self.request, environment_id='foo_env_id') + dynamic_select.update({}, self.request, environment_id='foo_env_id') result = dynamic_select.widget.add_item_link() self.assertEqual('', result) @@ -304,7 +306,7 @@ class TestFields(testtools.TestCase): mock_pkg = mock.Mock(fully_qualified_name='foo_class_fqn') mock_pkg.configure_mock(name='foo_class_name') mock_pkg_api.app_by_fqn.return_value = mock_pkg - dynamic_select.update(self.request, environment_id='foo_env_id') + dynamic_select.update({}, self.request, environment_id='foo_env_id') result = dynamic_select.widget.add_item_link() expected = '[["foo_class_name", "foo_url"]]' @@ -492,11 +494,11 @@ class TestFlavorChoiceField(testtools.TestCase): self.request = {'request': mock.Mock()} self.tiny_flavor = mock.Mock() - self.tiny_flavor.configure_mock(name='m1.tiny') + self.tiny_flavor.configure_mock(id='id1', name='m1.tiny') self.small_flavor = mock.Mock() - self.small_flavor.configure_mock(name='m1.small') + self.small_flavor.configure_mock(id='id2', name='m1.small') self.medium_flavor = mock.Mock() - self.medium_flavor.configure_mock(name='m1.medium') + self.medium_flavor.configure_mock(id='id3', name='m1.medium') self.addCleanup(mock.patch.stopall) @@ -507,7 +509,7 @@ class TestFlavorChoiceField(testtools.TestCase): self.tiny_flavor, self.small_flavor, self.medium_flavor ] expected_choices = [ - ('m1.medium', 'm1.medium'), ('m1.small', 'm1.small') + ('id3', 'm1.medium'), ('id2', 'm1.small') ] valid_requirements = [ ('vcpus', 2), ('disk', 101), ('ram', 501) @@ -529,7 +531,7 @@ class TestFlavorChoiceField(testtools.TestCase): self.flavor_choice_field.update(self.request) self.assertEqual(expected_choices, self.flavor_choice_field.choices) - self.assertEqual('m1.medium', self.flavor_choice_field.initial) + self.assertEqual('id3', self.flavor_choice_field.initial) @mock.patch.object(fields, 'nova') def test_update_without_requirements(self, mock_nova): @@ -539,14 +541,14 @@ class TestFlavorChoiceField(testtools.TestCase): del self.flavor_choice_field.requirements expected_choices = [ - ('m1.medium', 'm1.medium'), - ('m1.small', 'm1.small'), - ('m1.tiny', 'm1.tiny') + ('id3', 'm1.medium'), + ('id2', 'm1.small'), + ('id1', 'm1.tiny') ] self.flavor_choice_field.update(self.request) self.assertEqual(expected_choices, self.flavor_choice_field.choices) - self.assertEqual('m1.medium', self.flavor_choice_field.initial) + self.assertEqual('id3', self.flavor_choice_field.initial) class TestKeyPairChoiceField(testtools.TestCase): diff --git a/muranodashboard/tests/unit/dynamic_ui/test_forms.py b/muranodashboard/tests/unit/dynamic_ui/test_forms.py index 4c56daecb..3001a28e2 100644 --- a/muranodashboard/tests/unit/dynamic_ui/test_forms.py +++ b/muranodashboard/tests/unit/dynamic_ui/test_forms.py @@ -181,10 +181,10 @@ class TestServiceConfigurationForm(testtools.TestCase): # below, rather than `{'foo': 'bar', 'baz': 'qux'}` because # `cleaned_data[name] = value` in clean() appears to also change the # dict that was passed in to mock objects in previous lines of code. - foo_field.postclean.assert_called_once_with(self.form, mock.ANY) + foo_field.postclean.assert_called_once_with(self.form, 'foo', mock.ANY) password_field.compare.assert_called_once_with('password', mock.ANY) mock_log.debug.assert_called_once_with( - "Update cleaned data in postclean method") + "Update 'foo' data in postclean method") self.form.service.update_cleaned_data.assert_called_with( mock.ANY, form=self.form) diff --git a/releasenotes/notes/show-resource-91a1f73cdb5d74ab.yaml b/releasenotes/notes/show-resource-91a1f73cdb5d74ab.yaml new file mode 100644 index 000000000..191b9a01f --- /dev/null +++ b/releasenotes/notes/show-resource-91a1f73cdb5d74ab.yaml @@ -0,0 +1,6 @@ +--- + +features: + - | + Show resource usages in the description section right under the Flavor field + title (as quota usages + predicted increment progress bar).