Volume Progress Bar & Fixes For Quota

When a volume creation exceed the allocation quota
a vague error message was returned that offered the
user no guidance as to what went wrong. This has
been fixed.

Fixes Bug #1012883

This change also abstracts the Quota javascript to
allow it to be used anywhere on the site for any
progress bars that are to be shown in the future.

Implements blueprint progress-bar-javascript

On top of this I have renamed all "can_haz" filters
in the code, as they are really sort of embarassing.

Lastly, I have added the ability to append JS
events to the window load when a modal is loaded as
a static page, OR when it is loaded as an AJAX
modal.

Change-Id: I4b0cefa160cafbbd07d4b0981f62febaed051871
This commit is contained in:
John Postlethwait 2012-06-23 13:58:35 -07:00
parent 0ffa674697
commit 406cb5d56c
24 changed files with 521 additions and 197 deletions

View File

@ -407,18 +407,19 @@ def usage_list(request, start, end):
def tenant_quota_usages(request):
"""
Builds a dictionary of current usage against quota for the current
tenant.
project.
"""
# TODO(tres): Make this capture floating_ips and volumes as well.
instances = server_list(request)
floating_ips = tenant_floating_ip_list(request)
quotas = tenant_quota_get(request, request.user.tenant_id)
flavors = dict([(f.id, f) for f in flavor_list(request)])
volumes = volume_list(request)
usages = {'instances': {'flavor_fields': [], 'used': len(instances)},
'cores': {'flavor_fields': ['vcpus'], 'used': 0},
'gigabytes': {'used': 0,
'flavor_fields': ['disk',
'OS-FLV-EXT-DATA:ephemeral']},
'gigabytes': {'used': sum([int(v.size) for v in volumes]),
'flavor_fields': []},
'volumes': {'used': len(volumes), 'flavor_fields': []},
'ram': {'flavor_fields': ['ram'], 'used': 0},
'floating_ips': {'flavor_fields': [], 'used': len(floating_ips)}}
@ -427,11 +428,18 @@ def tenant_quota_usages(request):
for flavor_field in usages[usage]['flavor_fields']:
usages[usage]['used'] += getattr(
flavors[instance.flavor['id']], flavor_field, 0)
usages[usage]['quota'] = getattr(quotas, usage)
if usages[usage]['quota'] is None:
usages[usage]['quota'] = float("inf")
usages[usage]['available'] = float("inf")
elif type(usages[usage]['quota']) is str:
usages[usage]['quota'] = int(usages[usage]['quota'])
else:
if type(usages[usage]['used']) is str:
usages[usage]['used'] = int(usages[usage]['used'])
usages[usage]['available'] = usages[usage]['quota'] - \
usages[usage]['used']

View File

@ -9,5 +9,3 @@
{% block dash_main %}
{% include 'nova/images_and_snapshots/snapshots/_create.html' %}
{% endblock %}

View File

