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).