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 <tsufiev@gmail.com>
Co-Authored-By: Artem Tiumentcev <darland.maik@gmail.com>
Change-Id: I842cbce209ea90ab715d2e50824296a19c202a76
This commit is contained in:
Aleksey Nakoryakov 2017-07-19 10:21:43 +03:00 committed by zhurong
parent 9e93ef3dbb
commit 93d8a1e160
11 changed files with 375 additions and 68 deletions

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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 @@
</p>
{% endfor %}
</div>
{% if usages %}
<script type="text/html" id="quota_bars">
<div class="quota_title">
<strong class="pull-left">{% trans "Number of Instances" %}</strong>
<span class="pull-right">
{% blocktrans with used=usages.totalInstancesUsed|intcomma other_used=other_usages.totalInstancesUsed|intcomma quota=usages.maxTotalInstances|intcomma|quotainf %}
{{ used }} + {{ other_used }} of {{ quota }} used
{% endblocktrans %}
</span>
</div>
<div id="quota_instances"
class="quota_bar"
data-progress-indicator-flavor
data-quota-limit="{{ usages.maxTotalInstances }}"
data-quota-used="{{ usages.totalInstancesUsed }}">
{% widthratio usages.totalInstancesUsed usages.maxTotalInstances 100 as instance_percent %}
{% widthratio other_usages.totalInstancesUsed usages.maxTotalInstances 100 as instance_other_percent %}
{% bs_progress_bar instance_percent instance_other_percent 0 contexts=contexts %}
</div>
<div class="quota_title">
<strong class="pull-left">{% trans "Number of VCPUs" %}</strong>
<span class="pull-right">
{% blocktrans with used=usages.totalCoresUsed|intcomma other_used=other_usages.totalCoresUsed|intcomma quota=usages.maxTotalCores|intcomma|quotainf %}
{{ used }} + {{ other_used }} of {{ quota }} used
{% endblocktrans %}
</span>
</div>
<div id="quota_vcpus"
class="quota_bar"
data-progress-indicator-flavor
data-quota-limit="{{ usages.maxTotalCores }}"
data-quota-used="{{ usages.totalCoresUsed }}">
{% widthratio usages.totalCoresUsed usages.maxTotalCores 100 as vcpu_percent %}
{% widthratio other_usages.totalCoresUsed usages.maxTotalCores 100 as vcpu_other_percent %}
{% bs_progress_bar vcpu_percent vcpu_other_percent 0 contexts=contexts %}
</div>
<div class="quota_title">
<strong class="pull-left">{% trans "Total RAM" %}</strong>
<span class="pull-right">
{% blocktrans with used=usages.totalRAMUsed|intcomma other_used=other_usages.totalRAMUsed|intcomma quota=usages.maxTotalRAMSize|intcomma|quotainf %}
{{ used }} + {{ other_used }} of {{ quota }} MB used
{% endblocktrans %}
</span>
</div>
<div id="quota_ram"
class="quota_bar"
data-progress-indicator-flavor
data-quota-limit="{{ usages.maxTotalRAMSize }}"
data-quota-used="{{ usages.totalRAMUsed }}">
{% widthratio usages.totalRAMUsed usages.maxTotalRAMSize 100 as ram_percent %}
{% widthratio other_usages.totalRAMUsed usages.maxTotalRAMSize 100 as ram_other_percent %}
{% bs_progress_bar ram_percent ram_other_percent 0 contexts=contexts %}
</div>
</script>
{% 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 <i> if previous form without submit
$descEntry.children('i').remove()
$descEntry.addClass('selected-field')
$descEntry.children('i').remove();
$descEntry.addClass('selected-field');
$descEntry.prepend(
"<i class='fa fa-chevron-circle-right'></i>")
"<i class='fa fa-chevron-circle-right'></i>");
})
};
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('<div class="flavor-specs"></div>');
$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 () {

View File

@ -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."""

View File

@ -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):

View File

@ -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):

View File

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

View File

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