@ -1,4 +1,4 @@
{% load i18n horizon %}
{% load i18n horizon humanize %}
<p>{% blocktrans %}Specify the details for launching an instance.{% endblocktrans %}</p>
<p>{% blocktrans %}The chart below shows the resources used by this project in relation to the project's quotas.{% endblocktrans %}</p>
@ -17,55 +17,37 @@
<div class="quota-dynamic">
<h4>{% trans "Project Quotas" %}</h4>
<div class="quota_title">
<strong>{% trans "Instance Count" %} <span>({{ usages.instances.used }})</span></strong>
<p>{{ usages.instances.available|quota }}</p>
<div class="quota_title clearfix">
<strong>{% trans "Number of Instances" %} <span>({{ usages.instances.used|intcomma }})</span></strong>
<p>{{ usages.instances.available|quota|intcomma }}</p>
</div>
<div id="quota_instances" class="quota_bar" data-progress-indicator-flavor data-quota-limit="{{ usages.instances.quota }}" data-quota-used="{{ usages.instances.used }}">
{% horizon_progress_bar usages.instances.used usages.instances.quota %}
</div>
<div class="clearfix"></div>
<div id="quota_instances" class="quota_bar">{% horizon_progress_bar usages.instances.used usages.instances.quota %}</div>
<div class="quota_title">
<strong>{% trans "VCPUs" %} <span>({{ usages.cores.used }})</span></strong>
<p>{{ usages.cores.available|quota }}</p>
<div class="quota_title clearfix">
<strong>{% trans "Number of VCPUs" %} <span>({{ usages.cores.used|intcomma }})</span></strong>
<p>{{ usages.cores.available|quota|intcomma }}</p>
</div>
<div id="quota_vcpus" class="quota_bar" data-progress-indicator-flavor data-quota-limit="{{ usages.cores.quota }}" data-quota-used="{{ usages.cores.used }}">
{% horizon_progress_bar usages.cores.used usages.cores.quota %}
</div>
<div class="clearfix"></div>
<div id="quota_cores" class="quota_bar">{% horizon_progress_bar usages.cores.used usages.cores.quota %}</div>
<div class="quota_title">
<strong>{% trans "Disk" %} <span>({{ usages.gigabytes.used }} {% trans "GB" %})</span></strong>
<p>{{ usages.gigabytes.available|quota:"GB" }}</p>
<div class="quota_title clearfix">
<strong>{% trans "Total Memory" %} <span>({{ usages.ram.used|intcomma }} {% trans "MB" %})</span></strong>
<p>{{ usages.ram.available|quota:"MB"|intcomma }}</p>
</div>
<div class="clearfix"></div>
<div id="quota_disk" class="quota_bar">{% horizon_progress_bar usages.gigabytes.used usages.gigabytes.quota %}</div>
<div class="quota_title">
<strong>{% trans "Memory" %} <span>({{ usages.ram.used }} {% trans "MB" %})</span></strong>
<p>{{ usages.ram.available|quota:"MB" }}</p>
<div id="quota_ram" data-progress-indicator-flavor data-quota-limit="{{ usages.ram.quota }}" data-quota-used="{{ usages.ram.used }}" class="quota_bar">
{% horizon_progress_bar usages.ram.used usages.ram.quota %}
</div>
<div class="clearfix"></div>
<div id="quota_ram" class="quota_bar">{% horizon_progress_bar usages.ram.used usages.ram.quota %}</div>
</div>
<script type="text/javascript" charset="utf-8">
var horizon_flavors = {{ flavors|safe }};
var horizon_usages = {{ usages_json|safe }};
// FIXME(gabriel): move this function into a horizon primitive when we have
// one constructed at the head of the document. :-/
(function () {
function fire_change(el) {
if ("fireEvent" in el) {
el.fireEvent("onchange");
}
else
{
var evt = document.createEvent("HTMLEvents");
evt.initEvent("change", true, true);
el.dispatchEvent(evt);
}
}
fire_change(document.getElementById('id_flavor'));
fire_change(document.getElementById('id_source_type'));
fire_change(document.getElementById('id_volume_type'));
})();
if(typeof horizon !== 'undefined') {
horizon.Quota.initWithFlavors({{ flavors|safe }});
} else {
addHorizonLoadEvent(function() {
horizon.Quota.initWithFlavors({{ flavors|safe }});
});
}
</script>

View File

@ -1,22 +1,3 @@
{% load i18n horizon %}
<p>{% blocktrans %}An instance can be launched with varying types of attached storage. You may select from those options here.{% endblocktrans %}</p>
<script type="text/javascript" charset="utf-8">
// FIXME(gabriel): move this function into a horizon primitive when we have
// one constructed at the head of the document. :-/
(function () {
function fire_change(el) {
if ("fireEvent" in el) {
el.fireEvent("onchange");
}
else
{
var evt = document.createEvent("HTMLEvents");
evt.initEvent("change", true, true);
el.dispatchEvent(evt);
}
}
fire_change(document.getElementById('id_volume_type'));
})();
</script>

View File

@ -1,5 +1,5 @@
{% extends "horizon/common/_modal_form.html" %}
{% load i18n %}
{% load i18n horizon humanize %}
{% block form_id %}{% endblock %}
{% block form_action %}{% url horizon:nova:instances_and_volumes:volumes:create %}{% endblock %}
@ -8,15 +8,47 @@
{% block modal-header %}{% trans "Create Volume" %}{% endblock %}
{% block modal-body %}
<div class="left">
<div class="left">
<fieldset>
{% include "horizon/common/_form_fields.html" %}
{% include "horizon/common/_form_fields.html" %}
</fieldset>
</div>
<div class="right">
</div>
<div class="right quota-dynamic">
<h3>{% trans "Description" %}:</h3>
<p>{% trans "Volumes are block devices that can be attached to instances." %}</p>
</div>
<h3>{% trans "Volume Quotas" %}</h3>
<div class="quota_title clearfix">
<strong>{% trans "Total Gigabytes" %} <span>({{ usages.gigabytes.used|intcomma }} GB)</span></strong>
<p>{{ usages.gigabytes.available|quota:"GB"|intcomma }}</p>
</div>
<div id="quota_size" data-progress-indicator-for="id_size" data-quota-limit="{{ usages.gigabytes.quota }}" data-quota-used="{{ usages.gigabytes.used }}" class="quota_bar">
{% horizon_progress_bar usages.gigabytes.used usages.gigabytes.quota %}
</div>
<div class="quota_title clearfix">
<strong>{% trans "Number of Volumes" %} <span>({{ usages.volumes.used|intcomma }})</span></strong>
<p>{{ usages.volumes.available|quota|intcomma }}</p>
</div>
<div id="quota_volumes" data-progress-indicator-step-by="1" data-quota-limit="{{ usages.volumes.quota }}" data-quota-used="{{ usages.volumes.used }}" class="quota_bar">
{% horizon_progress_bar usages.volumes.used usages.volumes.quota %}
</div>
</div>
<script type="text/javascript" charset="utf-8">
if(typeof horizon !== 'undefined') {
horizon.Quota.init();
} else {
addHorizonLoadEvent(function() {
horizon.Quota.init();
});
}
</script>
{% endblock %}
{% block modal-footer %}

