From a436b12acc85d474f4e71404de771d7143421eed Mon Sep 17 00:00:00 2001 From: Lucas Palm Date: Wed, 20 Jan 2016 11:48:45 -0600 Subject: [PATCH] Add the Snapshots tab on the Volume Details page Currently, there is no way to filter and see the snapshots for just a single Volume. As the combined list of volume snapshots increases, so does the difficulty in viewing and using them. It would be beneficial to have these snapshots isolated from each other based on the volume they were created from. This change adds a "Snapshots" tab to the Volume Details page that shows a table of snapshots associated with only that specific Volume. This will not only make it easier to just view the snapshots for a particular Volume, but will also make it much more approachable to manage and delete them as well. Change-Id: I0a80ef2c8171c81f73e3abf5ab461dae3a3a9afe Closes-Bug: #1323644 --- .../dashboards/project/snapshots/tables.py | 21 ++++++-- .../dashboards/project/snapshots/views.py | 12 ++++- .../dashboards/project/volumes/tables.py | 1 - .../dashboards/project/volumes/tabs.py | 33 ++++++++++++- .../dashboards/project/volumes/tests.py | 48 +++++++++++++++++++ .../dashboards/project/volumes/urls.py | 3 ++ .../dashboards/project/volumes/views.py | 2 +- 7 files changed, 110 insertions(+), 10 deletions(-) diff --git a/openstack_dashboard/dashboards/project/snapshots/tables.py b/openstack_dashboard/dashboards/project/snapshots/tables.py index 7fbff884a2..683d90e9ff 100644 --- a/openstack_dashboard/dashboards/project/snapshots/tables.py +++ b/openstack_dashboard/dashboards/project/snapshots/tables.py @@ -110,6 +110,11 @@ class EditVolumeSnapshot(policy.PolicyTargetMixin, tables.LinkAction): def allowed(self, request, snapshot=None): return snapshot.status == "available" + def get_link_url(self, datum): + params = urlencode({"success_url": self.table.get_full_url()}) + snapshot_id = self.table.get_object_id(datum) + return "?".join([reverse(self.url, args=(snapshot_id,)), params]) + class CreateVolumeFromSnapshot(tables.LinkAction): name = "create_from_snapshot" @@ -178,15 +183,11 @@ class VolumeSnapshotsFilterAction(tables.FilterAction): if query in snapshot.name.lower()] -class VolumeSnapshotsTable(volume_tables.VolumesTableBase): +class VolumeDetailsSnapshotsTable(volume_tables.VolumesTableBase): name = tables.WrappingColumn( "name", verbose_name=_("Name"), link="horizon:project:snapshots:detail") - volume_name = SnapshotVolumeNameColumn( - "name", - verbose_name=_("Volume Name"), - link="horizon:project:volumes:detail") class Meta(object): name = "volume_snapshots" @@ -209,3 +210,13 @@ class VolumeSnapshotsTable(volume_tables.VolumesTableBase): permissions = [ ('openstack.services.volume', 'openstack.services.volumev2'), ] + + +class VolumeSnapshotsTable(VolumeDetailsSnapshotsTable): + volume_name = SnapshotVolumeNameColumn( + "name", + verbose_name=_("Volume Name"), + link="horizon:project:volumes:detail") + + class Meta(VolumeDetailsSnapshotsTable.Meta): + pass diff --git a/openstack_dashboard/dashboards/project/snapshots/views.py b/openstack_dashboard/dashboards/project/snapshots/views.py index 9e77e1b22f..00d6b94c4e 100644 --- a/openstack_dashboard/dashboards/project/snapshots/views.py +++ b/openstack_dashboard/dashboards/project/snapshots/views.py @@ -12,6 +12,7 @@ from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse_lazy +from django.utils.http import urlencode from django.utils.translation import ugettext_lazy as _ from horizon import exceptions @@ -81,8 +82,11 @@ class UpdateView(forms.ModalFormView): def get_context_data(self, **kwargs): context = super(UpdateView, self).get_context_data(**kwargs) context['snapshot'] = self.get_object() + success_url = self.request.GET.get('success_url', "") args = (self.kwargs['snapshot_id'],) - context['submit_url'] = reverse(self.submit_url, args=args) + params = urlencode({"success_url": success_url}) + context['submit_url'] = "?".join([reverse(self.submit_url, args=args), + params]) return context def get_initial(self): @@ -91,6 +95,12 @@ class UpdateView(forms.ModalFormView): 'name': snapshot.name, 'description': snapshot.description} + def get_success_url(self): + success_url = self.request.GET.get( + "success_url", + reverse_lazy("horizon:project:snapshots:index")) + return success_url + class DetailView(tabs.TabView): tab_group_class = vol_snapshot_tabs.SnapshotDetailTabs diff --git a/openstack_dashboard/dashboards/project/volumes/tables.py b/openstack_dashboard/dashboards/project/volumes/tables.py index d4928da70a..192c59f66a 100644 --- a/openstack_dashboard/dashboards/project/volumes/tables.py +++ b/openstack_dashboard/dashboards/project/volumes/tables.py @@ -34,7 +34,6 @@ from openstack_dashboard import api from openstack_dashboard.api import cinder from openstack_dashboard import policy - DELETABLE_STATES = ("available", "error", "error_extending") diff --git a/openstack_dashboard/dashboards/project/volumes/tabs.py b/openstack_dashboard/dashboards/project/volumes/tabs.py index 5f48ea88b2..ab25898242 100644 --- a/openstack_dashboard/dashboards/project/volumes/tabs.py +++ b/openstack_dashboard/dashboards/project/volumes/tabs.py @@ -14,8 +14,12 @@ from django.utils.translation import ugettext_lazy as _ +from horizon import exceptions from horizon import tabs +from openstack_dashboard.api import cinder +from openstack_dashboard.dashboards.project.snapshots import tables + class OverviewTab(tabs.Tab): name = _("Overview") @@ -26,6 +30,31 @@ class OverviewTab(tabs.Tab): return {"volume": self.tab_group.kwargs['volume']} -class VolumeDetailTabs(tabs.TabGroup): +class SnapshotTab(tabs.TableTab): + table_classes = (tables.VolumeDetailsSnapshotsTable,) + name = _("Snapshots") + slug = "snapshots_tab" + template_name = "horizon/common/_detail_table.html" + preload = False + + def get_volume_snapshots_data(self): + volume_id = self.tab_group.kwargs['volume_id'] + try: + snapshots = cinder.volume_snapshot_list( + self.request, search_opts={'volume_id': volume_id}) + volume = cinder.volume_get(self.request, volume_id) + except Exception: + snapshots = [] + exceptions.handle(self.request, + _("Unable to retrieve volume snapshots for " + "volume %s.") % volume_id) + + for snapshot in snapshots: + snapshot._volume = volume + + return snapshots + + +class VolumeDetailTabs(tabs.DetailTabsGroup): slug = "volume_details" - tabs = (OverviewTab,) + tabs = (OverviewTab, SnapshotTab) diff --git a/openstack_dashboard/dashboards/project/volumes/tests.py b/openstack_dashboard/dashboards/project/volumes/tests.py index fea517df67..b73018b89e 100644 --- a/openstack_dashboard/dashboards/project/volumes/tests.py +++ b/openstack_dashboard/dashboards/project/volumes/tests.py @@ -34,6 +34,7 @@ from openstack_dashboard.test import helpers as test from openstack_dashboard.usage import quotas +DETAIL_URL = ('horizon:project:volumes:detail') INDEX_URL = reverse('horizon:project:volumes:index') SEARCH_OPTS = dict(status=api.cinder.VOLUME_STATE_AVAILABLE) @@ -1490,6 +1491,53 @@ class VolumeViewTests(test.ResetImageAPIVersionMixin, test.TestCase): self.assertEqual(res.status_code, 200) self.assertEqual(volume.name, volume.id) + @test.create_stubs({cinder: ('tenant_absolute_limits', + 'volume_get', + 'volume_snapshot_list', + 'message_list'), + api.nova: ('server_get',)}) + def test_detail_view_snapshot_tab(self): + volume = self.cinder_volumes.first() + server = self.servers.first() + snapshots = self.cinder_volume_snapshots.list() + this_volume_snapshots = [snapshot for snapshot in snapshots + if snapshot.volume_id == volume.id] + volume.attachments = [{"server_id": server.id}] + + # Expected api calls for the Overview tab. + cinder.volume_get(IsA(http.HttpRequest), volume.id).AndReturn(volume) + cinder.volume_snapshot_list(IsA(http.HttpRequest), + search_opts={'volume_id': volume.id})\ + .AndReturn(snapshots) + api.nova.server_get(IsA(http.HttpRequest), server.id).AndReturn(server) + cinder.tenant_absolute_limits(IsA(http.HttpRequest))\ + .AndReturn(self.cinder_limits['absolute']) + cinder.message_list( + IsA(http.HttpRequest), + { + 'resource_uuid': volume.id, + 'resource_type': 'volume' + } + ).AndReturn([]) + + # Expected api calls for the Snapshots tab. + cinder.volume_get(IsA(http.HttpRequest), volume.id).AndReturn(volume) + cinder.volume_snapshot_list(IsA(http.HttpRequest), + search_opts={'volume_id': volume.id})\ + .AndReturn(this_volume_snapshots) + + self.mox.ReplayAll() + + url = '?'.join([reverse(DETAIL_URL, args=[volume.id]), + '='.join(['tab', 'volume_details__snapshots_tab'])]) + res = self.client.get(url) + + self.assertTemplateUsed(res, 'horizon/common/_detail.html') + self.assertEqual(res.context['volume'].id, volume.id) + self.assertEqual(len(res.context['table'].data), + len(this_volume_snapshots)) + self.assertNoMessages() + @test.create_stubs({cinder: ('volume_get',)}) def test_detail_view_with_exception(self): volume = self.cinder_volumes.first() diff --git a/openstack_dashboard/dashboards/project/volumes/urls.py b/openstack_dashboard/dashboards/project/volumes/urls.py index c750d6aa69..191ae283e0 100644 --- a/openstack_dashboard/dashboards/project/volumes/urls.py +++ b/openstack_dashboard/dashboards/project/volumes/urls.py @@ -45,6 +45,9 @@ urlpatterns = [ url(r'^(?P[^/]+)/$', views.DetailView.as_view(), name='detail'), + url(r'^(?P[^/]+)/\?tab=volume_details__snapshots_tab$', + views.DetailView.as_view(), + name='snapshots_tab'), url(r'^(?P[^/]+)/upload_to_image/$', views.UploadToImageView.as_view(), name='upload_to_image'), diff --git a/openstack_dashboard/dashboards/project/volumes/views.py b/openstack_dashboard/dashboards/project/volumes/views.py index 06beff5069..f1f2c14be0 100644 --- a/openstack_dashboard/dashboards/project/volumes/views.py +++ b/openstack_dashboard/dashboards/project/volumes/views.py @@ -140,7 +140,7 @@ class VolumesView(tables.PagedTableMixin, VolumeTableMixIn, return volumes -class DetailView(tabs.TabView): +class DetailView(tabs.TabbedTableView): tab_group_class = project_tabs.VolumeDetailTabs template_name = 'horizon/common/_detail.html' page_title = "{{ volume.name|default:volume.id }}"