diff --git a/openstack_dashboard/api/cinder.py b/openstack_dashboard/api/cinder.py index 6b7204e203..14bb822017 100644 --- a/openstack_dashboard/api/cinder.py +++ b/openstack_dashboard/api/cinder.py @@ -79,7 +79,7 @@ class Volume(BaseCinderAPIResourceWrapper): 'volume_type', 'availability_zone', 'imageRef', 'bootable', 'snapshot_id', 'source_volid', 'attachments', 'tenant_name', 'os-vol-host-attr:host', 'os-vol-tenant-attr:tenant_id', - 'metadata', 'volume_image_metadata', 'encrypted'] + 'metadata', 'volume_image_metadata', 'encrypted', 'transfer'] @property def is_bootable(self): @@ -129,6 +129,11 @@ class QosSpec(object): self.value = val +class VolumeTransfer(base.APIResourceWrapper): + + _attrs = ['id', 'name', 'created_at', 'volume_id', 'auth_key'] + + @memoized def cinderclient(request): api_version = VERSIONS.get_active_version() @@ -181,7 +186,17 @@ def volume_list(request, search_opts=None): c_client = cinderclient(request) if c_client is None: return [] - return [Volume(v) for v in c_client.volumes.list(search_opts=search_opts)] + + # build a dictionary of volume_id -> transfer + transfers = {t.volume_id: t + for t in transfer_list(request, search_opts=search_opts)} + + volumes = [] + for v in c_client.volumes.list(search_opts=search_opts): + v.transfer = transfers.get(v.id) + volumes.append(Volume(v)) + + return volumes def volume_get(request, volume_id): @@ -196,6 +211,14 @@ def volume_get(request, volume_id): # the lack a server_id property; to work around that we'll # give the attached instance a generic name. attachment['instance_name'] = _("Unknown instance") + + volume_data.transfer = None + if volume_data.status == 'awaiting-transfer': + for transfer in transfer_list(request): + if transfer.volume_id == volume_id: + volume_data.transfer = transfer + break + return Volume(volume_data) @@ -521,3 +544,30 @@ def extension_supported(request, extension_name): if extension.name == extension_name: return True return False + + +def transfer_list(request, detailed=True, search_opts=None): + """To see all volumes transfers as an admin pass in a special + search option: {'all_tenants': 1} + """ + c_client = cinderclient(request) + return [VolumeTransfer(v) for v in c_client.transfers.list( + detailed=detailed, search_opts=search_opts)] + + +def transfer_get(request, transfer_id): + transfer_data = cinderclient(request).transfers.get(transfer_id) + return VolumeTransfer(transfer_data) + + +def transfer_create(request, transfer_id, name): + volume = cinderclient(request).transfers.create(transfer_id, name) + return VolumeTransfer(volume) + + +def transfer_accept(request, transfer_id, auth_key): + return cinderclient(request).transfers.accept(transfer_id, auth_key) + + +def transfer_delete(request, transfer_id): + return cinderclient(request).transfers.delete(transfer_id) diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/volumes/_accept_transfer.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/volumes/_accept_transfer.html new file mode 100644 index 0000000000..94cbc8acee --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/volumes/_accept_transfer.html @@ -0,0 +1,7 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} + +{% block modal-body-right %} +
{% trans "Ownership of a volume can be transferred from one project to another. Accepting a transfer requires obtaining the Transfer ID and Authorization Key from the donor. This is equivalent to the cinder transfer-accept command." %}
+{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/volumes/_create_transfer.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/volumes/_create_transfer.html new file mode 100644 index 0000000000..6341b16a00 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/volumes/_create_transfer.html @@ -0,0 +1,7 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} + +{% block modal-body-right %} +{% trans 'Ownership of a volume can be transferred from one project to another. Once a volume transfer is created in a donor project, it then can be "accepted" by a recipient project. This is equivalent to the cinder transfer-create command.' %}
+{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/volumes/_detail_overview.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/volumes/_detail_overview.html index d4ee6d4470..367347be45 100644 --- a/openstack_dashboard/dashboards/project/volumes/templates/volumes/volumes/_detail_overview.html +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/volumes/_detail_overview.html @@ -26,7 +26,7 @@{% trans "The Transfer ID and the Authorization Key are needed by the recipient in order to accept the transfer. Please capture both the Transfer ID and the Authorization Key and provide them to your transfer recipient." %}
+{% trans "The Authorization Key will not be available after closing this page, so you must capture it now, or else you will be unable to use the transfer." %}
+{% endblock %} + +{% block modal-footer %} + {% trans "Close" %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/volumes/accept_transfer.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/volumes/accept_transfer.html new file mode 100644 index 0000000000..8177155a14 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/volumes/accept_transfer.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Accept Volume Transfer" %}{% endblock %} + +{% block page_header %} + {% include "horizon/common/_page_header.html" with title=_("Accept Volume Transfer") %} +{% endblock page_header %} + +{% block main %} + {% include 'project/volumes/volumes/_accept_transfer.html' %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/volumes/create_transfer.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/volumes/create_transfer.html new file mode 100644 index 0000000000..7d4fa2dedb --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/volumes/create_transfer.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Create Volume Transfer" %}{% endblock %} + +{% block page_header %} + {% include "horizon/common/_page_header.html" with title=_("Create a Volume Transfer") %} +{% endblock page_header %} + +{% block main %} + {% include 'project/volumes/volumes/_create_transfer.html' %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/volumes/show_transfer.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/volumes/show_transfer.html new file mode 100644 index 0000000000..6a46b60181 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/volumes/show_transfer.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Volume Transfer Details" %}{% endblock %} + +{% block page_header %} + {% include "horizon/common/_page_header.html" with title=_("Volume Transfer Details") %} +{% endblock page_header %} + +{% block main %} + {% include 'project/volumes/volumes/_show_transfer.html' %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volumes/volumes/forms.py b/openstack_dashboard/dashboards/project/volumes/volumes/forms.py index 5edf6d0f8a..3698f76ff7 100644 --- a/openstack_dashboard/dashboards/project/volumes/volumes/forms.py +++ b/openstack_dashboard/dashboards/project/volumes/volumes/forms.py @@ -20,6 +20,7 @@ Views for managing volumes. from django.conf import settings from django.core.urlresolvers import reverse from django.forms import ValidationError # noqa +from django import http from django.template.defaultfilters import filesizeformat # noqa from django.utils.translation import pgettext_lazy from django.utils.translation import ugettext_lazy as _ @@ -520,6 +521,65 @@ class CreateSnapshotForm(forms.SelfHandlingForm): redirect=redirect) +class CreateTransferForm(forms.SelfHandlingForm): + name = forms.CharField(max_length=255, label=_("Transfer Name"), + required=False) + + def handle(self, request, data): + try: + volume_id = self.initial['volume_id'] + transfer = cinder.transfer_create(request, volume_id, data['name']) + + if data['name']: + msg = _('Created volume transfer: "%s".') % data['name'] + else: + msg = _('Created volume transfer.') + messages.success(request, msg) + response = http.HttpResponseRedirect( + reverse("horizon:project:volumes:volumes:show_transfer", + args=(transfer.id, transfer.auth_key))) + return response + except Exception: + exceptions.handle(request, _('Unable to create volume transfer.')) + + +class AcceptTransferForm(forms.SelfHandlingForm): + # These max lengths correspond to the sizes in cinder + transfer_id = forms.CharField(max_length=36, label=_("Transfer ID")) + auth_key = forms.CharField(max_length=16, label=_("Authorization Key")) + + def handle(self, request, data): + try: + transfer = cinder.transfer_accept(request, + data['transfer_id'], + data['auth_key']) + + msg = (_('Successfully accepted volume transfer: "%s"') + % data['transfer_id']) + messages.success(request, msg) + return transfer + except Exception: + exceptions.handle(request, _('Unable to accept volume transfer.')) + + +class ShowTransferForm(forms.SelfHandlingForm): + name = forms.CharField( + label=_("Transfer Name"), + widget=forms.TextInput(attrs={'readonly': 'readonly'}), + required=False) + id = forms.CharField( + label=_("Transfer ID"), + widget=forms.TextInput(attrs={'readonly': 'readonly'}), + required=False) + auth_key = forms.CharField( + label=_("Authorization Key"), + widget=forms.TextInput(attrs={'readonly': 'readonly'}), + required=False) + + def handle(self, request, data): + pass + + class UpdateForm(forms.SelfHandlingForm): name = forms.CharField(max_length=255, label=_("Volume Name"), required=False) diff --git a/openstack_dashboard/dashboards/project/volumes/volumes/tables.py b/openstack_dashboard/dashboards/project/volumes/volumes/tables.py index a4389f989c..d1fb6d6123 100644 --- a/openstack_dashboard/dashboards/project/volumes/volumes/tables.py +++ b/openstack_dashboard/dashboards/project/volumes/volumes/tables.py @@ -24,6 +24,7 @@ from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ungettext_lazy from horizon import exceptions +from horizon import messages from horizon import tables from openstack_dashboard import api @@ -186,6 +187,17 @@ class CreateSnapshot(VolumePolicyTargetMixin, tables.LinkAction): return volume.status in ("available", "in-use") +class CreateTransfer(VolumePolicyTargetMixin, tables.LinkAction): + name = "create_transfer" + verbose_name = _("Create Transfer") + url = "horizon:project:volumes:volumes:create_transfer" + classes = ("ajax-modal",) + policy_rules = (("volume", "volume:create_transfer"),) + + def allowed(self, request, volume=None): + return volume.status == "available" + + class CreateBackup(VolumePolicyTargetMixin, tables.LinkAction): name = "backups" verbose_name = _("Create Backup") @@ -238,6 +250,48 @@ class RetypeVolume(VolumePolicyTargetMixin, tables.LinkAction): return volume.status in ("available", "in-use") +class AcceptTransfer(tables.LinkAction): + name = "accept_transfer" + verbose_name = _("Accept Transfer") + url = "horizon:project:volumes:volumes:accept_transfer" + classes = ("ajax-modal",) + icon = "exchange" + policy_rules = (("volume", "volume:accept_transfer"),) + ajax = True + + def single(self, table, request, object_id=None): + return HttpResponse(self.render()) + + +class DeleteTransfer(VolumePolicyTargetMixin, tables.Action): + # This class inherits from tables.Action instead of the more obvious + # tables.DeleteAction due to the confirmation message. When the delete + # is successful, DeleteAction automatically appends the name of the + # volume to the message, e.g. "Deleted volume transfer 'volume'". But + # we are deleting the volume *transfer*, whose name is different. + name = "delete_transfer" + verbose_name = _("Cancel Transfer") + policy_rules = (("volume", "volume:delete_transfer"),) + classes = ('btn-danger',) + + def allowed(self, request, volume): + return (volume.status == "awaiting-transfer" and + getattr(volume, 'transfer', None)) + + def single(self, table, request, volume_id): + volume = table.get_object_by_id(volume_id) + try: + cinder.transfer_delete(request, volume.transfer.id) + if volume.transfer.name: + msg = _('Successfully deleted volume transfer "%s"' + ) % volume.transfer.name + else: + msg = _("Successfully deleted volume transfer") + messages.success(request, msg) + except Exception: + exceptions.handle(request, _("Unable to delete volume transfer.")) + + class UpdateRow(tables.Row): ajax = True @@ -361,10 +415,12 @@ class VolumesTable(VolumesTableBase): verbose_name = _("Volumes") status_columns = ["status"] row_class = UpdateRow - table_actions = (CreateVolume, DeleteVolume, VolumesFilterAction) + table_actions = (CreateVolume, AcceptTransfer, DeleteVolume, + VolumesFilterAction) row_actions = (EditVolume, ExtendVolume, LaunchVolume, EditAttachments, CreateSnapshot, CreateBackup, RetypeVolume, - UploadToImage, DeleteVolume) + UploadToImage, CreateTransfer, DeleteTransfer, + DeleteVolume) class DetachVolume(tables.BatchAction): diff --git a/openstack_dashboard/dashboards/project/volumes/volumes/tests.py b/openstack_dashboard/dashboards/project/volumes/volumes/tests.py index cddc7eec4a..7bdc595833 100644 --- a/openstack_dashboard/dashboards/project/volumes/volumes/tests.py +++ b/openstack_dashboard/dashboards/project/volumes/volumes/tests.py @@ -1033,7 +1033,8 @@ class VolumeViewTests(test.TestCase): self.assertContains(res, expected_string, html=True, msg_prefix="The create button is not disabled") - @test.create_stubs({cinder: ('volume_get', 'tenant_absolute_limits'), + @test.create_stubs({cinder: ('tenant_absolute_limits', + 'volume_get',), api.nova: ('server_get',)}) def test_detail_view(self): volume = self.cinder_volumes.first() @@ -1398,3 +1399,100 @@ class VolumeViewTests(test.TestCase): self.assertFormError(res, "form", "new_size", "Volume cannot be extended to 1000GB as you only " "have 80GB of your quota available.") + + @test.create_stubs({cinder: ('volume_backup_supported', + 'volume_list', + 'tenant_absolute_limits'), + api.nova: ('server_list',)}) + def test_create_transfer_availability(self): + limits = self.cinder_limits['absolute'] + + cinder.volume_backup_supported(IsA(http.HttpRequest))\ + .MultipleTimes().AndReturn(False) + cinder.volume_list(IsA(http.HttpRequest), search_opts=None)\ + .AndReturn(self.volumes.list()) + api.nova.server_list(IsA(http.HttpRequest), search_opts=None)\ + .AndReturn([self.servers.list(), False]) + cinder.tenant_absolute_limits(IsA(http.HttpRequest))\ + .MultipleTimes().AndReturn(limits) + + self.mox.ReplayAll() + + res = self.client.get(VOLUME_INDEX_URL) + table = res.context['volumes_table'] + + # Verify that the create transfer action is present if and only if + # the volume is available + for vol in table.data: + actions = [a.name for a in table.get_row_actions(vol)] + self.assertEqual('create_transfer' in actions, + vol.status == 'available') + + @test.create_stubs({cinder: ('transfer_create',)}) + def test_create_transfer(self): + volumes = self.volumes.list() + volToTransfer = [v for v in volumes if v.status == 'available'][0] + formData = {'volume_id': volToTransfer.id, + 'name': u'any transfer name'} + + cinder.transfer_create(IsA(http.HttpRequest), + formData['volume_id'], + formData['name']).AndReturn( + self.cinder_volume_transfers.first()) + + self.mox.ReplayAll() + + # Create a transfer for the first available volume + url = reverse('horizon:project:volumes:volumes:create_transfer', + args=[volToTransfer.id]) + res = self.client.post(url, formData) + self.assertNoFormErrors(res) + + @test.create_stubs({cinder: ('volume_backup_supported', + 'volume_list', + 'transfer_delete', + 'tenant_absolute_limits'), + api.nova: ('server_list',)}) + def test_delete_transfer(self): + transfer = self.cinder_volume_transfers.first() + volumes = [] + # Attach the volume transfer to the relevant volume + for v in self.cinder_volumes.list(): + if v.id == transfer.volume_id: + v.status = 'awaiting-transfer' + v.transfer = transfer + volumes.append(v) + + formData = {'action': + 'volumes__delete_transfer__%s' % transfer.volume_id} + + cinder.volume_backup_supported(IsA(http.HttpRequest))\ + .MultipleTimes().AndReturn(False) + cinder.volume_list(IsA(http.HttpRequest), search_opts=None)\ + .AndReturn(volumes) + cinder.transfer_delete(IsA(http.HttpRequest), transfer.id) + api.nova.server_list(IsA(http.HttpRequest), search_opts=None).\ + AndReturn([self.servers.list(), False]) + cinder.tenant_absolute_limits(IsA(http.HttpRequest)).MultipleTimes().\ + AndReturn(self.cinder_limits['absolute']) + + self.mox.ReplayAll() + + url = VOLUME_INDEX_URL + res = self.client.post(url, formData, follow=True) + self.assertNoFormErrors(res) + self.assertIn('Successfully deleted volume transfer "test transfer"', + [m.message for m in res.context['messages']]) + + @test.create_stubs({cinder: ('transfer_accept',)}) + def test_accept_transfer(self): + transfer = self.cinder_volume_transfers.first() + + cinder.transfer_accept(IsA(http.HttpRequest), transfer.id, + transfer.auth_key) + self.mox.ReplayAll() + + formData = {'transfer_id': transfer.id, 'auth_key': transfer.auth_key} + url = reverse('horizon:project:volumes:volumes:accept_transfer') + res = self.client.post(url, formData, follow=True) + self.assertNoFormErrors(res) diff --git a/openstack_dashboard/dashboards/project/volumes/volumes/urls.py b/openstack_dashboard/dashboards/project/volumes/volumes/urls.py index a949562652..4617e54c78 100644 --- a/openstack_dashboard/dashboards/project/volumes/volumes/urls.py +++ b/openstack_dashboard/dashboards/project/volumes/volumes/urls.py @@ -35,6 +35,15 @@ urlpatterns = patterns( url(r'^(?P