View File

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

View File

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

View File

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

View File

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

View File

@ -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:
<div id="your_progress_bar_id" class="quota_bar">
{% horizon_progress_bar total_number_used max_number_allowed %}
</div>
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);
});
}
};

View File

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

View File

@ -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&hellip;"
*/
truncate: function(string, size, includeEllipsis) {
var ellip = "";
if(includeEllipsis) {
ellip = "&hellip;";
}
if(string.length > size) {
return string.substring(0, size) + ellip;
if(includeEllipsis) {
return string.substring(0, (size - 3)) + "&hellip;";
} else {
return string.substring(0, size);
}
} else {
return string;
}

View File

@ -3,7 +3,7 @@
<div class='clearfix'>
<ul class="nav nav-tabs">
{% for component in components %}
{% if user|can_haz:component %}
{% if user|has_permissions:component %}
<li{% if current.slug == component.slug %} class="active"{% endif %}>
<a href="{{ component.get_absolute_url }}" tabindex='1'>{{ component.name }}</a>
</li>

View File

@ -25,7 +25,7 @@
<script src='{{ STATIC_URL }}horizon/js/horizon.instances.js' type='text/javascript' charset='utf-8'></script>
<script src='{{ STATIC_URL }}horizon/js/horizon.messages.js' type='text/javascript' charset='utf-8'></script>
<script src='{{ STATIC_URL }}horizon/js/horizon.modals.js' type='text/javascript' charset='utf-8'></script>
<script src='{{ STATIC_URL }}horizon/js/horizon.quotas.js' type='text/javascript' charset='utf-8'></script>
<script src='{{ STATIC_URL }}horizon/js/horizon.quota.js' type='text/javascript' charset='utf-8'></script>
<script src='{{ STATIC_URL }}horizon/js/horizon.tables.js' type='text/javascript' charset='utf-8'></script>
<script src='{{ STATIC_URL }}horizon/js/horizon.tabs.js' type='text/javascript' charset='utf-8'></script>
<script src='{{ STATIC_URL }}horizon/js/horizon.templates.js' type='text/javascript' charset='utf-8'></script>

View File

@ -1,7 +1,7 @@
{% load horizon %}
{% for heading, panels in components.iteritems %}
{% with panels|can_haz_list:user as filtered_panels %}
{% with panels|has_permissions_on_list:user as filtered_panels %}
{% if filtered_panels %}
{% if heading %}<h4>{{ heading }}</h4>{% endif %}
<ul class="main_nav">

View File

@ -0,0 +1,20 @@
<script type="text/javascript" charset="utf-8">
/*
Added so that we can append Horizon scoped JS events to
the DOM load events without running in to the "horizon"
name-space not currently being defined since we load the
scripts at the bottom of the page.
*/
var addHorizonLoadEvent = function(func) {
var old_onload = window.onload;
if (typeof window.onload != 'function') {
window.onload = func;
} else {
window.onload = function() {
old_onload();
func();
}
}
}
</script>

View File

@ -27,7 +27,7 @@ register = template.Library()
@register.filter
def can_haz(user, component):
def has_permissions(user, component):
"""
Checks if the given user meets the requirements for the component. This
includes both user roles and services in the service catalog.
@ -50,8 +50,9 @@ def can_haz(user, component):
@register.filter
def can_haz_list(components, user):
return [component for component in components if can_haz(user, component)]
def has_permissions_on_list(components, user):
return [component for component
in components if has_permissions(user, component)]
@register.inclusion_tag('horizon/_nav_list.html', takes_context=True)

View File

@ -157,45 +157,50 @@ class ComputeApiTests(test.APITestCase):
floating_ip.id)
self.assertIsInstance(server, api.nova.Server)
@test.create_stubs({api.nova: ('volume_list',
'server_list',
'flavor_list',
'tenant_floating_ip_list',
'tenant_quota_get',)})
def test_tenant_quota_usages(self):
servers = self.servers.list()
flavors = self.flavors.list()
floating_ips = self.floating_ips.list()
quotas = self.quotas.first()
novaclient = self.stub_novaclient()
api.nova.flavor_list(IsA(http.HttpRequest)) \
.AndReturn(self.flavors.list())
api.nova.tenant_quota_get(IsA(http.HttpRequest), '1') \
.AndReturn(self.quotas.first())
api.nova.tenant_floating_ip_list(IsA(http.HttpRequest)) \
.AndReturn(self.floating_ips.list())
api.nova.server_list(IsA(http.HttpRequest)) \
.AndReturn(self.servers.list())
api.nova.volume_list(IsA(http.HttpRequest)) \
.AndReturn(self.volumes.list())
novaclient.servers = self.mox.CreateMockAnything()
novaclient.servers.list(True, {'project_id': '1'}).AndReturn(servers)
novaclient.flavors = self.mox.CreateMockAnything()
novaclient.flavors.list().AndReturn(flavors)
novaclient.floating_ips = self.mox.CreateMockAnything()
novaclient.floating_ips.list().AndReturn(floating_ips)
novaclient.quotas = self.mox.CreateMockAnything()
novaclient.quotas.get(self.tenant.id).AndReturn(quotas)
self.mox.ReplayAll()
quota_usages = api.tenant_quota_usages(self.request)
self.assertIsInstance(quota_usages, dict)
self.assertEquals(quota_usages,
{'gigabytes': {'available': 1000,
'used': 0,
'flavor_fields': ['disk',
'OS-FLV-EXT-DATA:ephemeral'],
'quota': 1000},
'instances': {'available': 8,
'used': 2,
'flavor_fields': [],
'quota': 10},
'ram': {'available': 8976,
'used': 1024,
'flavor_fields': ['ram'],
'quota': 10000},
'cores': {'available': 8,
'used': 2,
'flavor_fields': ['vcpus'],
'quota': 10},
'floating_ips': {'available': -1,
expected_output = {'gigabytes': {
'used': 80,
'flavor_fields': [],
'quota': 1000},
'ram': {
'available': 8976,
'used': 1024,
'flavor_fields': ['ram'],
'quota': 10000},
'floating_ips': {
'used': 2,
'flavor_fields': [],
'quota': 1}})
'quota': 1},
'instances': {
'used': 2,
'flavor_fields': [],
'quota': 10},
'volumes': {
'used': 3,
'flavor_fields': [],
'quota': 1},
'cores': {
'used': 2,
'flavor_fields': ['vcpus'],
'quota': 10}}
self.assertEquals(quota_usages, expected_output)

View File

@ -5,6 +5,7 @@
<meta content='text/html; charset=utf-8' http-equiv='Content-Type' />
<title>{% block title %}{% endblock %} {% site_branding %} Dashboard</title>
{% block css %}{% endblock %}
{% include "horizon/client_side/_script_loader.html" %}
</head>
<body id="{% block body_id %}{% endblock %}">
{% block content %}

View File

@ -42,6 +42,7 @@ DATABASES = {'default': {'ENGINE': 'django.db.backends.sqlite3'}}
INSTALLED_APPS = (
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.humanize',
'django_nose',
'horizon',
'horizon.tests',

View File

@ -233,7 +233,7 @@ class WorkflowsTests(test.TestCase):
self.assertContains(output, unicode(TestActionTwo.name))
self.assertContains(output, unicode(TestActionThree.name))
def test_can_haz(self):
def test_has_permissions(self):
self.assertQuerysetEqual(TestWorkflow._cls_registry, [])
TestWorkflow.register(AdminStep)
flow = TestWorkflow(self.request)

View File

@ -29,7 +29,7 @@ from django.template.defaultfilters import linebreaks, safe
from horizon import base
from horizon import exceptions
from horizon.templatetags.horizon import can_haz
from horizon.templatetags.horizon import has_permissions
from horizon.utils import html
@ -561,7 +561,7 @@ class Workflow(html.HTMLElement):
self._registry[default_step] = default_step(self)
self._ordered_steps = [self._registry[step_class]
for step_class in ordered_step_classes
if can_haz(self.request.user,
if has_permissions(self.request.user,
self._registry[step_class])]
def _order_steps(self):

View File

@ -111,6 +111,7 @@ INSTALLED_APPS = (
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.humanize',
'compressor',
'horizon',
'horizon.dashboards.nova',

View File

@ -8,6 +8,7 @@
{% block css %}
{% include "_stylesheets.html" %}
{% endblock %}
{% include "horizon/client_side/_script_loader.html" %}
</head>
<body id="{% block body_id %}{% endblock %}">
{% block content %}