+
+
{% endblock %}
{% block modal-footer %}
diff --git a/horizon/dashboards/nova/instances_and_volumes/volumes/forms.py b/horizon/dashboards/nova/instances_and_volumes/volumes/forms.py
index 33f8dd6c7..b35c18170 100644
--- a/horizon/dashboards/nova/instances_and_volumes/volumes/forms.py
+++ b/horizon/dashboards/nova/instances_and_volumes/volumes/forms.py
@@ -9,6 +9,7 @@ Views for managing Nova volumes.
from django import shortcuts
from django.contrib import messages
+from django.forms import ValidationError
from django.utils.translation import ugettext_lazy as _
from horizon import api
@@ -26,13 +27,38 @@ class CreateForm(forms.SelfHandlingForm):
def handle(self, request, data):
try:
+ # FIXME(johnp): Nova (cinderclient) currently returns a useless
+ # error message when the quota is exceeded when trying to create
+ # a volume, so we need to check for that scenario here before we
+ # send it off to Nova to try and create.
+ usages = api.tenant_quota_usages(request)
+
+ if type(data['size']) is str:
+ data['size'] = int(data['size'])
+
+ if usages['gigabytes']['available'] < data['size']:
+ error_message = _('A volume of %iGB cannot be created as you'
+ ' only have %iGB of your quota available.'
+ % (data['size'],
+ usages['gigabytes']['available'],))
+ raise ValidationError(error_message)
+ elif usages['volumes']['available'] <= 0:
+ error_message = _('You are already using all of your available'
+ ' volumes.')
+ raise ValidationError(error_message)
+
api.volume_create(request, data['size'], data['name'],
data['description'])
message = 'Creating volume "%s"' % data['name']
+
messages.info(request, message)
+ except ValidationError, e:
+ return self.api_error(e.messages[0])
except:
- exceptions.handle(request,
- _("Unable to create volume."))
+ exceptions.handle(request, ignore=True)
+
+ return self.api_error(_("Unable to create volume."))
+
return shortcuts.redirect("horizon:nova:instances_and_volumes:index")
diff --git a/horizon/dashboards/nova/instances_and_volumes/volumes/tests.py b/horizon/dashboards/nova/instances_and_volumes/volumes/tests.py
index 88f59431b..bfdef759e 100644
--- a/horizon/dashboards/nova/instances_and_volumes/volumes/tests.py
+++ b/horizon/dashboards/nova/instances_and_volumes/volumes/tests.py
@@ -27,6 +27,69 @@ from horizon import test
class VolumeViewTests(test.TestCase):
+ @test.create_stubs({api: ('tenant_quota_usages', 'volume_create',)})
+ def test_create_volume(self):
+ usage = {'gigabytes': {'available': 250}, 'volumes': {'available': 6}}
+ formData = {'name': u'A Volume I Am Making',
+ 'description': u'This is a volume I am making for a test.',
+ 'method': u'CreateForm',
+ 'size': 50}
+
+ api.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage)
+ api.volume_create(IsA(http.HttpRequest),
+ formData['size'],
+ formData['name'],
+ formData['description'])
+
+ self.mox.ReplayAll()
+
+ url = reverse('horizon:nova:instances_and_volumes:volumes:create')
+ res = self.client.post(url, formData)
+
+ redirect_url = reverse('horizon:nova:instances_and_volumes:index')
+ self.assertRedirectsNoFollow(res, redirect_url)
+
+ @test.create_stubs({api: ('tenant_quota_usages',)})
+ def test_create_volume_gb_used_over_alloted_quota(self):
+ usage = {'gigabytes': {'available': 100, 'used': 20}}
+ formData = {'name': u'This Volume Is Huge!',
+ 'description': u'This is a volume that is just too big!',
+ 'method': u'CreateForm',
+ 'size': 5000}
+
+ api.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage)
+ api.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage)
+
+ self.mox.ReplayAll()
+
+ url = reverse('horizon:nova:instances_and_volumes:volumes:create')
+ res = self.client.post(url, formData)
+
+ expected_error = [u'A volume of 5000GB cannot be created as you only'
+ ' have 100GB of your quota available.']
+ self.assertEqual(res.context['form'].errors['__all__'], expected_error)
+
+ @test.create_stubs({api: ('tenant_quota_usages',)})
+ def test_create_volume_number_over_alloted_quota(self):
+ usage = {'gigabytes': {'available': 100, 'used': 20},
+ 'volumes': {'available': 0}}
+ formData = {'name': u'Too Many...',
+ 'description': u'We have no volumes left!',
+ 'method': u'CreateForm',
+ 'size': 10}
+
+ api.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage)
+ api.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage)
+
+ self.mox.ReplayAll()
+
+ url = reverse('horizon:nova:instances_and_volumes:volumes:create')
+ res = self.client.post(url, formData)
+
+ expected_error = [u'You are already using all of your available'
+ ' volumes.']
+ self.assertEqual(res.context['form'].errors['__all__'], expected_error)
+
@test.create_stubs({api: ('volume_get',), api.nova: ('server_list',)})
def test_edit_attachments(self):
volume = self.volumes.first()
diff --git a/horizon/dashboards/nova/instances_and_volumes/volumes/views.py b/horizon/dashboards/nova/instances_and_volumes/volumes/views.py
index 03b74624b..63eb6de28 100644
--- a/horizon/dashboards/nova/instances_and_volumes/volumes/views.py
+++ b/horizon/dashboards/nova/instances_and_volumes/volumes/views.py
@@ -44,6 +44,15 @@ class CreateView(forms.ModalFormView):
form_class = CreateForm
template_name = 'nova/instances_and_volumes/volumes/create.html'
+ def get_context_data(self, **kwargs):
+ context = super(CreateView, self).get_context_data(**kwargs)
+ try:
+ context['usages'] = api.tenant_quota_usages(self.request)
+ except:
+ exceptions.handle(self.request)
+
+ return context
+
class CreateSnapshotView(forms.ModalFormView):
form_class = CreateSnapshotForm
diff --git a/horizon/exceptions.py b/horizon/exceptions.py
index 0c3a8c6fa..707b4fe62 100644
--- a/horizon/exceptions.py
+++ b/horizon/exceptions.py
@@ -57,7 +57,8 @@ class HorizonReporterFilter(SafeExceptionReporterFilter):
sensitive_variables = None
while current_frame is not None:
if (current_frame.f_code.co_name == 'sensitive_variables_wrapper'
- and 'sensitive_variables_wrapper' in current_frame.f_locals):
+ and 'sensitive_variables_wrapper'
+ in current_frame.f_locals):
# The sensitive_variables decorator was used, so we take note
# of the sensitive variables' names.
wrapper = current_frame.f_locals['sensitive_variables_wrapper']
diff --git a/horizon/static/horizon/js/horizon.quota.js b/horizon/static/horizon/js/horizon.quota.js
new file mode 100644
index 000000000..7dc9a27bd
--- /dev/null
+++ b/horizon/static/horizon/js/horizon.quota.js
@@ -0,0 +1,244 @@
+/*
+ Used for animating and displaying quota information on forms which use the
+ Bootstrap progress bars. Also used for displaying flavor details on modal-
+ dialogs.
+
+ Usage:
+ In order to have progress bars that work with this, you need to have a
+ DOM structure like this in your Django template:
+
+
+
+ With this progress bar, you then need to add some data- HTML attributes
+ to the div #your_progress_bar_id. The available data- attributes are:
+
+ data-quota-used="integer" REQUIRED
+ Integer representing the total number used by the user.
+
+ data-quota-limit="integer" REQUIRED
+ Integer representing the total quota limit the user has. Note this IS
+ NOT the amount remaining they can use, but the total original quota.
+
+ ONE OF THE THREE ATTRIBUTES BELOW IS REQUIRED:
+
+ data-progress-indicator-step-by="integer" OPTIONAL
+ Indicates the numeric unit the quota JavaScript should automatically
+ animate this progress bar by on load. Can be used with the other
+ data- attributes.
+
+ A good use-case here is when you have a modal dialog to create ONE
+ volume, and you have a progress bar for volumes, but there are no
+ form elements that represent that number (as it is not settable by
+ the user.)
+
+ data-progress-indicator-for="html_id_of_form_input"
+ Tells the quota JavaScript which form element on this page is tied to
+ this progress indicator. If this form element is an input, it will
+ automatically fire on "keyup" in that form field, and change this
+ progress bar to denote the numeric change.
+
+ data-progress-indicator-flavor
+ This attribute is used to tell this quota JavaScript that this
+ progress bar is controller by an instance flavor select form element.
+ This attribute takes no value, but is used and configured
+ automatically by this script to update when a new flavor is choosen
+ by the end-user.
+ */
+horizon.Quota = {
+ is_flavor_quota: false, // Is this a flavor-based quota display?
+ user_value_progress_bars: [], // Progress bars triggered by user-changeable form elements.
+ auto_value_progress_bars: [], // Progress bars that should be automatically changed.
+ flavor_progress_bars: [], // Progress bars that relate to flavor details.
+ user_value_form_inputs: [], // The actual form inputs that trigger progress changes.
+ selected_flavor: null, // The flavor object of the current selected flavor on the form.
+ flavors: [], // The flavor objects the form represents, passed to us in initWithFlavors.
+
+ /*
+ Determines the progress bars and form elements to be used for quota
+ display. Also attaches handlers to the form elements as well as performing
+ the animations when the progress bars first load.
+ */
+ init: function() {
+ this.user_value_progress_bars = $('div[data-progress-indicator-for]');
+ this.auto_value_progress_bars = $('div[data-progress-indicator-step-by]');
+ this.user_value_form_inputs = $($.map(this.user_value_progress_bars, function(elm) {
+ return ('#' + $(elm).attr('data-progress-indicator-for'));
+ }));
+
+ this._initialAnimations();
+ this._attachInputHandlers();
+ },
+
+ /*
+ Sets up the quota to be used with flavor form selectors, which requires
+ some different handling of the forms. Also calls init() so that all of the
+ other animations and handlers are taken care of as well when initializing
+ with this method.
+ */
+ initWithFlavors: function(flavors) {
+ this.is_flavor_quota = true;
+ this.flavor_progress_bars = $('div[data-progress-indicator-flavor]');
+ this.flavors = flavors;
+
+ this.init();
+
+ this.showFlavorDetails();
+ this.updateFlavorUsage();
+ },
+
+ // Returns the flavor object for the selected flavor in the form.
+ getSelectedFlavor: function() {
+ if(this.is_flavor_quota) {
+ this.selected_flavor = _.find(this.flavors, function(flavor) {
+ return flavor.id == $("#id_flavor").children(":selected").val();
+ });
+ } else {
+ this.selected_flavor = null;
+ }
+
+ return this.selected_flavor;
+ },
+
+ /*
+ Populates the flavor details table with the flavor attributes of the
+ selected flavor on the form select element.
+ */
+ showFlavorDetails: function() {
+ this.getSelectedFlavor();
+
+ var name = horizon.utils.truncate(this.selected_flavor.name, 14, true);
+ var vcpus = horizon.utils.humanizeNumbers(this.selected_flavor.vcpus);
+ var disk = horizon.utils.humanizeNumbers(this.selected_flavor.disk);
+ var ephemeral = horizon.utils.humanizeNumbers(this.selected_flavor["OS-FLV-EXT-DATA:ephemeral"]);
+ var disk_total = this.selected_flavor.disk + this.selected_flavor["OS-FLV-EXT-DATA:ephemeral"];
+ var disk_total_display = horizon.utils.humanizeNumbers(disk_total);
+ var ram = horizon.utils.humanizeNumbers(this.selected_flavor.ram);
+
+ $("#flavor_name").html(name);
+ $("#flavor_vcpus").text(vcpus);
+ $("#flavor_disk").text(disk);
+ $("#flavor_ephemeral").text(ephemeral);
+ $("#flavor_disk_total").text(disk_total_display);
+ $("#flavor_ram").text(ram);
+ },
+
+ // Updates a progress bar, taking care of exceeding quota display as well.
+ update: function(element, percentage_used, percentage_to_update) {
+ var update_width = percentage_to_update;
+
+ if(percentage_to_update + percentage_used > 100) {
+ update_width = 100 - percentage_used;
+
+ if(!element.hasClass('progress_bar_over')) {
+ element.addClass('progress_bar_over');
+ }
+ } else {
+ element.removeClass('progress_bar_over');
+ }
+
+ element.animate({width: update_width + "%"}, 300);
+ },
+
+ /*
+ When a new flavor is selected, this takes care of updating the relevant
+ progress bars associated with the flavor quota usage.
+ */
+ updateFlavorUsage: function() {
+ if(!this.is_flavor_quota) return;
+
+ var scope = this;
+ var instance_count = (parseInt($("#id_count").val(), 10) || 1);
+ var update_amount = 0;
+
+ this.getSelectedFlavor();
+
+ $(this.flavor_progress_bars).each(function(index, element) {
+ var element_id = $(element).attr('id');
+ var progress_stat = element_id.match(/^quota_(.+)/)[1];
+
+ if(progress_stat === undefined) {
+ return;
+ } else if(progress_stat === 'instances') {
+ update_amount = instance_count;
+ } else {
+ update_amount = (scope.selected_flavor[progress_stat] * instance_count);
+ }
+
+ scope.updateUsageFor(element, update_amount);
+ });
+ },
+
+ // Does the math to calculate what percentage to update a progress bar by.
+ updateUsageFor: function(progress_element, increment_by) {
+ progress_element = $(progress_element);
+
+ var update_indicator = progress_element.find('.progress_bar_selected');
+ var quota_limit = parseInt(progress_element.attr('data-quota-limit'), 10);
+ var quota_used = parseInt(progress_element.attr('data-quota-used'), 10);
+ var percentage_to_update = ((increment_by / quota_limit) * 100);
+ var percentage_used = ((quota_used / quota_limit) * 100);
+
+ this.update(update_indicator, percentage_used, percentage_to_update);
+ },
+
+ /*
+ Attaches event handlers for the form elements associated with the
+ progress bars.
+ */
+ _attachInputHandlers: function() {
+ var scope = this;
+
+ if(this.is_flavor_quota) {
+ var eventCallback = function(evt) {
+ scope.showFlavorDetails();
+ scope.updateFlavorUsage();
+ };
+
+ $('#id_flavor').on('change', eventCallback);
+ $('#id_count').on('keyup', eventCallback);
+ }
+
+ $(this.user_value_form_inputs).each(function(index, element) {
+ $(element).on('keyup', function(evt) {
+ var progress_element = $('div[data-progress-indicator-for=' + $(evt.target).attr('id') + ']');
+ var integers_in_input = $(evt.target).val().match(/\d+/g);
+ var user_integer;
+
+ if(integers_in_input === null) {
+ user_integer = 0;
+ } else if(integers_in_input.length > 1) {
+ /*
+ Join all the numbers together that have been typed in. This takes
+ care of junk input like "dd8d72n3k" and uses just the digits in
+ that input, resulting in "8723".
+ */
+ user_integer = integers_in_input.join('');
+ } else if(integers_in_input.length == 1) {
+ user_integer = integers_in_input[0];
+ }
+
+ var progress_amount = parseInt(user_integer, 10);
+
+ scope.updateUsageFor(progress_element, progress_amount);
+ });
+ });
+ },
+
+ /*
+ Animate the progress bars of elements which indicate they should
+ automatically be incremented, as opposed to elements which trigger
+ progress updates based on form element input or changes.
+ */
+ _initialAnimations: function() {
+ var scope = this;
+
+ $(this.auto_value_progress_bars).each(function(index, element) {
+ var auto_progress = $(element);
+ var update_amount = parseInt(auto_progress.attr('data-progress-indicator-step-by'), 10);
+
+ scope.updateUsageFor(auto_progress, update_amount);
+ });
+ }
+};
diff --git a/horizon/static/horizon/js/horizon.quotas.js b/horizon/static/horizon/js/horizon.quotas.js
deleted file mode 100644
index 49b8b87e2..000000000
--- a/horizon/static/horizon/js/horizon.quotas.js
+++ /dev/null
@@ -1,69 +0,0 @@
-/* Update quota usage infographics when a flavor is selected to show the usage
- * that will be consumed by the selected flavor. */
-horizon.updateQuotaUsages = function(flavors, usages) {
- var selectedFlavor = _.find(flavors, function(flavor) {
- return flavor.id == $("#id_flavor").children(":selected").val();
- });
-
- var selectedCount = parseInt($("#id_count").val(), 10);
- if(isNaN(selectedCount)) {
- selectedCount = 1;
- }
-
- // Map usage data fields to their corresponding html elements
- var flavorUsageMapping = [
- {'usage': 'instances', 'element': 'quota_instances'},
- {'usage': 'cores', 'element': 'quota_cores'},
- {'usage': 'gigabytes', 'element': 'quota_disk'},
- {'usage': 'ram', 'element': 'quota_ram'}
- ];
-
- var el, used, usage, width;
- _.each(flavorUsageMapping, function(mapping) {
- el = $('#' + mapping.element + " .progress_bar_selected");
- used = 0;
- usage = usages[mapping.usage];
-
- if(mapping.usage == "instances") {
- used = selectedCount;
- } else {
- _.each(usage.flavor_fields, function(flavorField) {
- used += (selectedFlavor[flavorField] * selectedCount);
- });
- }
-
- available = 100 - $('#' + mapping.element + " .progress_bar_fill").attr("data-width");
- if(used + usage.used <= usage.quota) {
- width = Math.round((used / usage.quota) * 100);
- el.removeClass('progress_bar_over');
- } else {
- width = available;
- if(!el.hasClass('progress_bar_over')) {
- el.addClass('progress_bar_over');
- }
- }
-
- el.animate({width: width + "%"}, 300);
- });
-
- // Also update flavor details
- $("#flavor_name").html(horizon.utils.truncate(selectedFlavor.name, 14, true));
- $("#flavor_vcpus").text(selectedFlavor.vcpus);
- $("#flavor_disk").text(selectedFlavor.disk);
- $("#flavor_ephemeral").text(selectedFlavor["OS-FLV-EXT-DATA:ephemeral"]);
- $("#flavor_disk_total").text(selectedFlavor.disk + selectedFlavor["OS-FLV-EXT-DATA:ephemeral"]);
- $("#flavor_ram").text(selectedFlavor.ram);
-};
-
-horizon.addInitFunction(function () {
- var quota_containers = $(".quota-dynamic");
- if (quota_containers.length) {
- horizon.updateQuotaUsages(horizon_flavors, horizon_usages);
- }
- $(document).on("change", "#id_flavor", function() {
- horizon.updateQuotaUsages(horizon_flavors, horizon_usages);
- });
- $(document).on("keyup", "#id_count", function() {
- horizon.updateQuotaUsages(horizon_flavors, horizon_usages);
- });
-});
diff --git a/horizon/static/horizon/js/horizon.utils.js b/horizon/static/horizon/js/horizon.utils.js
index 376549b51..bc361167a 100644
--- a/horizon/static/horizon/js/horizon.utils.js
+++ b/horizon/static/horizon/js/horizon.utils.js
@@ -3,14 +3,33 @@ horizon.utils = {
capitalize: function(string) {
return string.charAt(0).toUpperCase() + string.slice(1);
},
- // Truncate a string at the desired length
+
+ /*
+ Adds commas to any integer or numbers within a string for human display.
+
+ EG:
+ horizon.utils.humanizeNumbers(1234); -> "1,234"
+ horizon.utils.humanizeNumbers("My Total: 1234"); -> "My Total: 1,234"
+ */
+ humanizeNumbers: function(number) {
+ return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
+ },
+
+ /*
+ Truncate a string at the desired length. Optionally append an ellipsis
+ to the end of the string.
+
+ EG:
+ horizon.utils.truncate("String that is too long.", 18, true); ->
+ "String that is too…"
+ */
truncate: function(string, size, includeEllipsis) {
- var ellip = "";
- if(includeEllipsis) {
- ellip = "…";
- }
if(string.length > size) {
- return string.substring(0, size) + ellip;
+ if(includeEllipsis) {
+ return string.substring(0, (size - 3)) + "…";
+ } else {
+ return string.substring(0, size);
+ }
} else {
return string;
}
diff --git a/horizon/templates/horizon/_nav_list.html b/horizon/templates/horizon/_nav_list.html
index b7f57e279..f300be27a 100644
--- a/horizon/templates/horizon/_nav_list.html
+++ b/horizon/templates/horizon/_nav_list.html
@@ -3,7 +3,7 @@
{% for component in components %}
- {% if user|can_haz:component %}
+ {% if user|has_permissions:component %}