Added action for creating a volume from snapshot

Added a new action to create new volume from
a volume snapshot. The snapshot_id is passed to
nova volume API. The 'Create Volume' form is
reused, with the 'size' value being checked against
the snapshot size, as it cannot be less than
the snapshot size.

Fixes bug 1023740

PATCH SET 4: Added dropdown to select snapshot source
in 'Create Volume' form, when loaded from /nova/volumes/
When a snapshot is selected from the dropdown, the name
and size are pre-filled in the form (I've removed
the description copy as it is probably specific
for the reason a snapshot is being created).

PATCH SET 6: rebased with master, fixed a bug with
get_context in nova/volume/views

Change-Id: I20dd8d698f5d8481938417557e09dcdc9b891e82
This commit is contained in:
Tihomir Trifonov 2012-07-24 11:15:40 +03:00
parent da7f44cdc2
commit 2392da498a
8 changed files with 240 additions and 23 deletions

View File

@ -518,9 +518,9 @@ def volume_instance_list(request, instance_id):
return volumes
def volume_create(request, size, name, description):
def volume_create(request, size, name, description, snapshot_id=None):
return cinderclient(request).volumes.create(size, display_name=name,
display_description=description)
display_description=description, snapshot_id=snapshot_id)
def volume_delete(request, volume_id):

View File

@ -16,6 +16,8 @@
import logging
from django.core.urlresolvers import reverse
from django.utils.http import urlencode
from django.utils.translation import ugettext_lazy as _
from horizon import api
@ -34,6 +36,21 @@ class DeleteVolumeSnapshot(tables.DeleteAction):
api.volume_snapshot_delete(request, obj_id)
class CreateVolumeFromSnapshot(tables.LinkAction):
name = "create_from_snapshot"
verbose_name = _("Create Volume")
url = "horizon:nova:volumes:create"
classes = ("ajax-modal", "btn-camera")
def get_link_url(self, datum):
base_url = reverse(self.url)
params = urlencode({"snapshot_id": self.table.get_object_id(datum)})
return "?".join([base_url, params])
def allowed(self, request, volume=None):
return volume.status == "available" if volume else False
class UpdateRow(tables.Row):
ajax = True
@ -51,6 +68,6 @@ class VolumeSnapshotsTable(volume_tables.VolumesTableBase):
name = "volume_snapshots"
verbose_name = _("Volume Snapshots")
table_actions = (DeleteVolumeSnapshot,)
row_actions = (DeleteVolumeSnapshot,)
row_actions = (CreateVolumeFromSnapshot, DeleteVolumeSnapshot)
row_class = UpdateRow
status_columns = ("status",)

View File

