From 2392da498ab51f94d0e45db14fd800c1d196b4b7 Mon Sep 17 00:00:00 2001 From: Tihomir Trifonov Date: Tue, 24 Jul 2012 11:15:40 +0300 Subject: [PATCH] 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 --- horizon/api/nova.py | 4 +- .../volume_snapshots/tables.py | 19 +++- horizon/dashboards/nova/volumes/forms.py | 74 ++++++++++++-- .../volumes/templates/volumes/_create.html | 2 +- horizon/dashboards/nova/volumes/tests.py | 97 ++++++++++++++++++- horizon/dashboards/nova/volumes/views.py | 4 +- horizon/static/horizon/js/horizon.forms.js | 17 +++- horizon/utils/fields.py | 46 ++++++++- 8 files changed, 240 insertions(+), 23 deletions(-) diff --git a/horizon/api/nova.py b/horizon/api/nova.py index fcf6d9f12..853b51d7f 100644 --- a/horizon/api/nova.py +++ b/horizon/api/nova.py @@ -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): diff --git a/horizon/dashboards/nova/images_and_snapshots/volume_snapshots/tables.py b/horizon/dashboards/nova/images_and_snapshots/volume_snapshots/tables.py index f7f5be68f..417eacddd 100644 --- a/horizon/dashboards/nova/images_and_snapshots/volume_snapshots/tables.py +++ b/horizon/dashboards/nova/images_and_snapshots/volume_snapshots/tables.py @@ -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",) diff --git a/horizon/dashboards/nova/volumes/forms.py b/horizon/dashboards/nova/volumes/forms.py index 166dbdb47..2e15cf5ef 100644 --- a/horizon/dashboards/nova/volumes/forms.py +++ b/horizon/dashboards/nova/volumes/forms.py @@ -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', []) diff --git a/horizon/dashboards/nova/volumes/templates/volumes/_create.html b/horizon/dashboards/nova/volumes/templates/volumes/_create.html index 4c8525785..868b1c993 100644 --- a/horizon/dashboards/nova/volumes/templates/volumes/_create.html +++ b/horizon/dashboards/nova/volumes/templates/volumes/_create.html @@ -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 %} diff --git a/horizon/dashboards/nova/volumes/tests.py b/horizon/dashboards/nova/volumes/tests.py index 95917fed2..c8a0dda4e 100644 --- a/horizon/dashboards/nova/volumes/tests.py +++ b/horizon/dashboards/nova/volumes/tests.py @@ -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() diff --git a/horizon/dashboards/nova/volumes/views.py b/horizon/dashboards/nova/volumes/views.py index fd6f54dd8..d4e05bfb9 100644 --- a/horizon/dashboards/nova/volumes/views.py +++ b/horizon/dashboards/nova/volumes/views.py @@ -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"]} diff --git a/horizon/static/horizon/js/horizon.forms.js b/horizon/static/horizon/js/horizon.forms.js index 6dd16e841..8ee609e20 100644 --- a/horizon/static/horizon/js/horizon.forms.js +++ b/horizon/static/horizon/js/horizon.forms.js @@ -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 diff --git a/horizon/utils/fields.py b/horizon/utils/fields.py index 7230fb6f8..90468d141 100644 --- a/horizon/utils/fields.py +++ b/horizon/utils/fields.py @@ -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: + + 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'' % ( + escape(option_value), other_html, + conditional_escape(force_unicode(option_label)))