@ -15,15 +15,54 @@ from horizon import api
from horizon import forms
from horizon import exceptions
from horizon import messages
from horizon.utils.fields import SelectWidget
from horizon.utils.memoized import memoized
from ..instances.tables import ACTIVE_STATES
class CreateForm(forms.SelfHandlingForm):
name = forms.CharField(max_length="255", label="Volume Name")
name = forms.CharField(max_length="255", label=_("Volume Name"))
description = forms.CharField(widget=forms.Textarea,
label=_("Description"), required=False)
size = forms.IntegerField(min_value=1, label="Size (GB)")
size = forms.IntegerField(min_value=1, label=_("Size (GB)"))
snapshot_source = forms.ChoiceField(label=_("Use snapshot as a source"),
widget=SelectWidget(
attrs={'class': 'snapshot-selector'},
data_attrs=('size', 'display_name'),
transform=lambda x:
("%s (%sGB)" % (x.display_name,
x.size))),
required=False)
def __init__(self, request, *args, **kwargs):
super(CreateForm, self).__init__(request, *args, **kwargs)
if ("snapshot_id" in request.GET):
try:
snapshot = self.get_snapshot(request,
request.GET["snapshot_id"])
self.fields['name'].initial = snapshot.display_name
self.fields['size'].initial = snapshot.size
self.fields['snapshot_source'].choices = ((snapshot.id,
snapshot),)
self.fields['size'].help_text = _('Volume size must be equal '
'to or greater than the snapshot size (%sGB)'
% snapshot.size)
except:
exceptions.handle(request,
_('Unable to load the specified snapshot.'))
else:
try:
snapshots = api.volume_snapshot_list(request)
if snapshots:
choices = [('', _("Choose a snapshot"))] + \
[(s.id, s) for s in snapshots]
self.fields['snapshot_source'].choices = choices
else:
del self.fields['snapshot_source']
except:
exceptions.handle(request, _("Unable to retrieve "
"volume snapshots."))
def handle(self, request, data):
try:
@ -33,8 +72,20 @@ class CreateForm(forms.SelfHandlingForm):
# 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'])
snapshot_id = None
if (data.get("snapshot_source", None)):
# Create from Snapshot
snapshot = self.get_snapshot(request,
data["snapshot_source"])
snapshot_id = snapshot.id
if (data['size'] < snapshot.size):
error_message = _('The volume size cannot be less than '
'the snapshot size (%sGB)' %
snapshot.size)
raise ValidationError(error_message)
else:
if type(data['size']) is str:
data['size'] = int(data['size'])
if usages['gigabytes']['available'] < data['size']:
error_message = _('A volume of %(req)iGB cannot be created as '
@ -51,7 +102,8 @@ class CreateForm(forms.SelfHandlingForm):
volume = api.volume_create(request,
data['size'],
data['name'],
data['description'])
data['description'],
snapshot_id=snapshot_id)
message = 'Creating volume "%s"' % data['name']
messages.info(request, message)
return volume
@ -61,12 +113,16 @@ class CreateForm(forms.SelfHandlingForm):
exceptions.handle(request, ignore=True)
return self.api_error(_("Unable to create volume."))
@memoized
def get_snapshot(self, request, id):
return api.nova.volume_snapshot_get(request, id)
class AttachForm(forms.SelfHandlingForm):
instance = forms.ChoiceField(label="Attach to Instance",
instance = forms.ChoiceField(label=_("Attach to Instance"),
help_text=_("Select an instance to "
"attach to."))
device = forms.CharField(label="Device Name", initial="/dev/vdc")
device = forms.CharField(label=_("Device Name"), initial="/dev/vdc")
def __init__(self, *args, **kwargs):
super(AttachForm, self).__init__(*args, **kwargs)
@ -126,8 +182,8 @@ class CreateSnapshotForm(forms.SelfHandlingForm):
description = forms.CharField(widget=forms.Textarea,
label=_("Description"), required=False)
def __init__(self, *args, **kwargs):
super(CreateSnapshotForm, self).__init__(*args, **kwargs)
def __init__(self, request, *args, **kwargs):
super(CreateSnapshotForm, self).__init__(request, *args, **kwargs)
# populate volume_id
volume_id = kwargs.get('initial', {}).get('volume_id', [])

View File

@ -2,7 +2,7 @@
{% load i18n horizon humanize %}
{% block form_id %}{% endblock %}
{% block form_action %}{% url horizon:nova:volumes:create %}{% endblock %}
{% block form_action %}{% url horizon:nova:volumes:create %}?{{ request.GET.urlencode }}{% endblock %}
{% block modal_id %}create_volume_modal{% endblock %}
{% block modal-header %}{% trans "Create Volume" %}{% endblock %}

View File

@ -27,20 +27,24 @@ from horizon import test
class VolumeViewTests(test.TestCase):
@test.create_stubs({api: ('tenant_quota_usages', 'volume_create',)})
@test.create_stubs({api: ('tenant_quota_usages', 'volume_create',
'volume_snapshot_list')})
def test_create_volume(self):
volume = self.volumes.first()
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}
'size': 50, 'snapshot_source': ''}
api.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage)
api.volume_snapshot_list(IsA(http.HttpRequest)).\
AndReturn(self.volume_snapshots.list())
api.volume_create(IsA(http.HttpRequest),
formData['size'],
formData['name'],
formData['description']).AndReturn(volume)
formData['description'],
snapshot_id=None).AndReturn(volume)
self.mox.ReplayAll()
@ -50,7 +54,86 @@ class VolumeViewTests(test.TestCase):
redirect_url = reverse('horizon:nova:volumes:index')
self.assertRedirectsNoFollow(res, redirect_url)
@test.create_stubs({api: ('tenant_quota_usages',)})
@test.create_stubs({api: ('tenant_quota_usages', 'volume_create',
'volume_snapshot_list'),
api.nova: ('volume_snapshot_get',)})
def test_create_volume_from_snapshot(self):
volume = self.volumes.first()
usage = {'gigabytes': {'available': 250}, 'volumes': {'available': 6}}
snapshot = self.volume_snapshots.first()
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, 'snapshot_source': snapshot.id}
# first call- with url param
api.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage)
api.nova.volume_snapshot_get(IsA(http.HttpRequest),
str(snapshot.id)).AndReturn(snapshot)
api.volume_create(IsA(http.HttpRequest),
formData['size'],
formData['name'],
formData['description'],
snapshot_id=snapshot.id).\
AndReturn(volume)
# second call- with dropdown
api.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage)
api.volume_snapshot_list(IsA(http.HttpRequest)).\
AndReturn(self.volume_snapshots.list())
api.nova.volume_snapshot_get(IsA(http.HttpRequest),
str(snapshot.id)).AndReturn(snapshot)
api.volume_create(IsA(http.HttpRequest),
formData['size'],
formData['name'],
formData['description'],
snapshot_id=snapshot.id).\
AndReturn(volume)
self.mox.ReplayAll()
# get snapshot from url
url = reverse('horizon:nova:volumes:create')
res = self.client.post("?".join([url,
"snapshot_id=" + str(snapshot.id)]),
formData)
redirect_url = reverse('horizon:nova:volumes:index')
self.assertRedirectsNoFollow(res, redirect_url)
# get snapshot from dropdown list
url = reverse('horizon:nova:volumes:create')
res = self.client.post(url, formData)
redirect_url = reverse('horizon:nova:volumes:index')
self.assertRedirectsNoFollow(res, redirect_url)
@test.create_stubs({api: ('tenant_quota_usages',),
api.nova: ('volume_snapshot_get',)})
def test_create_volume_from_snapshot_invalid_size(self):
usage = {'gigabytes': {'available': 250}, 'volumes': {'available': 6}}
snapshot = self.volume_snapshots.first()
formData = {'name': u'A Volume I Am Making',
'description': u'This is a volume I am making for a test.',
'method': u'CreateForm',
'size': 20, 'snapshot_source': snapshot.id}
api.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage)
api.nova.volume_snapshot_get(IsA(http.HttpRequest),
str(snapshot.id)).AndReturn(snapshot)
api.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage)
self.mox.ReplayAll()
url = reverse('horizon:nova:volumes:create')
res = self.client.post("?".join([url,
"snapshot_id=" + str(snapshot.id)]),
formData, follow=True)
self.assertEqual(res.redirect_chain, [])
self.assertFormError(res, 'form', None,
"The volume size cannot be less than the "
"snapshot size (40GB)")
@test.create_stubs({api: ('tenant_quota_usages', 'volume_snapshot_list')})
def test_create_volume_gb_used_over_alloted_quota(self):
usage = {'gigabytes': {'available': 100, 'used': 20}}
formData = {'name': u'This Volume Is Huge!',
@ -59,6 +142,8 @@ class VolumeViewTests(test.TestCase):
'size': 5000}
api.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage)
api.volume_snapshot_list(IsA(http.HttpRequest)).\
AndReturn(self.volume_snapshots.list())
api.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage)
self.mox.ReplayAll()
@ -70,7 +155,7 @@ class VolumeViewTests(test.TestCase):
' have 100GB of your quota available.']
self.assertEqual(res.context['form'].errors['__all__'], expected_error)
@test.create_stubs({api: ('tenant_quota_usages',)})
@test.create_stubs({api: ('tenant_quota_usages', 'volume_snapshot_list')})
def test_create_volume_number_over_alloted_quota(self):
usage = {'gigabytes': {'available': 100, 'used': 20},
'volumes': {'available': 0}}
@ -80,6 +165,8 @@ class VolumeViewTests(test.TestCase):
'size': 10}
api.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage)
api.volume_snapshot_list(IsA(http.HttpRequest)).\
AndReturn(self.volume_snapshots.list())
api.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(usage)
self.mox.ReplayAll()

View File

@ -96,7 +96,9 @@ class CreateSnapshotView(forms.ModalFormView):
success_url = reverse_lazy("horizon:nova:images_and_snapshots:index")
def get_context_data(self, **kwargs):
return {'volume_id': self.kwargs['volume_id']}
context = super(CreateSnapshotView, self).get_context_data(**kwargs)
context['volume_id'] = self.kwargs['volume_id']
return context
def get_initial(self):
return {'volume_id': self.kwargs["volume_id"]}

View File

@ -1,7 +1,7 @@
/* Namespace for core functionality related to Forms. */
horizon.forms = {
handle_source_group: function() {
$(document).on("change", "#id_source_group", function (evt) {
$("div.table_wrapper, #modal_wrapper").on("change", "#id_source_group", function (evt) {
var $sourceGroup = $('#id_source_group'),
$cidrContainer = $('#id_cidr').closest(".control-group");
if($sourceGroup.val() === "") {
@ -10,6 +10,16 @@ horizon.forms = {
$cidrContainer.addClass("hide");
}
});
},
handle_snapshot_source: function() {
$("div.table_wrapper, #modal_wrapper").on("change", "select#id_snapshot_source", function(evt) {
var $option = $(this).find("option:selected");
var $form = $(this).closest('form');
var $volName = $form.find('input#id_name');
$volName.val($option.data("display_name"));
var $volSize = $form.find('input#id_size');
$volSize.val($option.data("size"));
});
}
};
@ -48,6 +58,7 @@ horizon.addInitFunction(function () {
horizon.modals.addModalInitFunction(horizon.forms.bind_add_item_handlers);
horizon.forms.handle_source_group();
horizon.forms.handle_snapshot_source();
// Bind event handlers to confirm dangerous actions.
$("body").on("click", "form button.btn-danger", function (evt) {
@ -62,8 +73,8 @@ horizon.addInitFunction(function () {
var type = $(this).val();
$(this).closest('fieldset').find('input[type=text]').each(function(index, obj){
var label_val = "";
if ($(obj).attr("data-" + type)){
label_val = $(obj).attr("data-" + type);
if ($(obj).data(type)){
label_val = $(obj).data(type);
} else if ($(obj).attr("data")){
label_val = $(obj).attr("data");
} else

View File

@ -1,8 +1,11 @@
import re
import netaddr
from django.core.exceptions import ValidationError
from django.forms import forms
from django.forms import forms, widgets
from django.utils.translation import ugettext as _
from django.utils.encoding import force_unicode
from django.utils.html import escape, conditional_escape
from django.utils.functional import Promise
ip_allowed_symbols_re = re.compile(r'^[a-fA-F0-9:/\.]+$')
IPv4 = 1
@ -82,3 +85,44 @@ class IPField(forms.Field):
def clean(self, value):
super(IPField, self).clean(value)
return str(getattr(self, "ip", ""))
class SelectWidget(widgets.Select):
"""
Customizable select widget, that allows to render
data-xxx attributes from choices.
.. attribute:: data_attrs
Specifies object properties to serialize as
data-xxx attribute. If passed ('id', ),
this will be rendered as:
<option data-id="123">option_value</option>
where 123 is the value of choice_value.id
.. attribute:: transform
A callable used to render the display value
from the option object.
"""
def __init__(self, attrs=None, choices=(), data_attrs=(), transform=None):
self.data_attrs = data_attrs
self.transform = transform
super(SelectWidget, self).__init__(attrs, choices)
def render_option(self, selected_choices, option_value, option_label):
option_value = force_unicode(option_value)
other_html = (option_value in selected_choices) and \
u' selected="selected"' or ''
if not isinstance(option_label, (basestring, Promise)):
for data_attr in self.data_attrs:
data_value = conditional_escape(
force_unicode(getattr(option_label,
data_attr, "")))
other_html += ' data-%s="%s"' % (data_attr, data_value)
if self.transform:
option_label = self.transform(option_label)
return u'<option value="%s"%s>%s</option>' % (
escape(option_value), other_html,
conditional_escape(force_unicode(option_label)))