diff --git a/openstack_dashboard/dashboards/admin/snapshots/views.py b/openstack_dashboard/dashboards/admin/snapshots/views.py index 22954609b8..de0852ffde 100644 --- a/openstack_dashboard/dashboards/admin/snapshots/views.py +++ b/openstack_dashboard/dashboards/admin/snapshots/views.py @@ -115,7 +115,7 @@ class UpdateStatusView(forms.ModalFormView): class DetailView(views.DetailView): tab_group_class = vol_snapshot_tabs.SnapshotDetailsTabs - volume_url = 'horizon:admin:volumes:volumes:detail' + volume_url = 'horizon:admin:volumes:detail' def get_context_data(self, **kwargs): context = super(DetailView, self).get_context_data(**kwargs) diff --git a/openstack_dashboard/dashboards/admin/volumes/volumes/forms.py b/openstack_dashboard/dashboards/admin/volumes/forms.py similarity index 99% rename from openstack_dashboard/dashboards/admin/volumes/volumes/forms.py rename to openstack_dashboard/dashboards/admin/volumes/forms.py index ec7a16bcef..06708a9bc2 100644 --- a/openstack_dashboard/dashboards/admin/volumes/volumes/forms.py +++ b/openstack_dashboard/dashboards/admin/volumes/forms.py @@ -210,7 +210,7 @@ class MigrateVolume(forms.SelfHandlingForm): % data['name']) return True except Exception: - redirect = reverse("horizon:admin:volumes:volumes_tab") + redirect = reverse("horizon:admin:volumes:index") exceptions.handle(request, _("Failed to migrate volume."), redirect=redirect) diff --git a/openstack_dashboard/dashboards/admin/volumes/volumes/tables.py b/openstack_dashboard/dashboards/admin/volumes/tables.py similarity index 93% rename from openstack_dashboard/dashboards/admin/volumes/volumes/tables.py rename to openstack_dashboard/dashboards/admin/volumes/tables.py index 5b5350c03c..ae88850fa5 100644 --- a/openstack_dashboard/dashboards/admin/volumes/volumes/tables.py +++ b/openstack_dashboard/dashboards/admin/volumes/tables.py @@ -37,7 +37,7 @@ class VolumesFilterAction(tables.FilterAction): class ManageVolumeAction(tables.LinkAction): name = "manage" verbose_name = _("Manage Volume") - url = "horizon:admin:volumes:volumes:manage" + url = "horizon:admin:volumes:manage" classes = ("ajax-modal",) icon = "plus" policy_rules = (("volume", "volume_extension:volume_manage"),) @@ -47,7 +47,7 @@ class ManageVolumeAction(tables.LinkAction): class UnmanageVolumeAction(tables.LinkAction): name = "unmanage" verbose_name = _("Unmanage Volume") - url = "horizon:admin:volumes:volumes:unmanage" + url = "horizon:admin:volumes:unmanage" classes = ("ajax-modal",) icon = "pencil" policy_rules = (("volume", "volume_extension:volume_unmanage"),) @@ -73,7 +73,7 @@ class UnmanageVolumeAction(tables.LinkAction): class MigrateVolume(tables.LinkAction): name = "migrate" verbose_name = _("Migrate Volume") - url = "horizon:admin:volumes:volumes:migrate" + url = "horizon:admin:volumes:migrate" classes = ("ajax-modal", "btn-migrate") policy_rules = ( ("volume", "volume_extension:volume_admin_actions:migrate_volume"),) @@ -85,7 +85,7 @@ class MigrateVolume(tables.LinkAction): class UpdateVolumeStatusAction(tables.LinkAction): name = "update_status" verbose_name = _("Update Volume Status") - url = "horizon:admin:volumes:volumes:update_status" + url = "horizon:admin:volumes:update_status" classes = ("ajax-modal",) icon = "pencil" policy_rules = (("volume", @@ -95,7 +95,7 @@ class UpdateVolumeStatusAction(tables.LinkAction): class VolumesTable(volumes_tables.VolumesTable): name = tables.WrappingColumn("name", verbose_name=_("Name"), - link="horizon:admin:volumes:volumes:detail") + link="horizon:admin:volumes:detail") host = tables.Column("os-vol-host-attr:host", verbose_name=_("Host")) tenant = tables.Column(lambda obj: getattr(obj, 'tenant_name', None), verbose_name=_("Project")) diff --git a/openstack_dashboard/dashboards/admin/volumes/tabs.py b/openstack_dashboard/dashboards/admin/volumes/tabs.py deleted file mode 100644 index 3358f9a115..0000000000 --- a/openstack_dashboard/dashboards/admin/volumes/tabs.py +++ /dev/null @@ -1,104 +0,0 @@ -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. - -from collections import OrderedDict -from django.conf import settings -from django.utils.translation import ugettext_lazy as _ - -from horizon import exceptions -from horizon import tables -from horizon import tabs - -from openstack_dashboard.api import keystone - -from openstack_dashboard.dashboards.admin.volumes.volumes \ - import tables as volumes_tables -from openstack_dashboard.dashboards.project.volumes \ - import views as volumes_views - - -class VolumeTab(tables.PagedTableMixin, tabs.TableTab, - volumes_views.VolumeTableMixIn, tables.DataTableView): - table_classes = (volumes_tables.VolumesTable,) - name = _("Volumes") - slug = "volumes_tab" - template_name = "admin/volumes/volumes/volumes_tables.html" - preload = False - FILTERS_MAPPING = {'bootable': {_('yes'): 'true', _('no'): 'false'}, - 'encrypted': {_('yes'): True, _('no'): False}} - - def get_volumes_data(self): - default_filters = {'all_tenants': True} - - filters = self.get_filters(default_filters.copy()) - filter_first = getattr(settings, 'FILTER_DATA_FIRST', {}) - volumes = [] - - self.table.needs_filter_first = False - - if filter_first.get('admin.volumes', False) and \ - len(filters) == len(default_filters): - self.table.needs_filter_first = True - return volumes - - if 'project' in filters: - # Keystone returns a tuple ([],false) where the first element is - # tenant list that's why the 0 is hardcoded below - tenants = keystone.tenant_list(self.request)[0] - tenant_ids = [t.id for t in tenants - if t.name == filters['project']] - if not tenant_ids: - return [] - del filters['project'] - for id in tenant_ids: - filters['project_id'] = id - volumes += self._get_volumes(search_opts=filters) - else: - volumes = self._get_volumes(search_opts=filters) - - attached_instance_ids = self._get_attached_instance_ids(volumes) - instances = self._get_instances(search_opts={'all_tenants': True}, - instance_ids=attached_instance_ids) - volume_ids_with_snapshots = self._get_volumes_ids_with_snapshots( - search_opts={'all_tenants': True}) - self._set_volume_attributes( - volumes, instances, volume_ids_with_snapshots) - - # Gather our tenants to correlate against IDs - try: - tenants, has_more = keystone.tenant_list(self.request) - except Exception: - tenants = [] - msg = _('Unable to retrieve volume project information.') - exceptions.handle(self.request, msg) - - tenant_dict = OrderedDict([(t.id, t) for t in tenants]) - for volume in volumes: - tenant_id = getattr(volume, "os-vol-tenant-attr:tenant_id", None) - tenant = tenant_dict.get(tenant_id, None) - volume.tenant_name = getattr(tenant, "name", None) - - return volumes - - def get_filters(self, filters): - self.table = self._tables['volumes'] - self.handle_server_filter(self.request, table=self.table) - self.update_server_filter_action(self.request, table=self.table) - filters = super(VolumeTab, self).get_filters(filters, - self.FILTERS_MAPPING) - return filters - - -class VolumesGroupTabs(tabs.TabGroup): - slug = "volumes_group_tabs" - tabs = (VolumeTab, ) - sticky = True diff --git a/openstack_dashboard/dashboards/admin/volumes/templates/volumes/volumes/_manage_volume.html b/openstack_dashboard/dashboards/admin/volumes/templates/volumes/_manage_volume.html similarity index 100% rename from openstack_dashboard/dashboards/admin/volumes/templates/volumes/volumes/_manage_volume.html rename to openstack_dashboard/dashboards/admin/volumes/templates/volumes/_manage_volume.html diff --git a/openstack_dashboard/dashboards/admin/volumes/templates/volumes/volumes/_migrate_volume.html b/openstack_dashboard/dashboards/admin/volumes/templates/volumes/_migrate_volume.html similarity index 100% rename from openstack_dashboard/dashboards/admin/volumes/templates/volumes/volumes/_migrate_volume.html rename to openstack_dashboard/dashboards/admin/volumes/templates/volumes/_migrate_volume.html diff --git a/openstack_dashboard/dashboards/admin/volumes/templates/volumes/volumes/_unmanage_volume.html b/openstack_dashboard/dashboards/admin/volumes/templates/volumes/_unmanage_volume.html similarity index 100% rename from openstack_dashboard/dashboards/admin/volumes/templates/volumes/volumes/_unmanage_volume.html rename to openstack_dashboard/dashboards/admin/volumes/templates/volumes/_unmanage_volume.html diff --git a/openstack_dashboard/dashboards/admin/volumes/templates/volumes/volumes/_update_status.html b/openstack_dashboard/dashboards/admin/volumes/templates/volumes/_update_status.html similarity index 100% rename from openstack_dashboard/dashboards/admin/volumes/templates/volumes/volumes/_update_status.html rename to openstack_dashboard/dashboards/admin/volumes/templates/volumes/_update_status.html diff --git a/openstack_dashboard/dashboards/admin/volumes/templates/volumes/index.html b/openstack_dashboard/dashboards/admin/volumes/templates/volumes/index.html deleted file mode 100644 index 4b518c863d..0000000000 --- a/openstack_dashboard/dashboards/admin/volumes/templates/volumes/index.html +++ /dev/null @@ -1,11 +0,0 @@ -{% extends 'base.html' %} -{% load i18n %} -{% block title %}{% trans "Volumes" %}{% endblock %} - -{% block main %} -
-
- {{ tab_group.render }} -
-
-{% endblock %} diff --git a/openstack_dashboard/dashboards/admin/volumes/templates/volumes/volumes/manage_volume.html b/openstack_dashboard/dashboards/admin/volumes/templates/volumes/manage_volume.html similarity index 100% rename from openstack_dashboard/dashboards/admin/volumes/templates/volumes/volumes/manage_volume.html rename to openstack_dashboard/dashboards/admin/volumes/templates/volumes/manage_volume.html diff --git a/openstack_dashboard/dashboards/admin/volumes/templates/volumes/volumes/migrate_volume.html b/openstack_dashboard/dashboards/admin/volumes/templates/volumes/migrate_volume.html similarity index 100% rename from openstack_dashboard/dashboards/admin/volumes/templates/volumes/volumes/migrate_volume.html rename to openstack_dashboard/dashboards/admin/volumes/templates/volumes/migrate_volume.html diff --git a/openstack_dashboard/dashboards/admin/volumes/templates/volumes/volumes/unmanage_volume.html b/openstack_dashboard/dashboards/admin/volumes/templates/volumes/unmanage_volume.html similarity index 100% rename from openstack_dashboard/dashboards/admin/volumes/templates/volumes/volumes/unmanage_volume.html rename to openstack_dashboard/dashboards/admin/volumes/templates/volumes/unmanage_volume.html diff --git a/openstack_dashboard/dashboards/admin/volumes/templates/volumes/volumes/update_status.html b/openstack_dashboard/dashboards/admin/volumes/templates/volumes/update_status.html similarity index 100% rename from openstack_dashboard/dashboards/admin/volumes/templates/volumes/volumes/update_status.html rename to openstack_dashboard/dashboards/admin/volumes/templates/volumes/update_status.html diff --git a/openstack_dashboard/dashboards/admin/volumes/templates/volumes/volumes/volumes_tables.html b/openstack_dashboard/dashboards/admin/volumes/templates/volumes/volumes_tables.html similarity index 100% rename from openstack_dashboard/dashboards/admin/volumes/templates/volumes/volumes/volumes_tables.html rename to openstack_dashboard/dashboards/admin/volumes/templates/volumes/volumes_tables.html diff --git a/openstack_dashboard/dashboards/admin/volumes/tests.py b/openstack_dashboard/dashboards/admin/volumes/tests.py index bb8883512e..14618600b0 100644 --- a/openstack_dashboard/dashboards/admin/volumes/tests.py +++ b/openstack_dashboard/dashboards/admin/volumes/tests.py @@ -28,6 +28,8 @@ from openstack_dashboard.dashboards.project.volumes \ import tables as volume_tables from openstack_dashboard.test import helpers as test +from openstack_dashboard.dashboards.admin.snapshots import forms + INDEX_URL = reverse('horizon:admin:volumes:index') @@ -72,7 +74,7 @@ class VolumeTests(test.BaseAdminViewTests): self.mox.ReplayAll() res = self.client.get(INDEX_URL) - self.assertTemplateUsed(res, 'admin/volumes/index.html') + self.assertTemplateUsed(res, 'horizon/common/_data_table_view.html') volumes = res.context['volumes_table'].data self.assertItemsEqual(volumes, self.cinder_volumes.list()) @@ -108,7 +110,7 @@ class VolumeTests(test.BaseAdminViewTests): res = self.client.get(urlunquote(url)) - self.assertTemplateUsed(res, 'admin/volumes/index.html') + self.assertTemplateUsed(res, 'horizon/common/_data_table_view.html') self.assertEqual(res.status_code, 200) self.mox.UnsetStubs() @@ -117,7 +119,7 @@ class VolumeTests(test.BaseAdminViewTests): @override_settings(FILTER_DATA_FIRST={'admin.volumes': True}) def test_volumes_tab_with_admin_filter_first(self): res = self.client.get(INDEX_URL) - self.assertTemplateUsed(res, 'admin/volumes/index.html') + self.assertTemplateUsed(res, 'horizon/common/_data_table_view.html') volumes = res.context['volumes_table'].data self.assertItemsEqual(volumes, []) @@ -147,7 +149,7 @@ class VolumeTests(test.BaseAdminViewTests): expected_volumes = mox_volumes[size:2 * size] marker = expected_volumes[0].id next = volume_tables.VolumesTable._meta.pagination_param - url = "?".join([INDEX_URL, "=".join([next, marker])]) + url = INDEX_URL + "?%s=%s" % (next, marker) res = self._test_index_paginated(marker=marker, sort_dir="desc", volumes=expected_volumes, url=url, has_more=True, has_prev=True) @@ -158,7 +160,7 @@ class VolumeTests(test.BaseAdminViewTests): expected_volumes = mox_volumes[-size:] marker = expected_volumes[0].id next = volume_tables.VolumesTable._meta.pagination_param - url = "?".join([INDEX_URL, "=".join([next, marker])]) + url = INDEX_URL + "?%s=%s" % (next, marker) res = self._test_index_paginated(marker=marker, sort_dir="desc", volumes=expected_volumes, url=url, has_more=False, has_prev=True) @@ -174,7 +176,7 @@ class VolumeTests(test.BaseAdminViewTests): expected_volumes = mox_volumes[size:2 * size] marker = mox_volumes[0].id prev = volume_tables.VolumesTable._meta.prev_pagination_param - url = "?".join([INDEX_URL, "=".join([prev, marker])]) + url = INDEX_URL + "?%s=%s" % (prev, marker) res = self._test_index_paginated(marker=marker, sort_dir="asc", volumes=expected_volumes, url=url, has_more=False, has_prev=True) @@ -185,9 +187,210 @@ class VolumeTests(test.BaseAdminViewTests): expected_volumes = mox_volumes[:size] marker = mox_volumes[0].id prev = volume_tables.VolumesTable._meta.prev_pagination_param - url = "?".join([INDEX_URL, "=".join([prev, marker])]) + url = INDEX_URL + "?%s=%s" % (prev, marker) res = self._test_index_paginated(marker=marker, sort_dir="asc", volumes=expected_volumes, url=url, has_more=True, has_prev=False) volumes = res.context['volumes_table'].data self.assertItemsEqual(volumes, expected_volumes) + + @test.create_stubs({cinder: ('volume_reset_state', + 'volume_get')}) + def test_update_volume_status(self): + volume = self.volumes.first() + formData = {'status': 'error'} + + cinder.volume_get(IsA(http.HttpRequest), volume.id).AndReturn(volume) + cinder.volume_reset_state(IsA(http.HttpRequest), + volume.id, + formData['status']) + self.mox.ReplayAll() + + res = self.client.post( + reverse('horizon:admin:volumes:update_status', + args=(volume.id,)), + formData) + self.assertNoFormErrors(res) + + @test.create_stubs({cinder: ('volume_manage', + 'volume_type_list', + 'availability_zone_list', + 'extension_supported')}) + def test_manage_volume(self): + metadata = {'key': u'k1', + 'value': u'v1'} + formData = {'host': 'host-1', + 'identifier': 'vol-1', + 'id_type': u'source-name', + 'name': 'name-1', + 'description': 'manage a volume', + 'volume_type': 'vol_type_1', + 'availability_zone': 'nova', + 'metadata': metadata['key'] + '=' + metadata['value'], + 'bootable': False} + cinder.volume_type_list( + IsA(http.HttpRequest)). \ + AndReturn(self.cinder_volume_types.list()) + cinder.availability_zone_list( + IsA(http.HttpRequest)). \ + AndReturn(self.availability_zones.list()) + cinder.extension_supported( + IsA(http.HttpRequest), + 'AvailabilityZones'). \ + AndReturn(True) + cinder.volume_manage( + IsA(http.HttpRequest), + host=formData['host'], + identifier=formData['identifier'], + id_type=formData['id_type'], + name=formData['name'], + description=formData['description'], + volume_type=formData['volume_type'], + availability_zone=formData['availability_zone'], + metadata={metadata['key']: metadata['value']}, + bootable=formData['bootable']) + self.mox.ReplayAll() + res = self.client.post( + reverse('horizon:admin:volumes:manage'), + formData) + self.assertNoFormErrors(res) + + @test.create_stubs({cinder: ('volume_unmanage', + 'volume_get')}) + def test_unmanage_volume(self): + # important - need to get the v2 cinder volume which has host data + volume_list = [x for x in self.cinder_volumes.list() + if x.name == 'v2_volume'] + volume = volume_list[0] + formData = {'volume_name': volume.name, + 'host_name': 'host@backend-name#pool', + 'volume_id': volume.id} + + cinder.volume_get(IsA(http.HttpRequest), volume.id).AndReturn(volume) + cinder.volume_unmanage(IsA(http.HttpRequest), volume.id). \ + AndReturn(volume) + self.mox.ReplayAll() + res = self.client.post( + reverse('horizon:admin:volumes:unmanage', + args=(volume.id,)), + formData) + self.assertNoFormErrors(res) + + @test.create_stubs({cinder: ('pool_list', + 'volume_get',)}) + def test_volume_migrate_get(self): + volume = self.cinder_volumes.get(name='v2_volume') + cinder.volume_get(IsA(http.HttpRequest), volume.id) \ + .AndReturn(volume) + cinder.pool_list(IsA(http.HttpRequest)) \ + .AndReturn(self.cinder_pools.list()) + + self.mox.ReplayAll() + + url = reverse('horizon:admin:volumes:migrate', + args=[volume.id]) + res = self.client.get(url) + + self.assertTemplateUsed(res, + 'admin/volumes/migrate_volume.html') + + @test.create_stubs({cinder: ('volume_get',)}) + def test_volume_migrate_get_volume_get_exception(self): + volume = self.cinder_volumes.get(name='v2_volume') + cinder.volume_get(IsA(http.HttpRequest), volume.id) \ + .AndRaise(self.exceptions.cinder) + + self.mox.ReplayAll() + + url = reverse('horizon:admin:volumes:migrate', + args=[volume.id]) + res = self.client.get(url) + + self.assertRedirectsNoFollow(res, INDEX_URL) + + @test.create_stubs({cinder: ('pool_list', + 'volume_get',)}) + def test_volume_migrate_list_pool_get_exception(self): + volume = self.cinder_volumes.get(name='v2_volume') + cinder.volume_get(IsA(http.HttpRequest), volume.id) \ + .AndReturn(volume) + cinder.pool_list(IsA(http.HttpRequest)) \ + .AndRaise(self.exceptions.cinder) + + self.mox.ReplayAll() + url = reverse('horizon:admin:volumes:migrate', + args=[volume.id]) + res = self.client.get(url) + + self.assertRedirectsNoFollow(res, INDEX_URL) + + @test.create_stubs({cinder: ('pool_list', + 'volume_get', + 'volume_migrate',)}) + def test_volume_migrate_post(self): + volume = self.cinder_volumes.get(name='v2_volume') + host = self.cinder_pools.first().name + + cinder.volume_get(IsA(http.HttpRequest), volume.id) \ + .AndReturn(volume) + cinder.pool_list(IsA(http.HttpRequest)) \ + .AndReturn(self.cinder_pools.list()) + cinder.volume_migrate(IsA(http.HttpRequest), + volume.id, + host, + False) \ + .AndReturn(None) + + self.mox.ReplayAll() + + url = reverse('horizon:admin:volumes:migrate', + args=[volume.id]) + res = self.client.post(url, {'host': host, 'volume_id': volume.id}) + self.assertNoFormErrors(res) + self.assertRedirectsNoFollow(res, INDEX_URL) + + @test.create_stubs({cinder: ('pool_list', + 'volume_get', + 'volume_migrate',)}) + def test_volume_migrate_post_api_exception(self): + volume = self.cinder_volumes.get(name='v2_volume') + host = self.cinder_pools.first().name + + cinder.volume_get(IsA(http.HttpRequest), volume.id) \ + .AndReturn(volume) + cinder.pool_list(IsA(http.HttpRequest)) \ + .AndReturn(self.cinder_pools.list()) + cinder.volume_migrate(IsA(http.HttpRequest), + volume.id, + host, + False) \ + .AndRaise(self.exceptions.cinder) + + self.mox.ReplayAll() + + url = reverse('horizon:admin:volumes:migrate', + args=[volume.id]) + res = self.client.post(url, {'host': host, 'volume_id': volume.id}) + self.assertRedirectsNoFollow(res, INDEX_URL) + + def test_get_volume_status_choices_without_current(self): + current_status = {'status': 'available'} + status_choices = forms.populate_status_choices(current_status, + forms.STATUS_CHOICES) + self.assertEqual(len(status_choices), len(forms.STATUS_CHOICES)) + self.assertNotIn(current_status['status'], + [status[0] for status in status_choices]) + + @test.create_stubs({cinder: ('volume_get',)}) + def test_update_volume_status_get(self): + volume = self.cinder_volumes.get(name='v2_volume') + cinder.volume_get(IsA(http.HttpRequest), volume.id) \ + .AndReturn(volume) + + self.mox.ReplayAll() + + url = reverse('horizon:admin:volumes:update_status', + args=[volume.id]) + res = self.client.get(url) + status_option = "" % volume.status + self.assertNotContains(res, status_option) diff --git a/openstack_dashboard/dashboards/admin/volumes/urls.py b/openstack_dashboard/dashboards/admin/volumes/urls.py index a9cf84977e..74ee26e819 100644 --- a/openstack_dashboard/dashboards/admin/volumes/urls.py +++ b/openstack_dashboard/dashboards/admin/volumes/urls.py @@ -10,20 +10,28 @@ # License for the specific language governing permissions and limitations # under the License. -from django.conf.urls import include from django.conf.urls import url from openstack_dashboard.dashboards.admin.volumes import views -from openstack_dashboard.dashboards.admin.volumes.volumes \ - import urls as volumes_urls + urlpatterns = [ url(r'^$', - views.IndexView.as_view(), + views.VolumesView.as_view(), name='index'), - url(r'^\?tab=volumes_group_tabs__volumes_tab$', - views.IndexView.as_view(), - name='volumes_tab'), - url(r'', - include(volumes_urls, namespace='volumes')), + url(r'^manage/$', + views.ManageVolumeView.as_view(), + name='manage'), + url(r'^(?P[^/]+)/$', + views.DetailView.as_view(), + name='detail'), + url(r'^(?P[^/]+)/update_status$', + views.UpdateStatusView.as_view(), + name='update_status'), + url(r'^(?P[^/]+)/unmanage$', + views.UnmanageVolumeView.as_view(), + name='unmanage'), + url(r'^(?P[^/]+)/migrate$', + views.MigrateVolumeView.as_view(), + name='migrate'), ] diff --git a/openstack_dashboard/dashboards/admin/volumes/views.py b/openstack_dashboard/dashboards/admin/volumes/views.py index 9f872cb53d..b30b7cf0fb 100644 --- a/openstack_dashboard/dashboards/admin/volumes/views.py +++ b/openstack_dashboard/dashboards/admin/volumes/views.py @@ -15,15 +15,229 @@ """ Admin views for managing volumes and snapshots. """ +from collections import OrderedDict +from django.conf import settings +from django.core.urlresolvers import reverse +from django.core.urlresolvers import reverse_lazy from django.utils.translation import ugettext_lazy as _ -from horizon import tabs +from horizon import exceptions +from horizon import forms +from horizon import tables +from horizon.utils import memoized +from openstack_dashboard.api import cinder +from openstack_dashboard.api import keystone from openstack_dashboard.dashboards.admin.volumes \ - import tabs as volumes_tabs + import forms as volumes_forms +from openstack_dashboard.dashboards.admin.volumes \ + import tables as volumes_tables +from openstack_dashboard.dashboards.project.volumes \ + import views as volumes_views -class IndexView(tabs.TabbedTableView): - tab_group_class = volumes_tabs.VolumesGroupTabs - template_name = 'admin/volumes/index.html' +class VolumesView(tables.PagedTableMixin, volumes_views.VolumeTableMixIn, + tables.DataTableView): + table_class = volumes_tables.VolumesTable page_title = _("Volumes") + + FILTERS_MAPPING = {'bootable': {_('yes'): 'true', _('no'): 'false'}, + 'encrypted': {_('yes'): True, _('no'): False}} + + def get_data(self): + default_filters = {'all_tenants': True} + + filters = self.get_filters(default_filters.copy()) + filter_first = getattr(settings, 'FILTER_DATA_FIRST', {}) + volumes = [] + + self.table.needs_filter_first = False + + if filter_first.get('admin.volumes', False) and \ + len(filters) == len(default_filters): + self.table.needs_filter_first = True + return volumes + + if 'project' in filters: + # Keystone returns a tuple ([],false) where the first element is + # tenant list that's why the 0 is hardcoded below + tenants = keystone.tenant_list(self.request)[0] + tenant_ids = [t.id for t in tenants + if t.name == filters['project']] + if not tenant_ids: + return [] + del filters['project'] + for id in tenant_ids: + filters['project_id'] = id + volumes += self._get_volumes(search_opts=filters) + else: + volumes = self._get_volumes(search_opts=filters) + + attached_instance_ids = self._get_attached_instance_ids(volumes) + instances = self._get_instances(search_opts={'all_tenants': True}, + instance_ids=attached_instance_ids) + volume_ids_with_snapshots = self._get_volumes_ids_with_snapshots( + search_opts={'all_tenants': True}) + self._set_volume_attributes( + volumes, instances, volume_ids_with_snapshots) + + # Gather our tenants to correlate against IDs + try: + tenants, has_more = keystone.tenant_list(self.request) + except Exception: + tenants = [] + msg = _('Unable to retrieve volume project information.') + exceptions.handle(self.request, msg) + + tenant_dict = OrderedDict([(t.id, t) for t in tenants]) + for volume in volumes: + tenant_id = getattr(volume, "os-vol-tenant-attr:tenant_id", None) + tenant = tenant_dict.get(tenant_id, None) + volume.tenant_name = getattr(tenant, "name", None) + + return volumes + + def get_filters(self, filters): + self.table = self._tables['volumes'] + self.handle_server_filter(self.request, table=self.table) + self.update_server_filter_action(self.request, table=self.table) + filters = super(VolumesView, self).get_filters(filters, + self.FILTERS_MAPPING) + return filters + + +class DetailView(volumes_views.DetailView): + def get_context_data(self, **kwargs): + context = super(DetailView, self).get_context_data(**kwargs) + table = volumes_tables.VolumesTable(self.request) + context["actions"] = table.render_row_actions(context["volume"]) + return context + + def get_redirect_url(self): + return reverse('horizon:admin:volumes:index') + + +class ManageVolumeView(forms.ModalFormView): + form_class = volumes_forms.ManageVolume + template_name = 'admin/volumes/manage_volume.html' + form_id = "manage_volume_modal" + submit_label = _("Manage") + success_url = reverse_lazy('horizon:admin:volumes:index') + submit_url = reverse_lazy('horizon:admin:volumes:manage') + cancel_url = reverse_lazy("horizon:admin:volumes:index") + page_title = _("Manage Volume") + + def get_context_data(self, **kwargs): + context = super(ManageVolumeView, self).get_context_data(**kwargs) + return context + + +class UnmanageVolumeView(forms.ModalFormView): + form_class = volumes_forms.UnmanageVolume + template_name = 'admin/volumes/unmanage_volume.html' + form_id = "unmanage_volume_modal" + submit_label = _("Unmanage") + success_url = reverse_lazy('horizon:admin:volumes:index') + submit_url = 'horizon:admin:volumes:unmanage' + cancel_url = reverse_lazy("horizon:admin:volumes:index") + page_title = _("Unmanage Volume") + + def get_context_data(self, **kwargs): + context = super(UnmanageVolumeView, self).get_context_data(**kwargs) + args = (self.kwargs['volume_id'],) + context['submit_url'] = reverse(self.submit_url, args=args) + return context + + @memoized.memoized_method + def get_data(self): + try: + volume_id = self.kwargs['volume_id'] + volume = cinder.volume_get(self.request, volume_id) + except Exception: + exceptions.handle(self.request, + _('Unable to retrieve volume details.'), + redirect=self.success_url) + return volume + + def get_initial(self): + volume = self.get_data() + return {'volume_id': self.kwargs["volume_id"], + 'name': volume.name, + 'host': getattr(volume, "os-vol-host-attr:host")} + + +class MigrateVolumeView(forms.ModalFormView): + form_class = volumes_forms.MigrateVolume + template_name = 'admin/volumes/migrate_volume.html' + form_id = "migrate_volume_modal" + submit_label = _("Migrate") + success_url = reverse_lazy('horizon:admin:volumes:index') + submit_url = 'horizon:admin:volumes:migrate' + cancel_url = reverse_lazy("horizon:admin:volumes:index") + page_title = _("Migrate Volume") + + def get_context_data(self, **kwargs): + context = super(MigrateVolumeView, self).get_context_data(**kwargs) + args = (self.kwargs['volume_id'],) + context['submit_url'] = reverse(self.submit_url, args=args) + return context + + @memoized.memoized_method + def get_data(self): + try: + volume_id = self.kwargs['volume_id'] + volume = cinder.volume_get(self.request, volume_id) + except Exception: + exceptions.handle(self.request, + _('Unable to retrieve volume details.'), + redirect=self.success_url) + return volume + + @memoized.memoized_method + def get_hosts(self): + try: + return cinder.pool_list(self.request) + except Exception: + exceptions.handle(self.request, + _('Unable to retrieve pools information.'), + redirect=self.success_url) + + def get_initial(self): + volume = self.get_data() + return {'volume_id': self.kwargs["volume_id"], + 'name': volume.name, + 'current_host': getattr(volume, "os-vol-host-attr:host"), + 'hosts': self.get_hosts()} + + +class UpdateStatusView(forms.ModalFormView): + form_class = volumes_forms.UpdateStatus + modal_id = "update_volume_status_modal" + template_name = 'admin/volumes/update_status.html' + submit_label = _("Update Status") + submit_url = "horizon:admin:volumes:update_status" + success_url = reverse_lazy('horizon:admin:volumes:index') + page_title = _("Update Volume Status") + + def get_context_data(self, **kwargs): + context = super(UpdateStatusView, self).get_context_data(**kwargs) + context["volume_id"] = self.kwargs['volume_id'] + args = (self.kwargs['volume_id'],) + context['submit_url'] = reverse(self.submit_url, args=args) + return context + + @memoized.memoized_method + def get_data(self): + try: + volume_id = self.kwargs['volume_id'] + volume = cinder.volume_get(self.request, volume_id) + except Exception: + exceptions.handle(self.request, + _('Unable to retrieve volume details.'), + redirect=self.success_url) + return volume + + def get_initial(self): + volume = self.get_data() + return {'volume_id': self.kwargs["volume_id"], + 'status': volume.status} diff --git a/openstack_dashboard/dashboards/admin/volumes/volumes/__init__.py b/openstack_dashboard/dashboards/admin/volumes/volumes/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/openstack_dashboard/dashboards/admin/volumes/volumes/tests.py b/openstack_dashboard/dashboards/admin/volumes/volumes/tests.py deleted file mode 100644 index 7ba3cd03be..0000000000 --- a/openstack_dashboard/dashboards/admin/volumes/volumes/tests.py +++ /dev/null @@ -1,235 +0,0 @@ -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. - -from django.core.urlresolvers import reverse -from django import http -from mox3.mox import IsA # noqa - -from openstack_dashboard.api import cinder -from openstack_dashboard.test import helpers as test - -from openstack_dashboard.dashboards.admin.snapshots import forms - -INDEX_URL = reverse('horizon:admin:volumes:volumes_tab') - - -class VolumeViewTests(test.BaseAdminViewTests): - def tearDown(self): - for volume in self.cinder_volumes.list(): - # VolumeTableMixIn._set_volume_attributes mutates data - # and cinder_volumes.list() doesn't deep copy - for att in volume.attachments: - if 'instance' in att: - del att['instance'] - super(VolumeViewTests, self).tearDown() - - @test.create_stubs({cinder: ('volume_reset_state', - 'volume_get')}) - def test_update_volume_status(self): - volume = self.volumes.first() - formData = {'status': 'error'} - - cinder.volume_get(IsA(http.HttpRequest), volume.id).AndReturn(volume) - cinder.volume_reset_state(IsA(http.HttpRequest), - volume.id, - formData['status']) - self.mox.ReplayAll() - - res = self.client.post( - reverse('horizon:admin:volumes:volumes:update_status', - args=(volume.id,)), - formData) - self.assertNoFormErrors(res) - - @test.create_stubs({cinder: ('volume_manage', - 'volume_type_list', - 'availability_zone_list', - 'extension_supported')}) - def test_manage_volume(self): - metadata = {'key': u'k1', - 'value': u'v1'} - formData = {'host': 'host-1', - 'identifier': 'vol-1', - 'id_type': u'source-name', - 'name': 'name-1', - 'description': 'manage a volume', - 'volume_type': 'vol_type_1', - 'availability_zone': 'nova', - 'metadata': metadata['key'] + '=' + metadata['value'], - 'bootable': False} - cinder.volume_type_list( - IsA(http.HttpRequest)).\ - AndReturn(self.cinder_volume_types.list()) - cinder.availability_zone_list( - IsA(http.HttpRequest)).\ - AndReturn(self.availability_zones.list()) - cinder.extension_supported( - IsA(http.HttpRequest), - 'AvailabilityZones').\ - AndReturn(True) - cinder.volume_manage( - IsA(http.HttpRequest), - host=formData['host'], - identifier=formData['identifier'], - id_type=formData['id_type'], - name=formData['name'], - description=formData['description'], - volume_type=formData['volume_type'], - availability_zone=formData['availability_zone'], - metadata={metadata['key']: metadata['value']}, - bootable=formData['bootable']) - self.mox.ReplayAll() - res = self.client.post( - reverse('horizon:admin:volumes:volumes:manage'), - formData) - self.assertNoFormErrors(res) - - @test.create_stubs({cinder: ('volume_unmanage', - 'volume_get')}) - def test_unmanage_volume(self): - # important - need to get the v2 cinder volume which has host data - volume_list = [x for x in self.cinder_volumes.list() - if x.name == 'v2_volume'] - volume = volume_list[0] - formData = {'volume_name': volume.name, - 'host_name': 'host@backend-name#pool', - 'volume_id': volume.id} - - cinder.volume_get(IsA(http.HttpRequest), volume.id).AndReturn(volume) - cinder.volume_unmanage(IsA(http.HttpRequest), volume.id).\ - AndReturn(volume) - self.mox.ReplayAll() - res = self.client.post( - reverse('horizon:admin:volumes:volumes:unmanage', - args=(volume.id,)), - formData) - self.assertNoFormErrors(res) - - @test.create_stubs({cinder: ('pool_list', - 'volume_get',)}) - def test_volume_migrate_get(self): - volume = self.cinder_volumes.get(name='v2_volume') - cinder.volume_get(IsA(http.HttpRequest), volume.id) \ - .AndReturn(volume) - cinder.pool_list(IsA(http.HttpRequest)) \ - .AndReturn(self.cinder_pools.list()) - - self.mox.ReplayAll() - - url = reverse('horizon:admin:volumes:volumes:migrate', - args=[volume.id]) - res = self.client.get(url) - - self.assertTemplateUsed(res, - 'admin/volumes/volumes/migrate_volume.html') - - @test.create_stubs({cinder: ('volume_get',)}) - def test_volume_migrate_get_volume_get_exception(self): - volume = self.cinder_volumes.get(name='v2_volume') - cinder.volume_get(IsA(http.HttpRequest), volume.id) \ - .AndRaise(self.exceptions.cinder) - - self.mox.ReplayAll() - - url = reverse('horizon:admin:volumes:volumes:migrate', - args=[volume.id]) - res = self.client.get(url) - - self.assertRedirectsNoFollow(res, INDEX_URL) - - @test.create_stubs({cinder: ('pool_list', - 'volume_get',)}) - def test_volume_migrate_list_pool_get_exception(self): - volume = self.cinder_volumes.get(name='v2_volume') - cinder.volume_get(IsA(http.HttpRequest), volume.id) \ - .AndReturn(volume) - cinder.pool_list(IsA(http.HttpRequest)) \ - .AndRaise(self.exceptions.cinder) - - self.mox.ReplayAll() - url = reverse('horizon:admin:volumes:volumes:migrate', - args=[volume.id]) - res = self.client.get(url) - - self.assertRedirectsNoFollow(res, INDEX_URL) - - @test.create_stubs({cinder: ('pool_list', - 'volume_get', - 'volume_migrate',)}) - def test_volume_migrate_post(self): - volume = self.cinder_volumes.get(name='v2_volume') - host = self.cinder_pools.first().name - - cinder.volume_get(IsA(http.HttpRequest), volume.id) \ - .AndReturn(volume) - cinder.pool_list(IsA(http.HttpRequest)) \ - .AndReturn(self.cinder_pools.list()) - cinder.volume_migrate(IsA(http.HttpRequest), - volume.id, - host, - False) \ - .AndReturn(None) - - self.mox.ReplayAll() - - url = reverse('horizon:admin:volumes:volumes:migrate', - args=[volume.id]) - res = self.client.post(url, {'host': host, 'volume_id': volume.id}) - self.assertNoFormErrors(res) - self.assertRedirectsNoFollow(res, INDEX_URL) - - @test.create_stubs({cinder: ('pool_list', - 'volume_get', - 'volume_migrate',)}) - def test_volume_migrate_post_api_exception(self): - volume = self.cinder_volumes.get(name='v2_volume') - host = self.cinder_pools.first().name - - cinder.volume_get(IsA(http.HttpRequest), volume.id) \ - .AndReturn(volume) - cinder.pool_list(IsA(http.HttpRequest)) \ - .AndReturn(self.cinder_pools.list()) - cinder.volume_migrate(IsA(http.HttpRequest), - volume.id, - host, - False) \ - .AndRaise(self.exceptions.cinder) - - self.mox.ReplayAll() - - url = reverse('horizon:admin:volumes:volumes:migrate', - args=[volume.id]) - res = self.client.post(url, {'host': host, 'volume_id': volume.id}) - self.assertRedirectsNoFollow(res, INDEX_URL) - - def test_get_volume_status_choices_without_current(self): - current_status = {'status': 'available'} - status_choices = forms.populate_status_choices(current_status, - forms.STATUS_CHOICES) - self.assertEqual(len(status_choices), len(forms.STATUS_CHOICES)) - self.assertNotIn(current_status['status'], - [status[0] for status in status_choices]) - - @test.create_stubs({cinder: ('volume_get',)}) - def test_update_volume_status_get(self): - volume = self.cinder_volumes.get(name='v2_volume') - cinder.volume_get(IsA(http.HttpRequest), volume.id) \ - .AndReturn(volume) - - self.mox.ReplayAll() - - url = reverse('horizon:admin:volumes:volumes:update_status', - args=[volume.id]) - res = self.client.get(url) - status_option = "" % volume.status - self.assertNotContains(res, status_option) diff --git a/openstack_dashboard/dashboards/admin/volumes/volumes/urls.py b/openstack_dashboard/dashboards/admin/volumes/volumes/urls.py deleted file mode 100644 index 279c4f3902..0000000000 --- a/openstack_dashboard/dashboards/admin/volumes/volumes/urls.py +++ /dev/null @@ -1,34 +0,0 @@ -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. - -from django.conf.urls import url - -from openstack_dashboard.dashboards.admin.volumes.volumes \ - import views - -urlpatterns = [ - url(r'^manage/$', - views.ManageVolumeView.as_view(), - name='manage'), - url(r'^(?P[^/]+)/$', - views.DetailView.as_view(), - name='detail'), - url(r'^(?P[^/]+)/update_status$', - views.UpdateStatusView.as_view(), - name='update_status'), - url(r'^(?P[^/]+)/unmanage$', - views.UnmanageVolumeView.as_view(), - name='unmanage'), - url(r'^(?P[^/]+)/migrate$', - views.MigrateVolumeView.as_view(), - name='migrate'), -] diff --git a/openstack_dashboard/dashboards/admin/volumes/volumes/views.py b/openstack_dashboard/dashboards/admin/volumes/volumes/views.py deleted file mode 100644 index 490b938242..0000000000 --- a/openstack_dashboard/dashboards/admin/volumes/volumes/views.py +++ /dev/null @@ -1,164 +0,0 @@ -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. - -from django.core.urlresolvers import reverse -from django.core.urlresolvers import reverse_lazy -from django.utils.translation import ugettext_lazy as _ - -from horizon import exceptions -from horizon import forms -from horizon.utils import memoized - -from openstack_dashboard.api import cinder -from openstack_dashboard.dashboards.admin.volumes.volumes \ - import forms as volumes_forms -from openstack_dashboard.dashboards.admin.volumes.volumes \ - import tables as volumes_tables -from openstack_dashboard.dashboards.project.volumes \ - import views as volumes_views - - -class DetailView(volumes_views.DetailView): - def get_context_data(self, **kwargs): - context = super(DetailView, self).get_context_data(**kwargs) - table = volumes_tables.VolumesTable(self.request) - context["actions"] = table.render_row_actions(context["volume"]) - return context - - def get_redirect_url(self): - return reverse('horizon:admin:volumes:index') - - -class ManageVolumeView(forms.ModalFormView): - form_class = volumes_forms.ManageVolume - template_name = 'admin/volumes/volumes/manage_volume.html' - form_id = "manage_volume_modal" - submit_label = _("Manage") - success_url = reverse_lazy('horizon:admin:volumes:volumes_tab') - submit_url = reverse_lazy('horizon:admin:volumes:volumes:manage') - cancel_url = reverse_lazy("horizon:admin:volumes:index") - page_title = _("Manage Volume") - - def get_context_data(self, **kwargs): - context = super(ManageVolumeView, self).get_context_data(**kwargs) - return context - - -class UnmanageVolumeView(forms.ModalFormView): - form_class = volumes_forms.UnmanageVolume - template_name = 'admin/volumes/volumes/unmanage_volume.html' - form_id = "unmanage_volume_modal" - submit_label = _("Unmanage") - success_url = reverse_lazy('horizon:admin:volumes:volumes_tab') - submit_url = 'horizon:admin:volumes:volumes:unmanage' - cancel_url = reverse_lazy("horizon:admin:volumes:index") - page_title = _("Unmanage Volume") - - def get_context_data(self, **kwargs): - context = super(UnmanageVolumeView, self).get_context_data(**kwargs) - args = (self.kwargs['volume_id'],) - context['submit_url'] = reverse(self.submit_url, args=args) - return context - - @memoized.memoized_method - def get_data(self): - try: - volume_id = self.kwargs['volume_id'] - volume = cinder.volume_get(self.request, volume_id) - except Exception: - exceptions.handle(self.request, - _('Unable to retrieve volume details.'), - redirect=self.success_url) - return volume - - def get_initial(self): - volume = self.get_data() - return {'volume_id': self.kwargs["volume_id"], - 'name': volume.name, - 'host': getattr(volume, "os-vol-host-attr:host")} - - -class MigrateVolumeView(forms.ModalFormView): - form_class = volumes_forms.MigrateVolume - template_name = 'admin/volumes/volumes/migrate_volume.html' - form_id = "migrate_volume_modal" - submit_label = _("Migrate") - success_url = reverse_lazy('horizon:admin:volumes:volumes_tab') - submit_url = 'horizon:admin:volumes:volumes:migrate' - cancel_url = reverse_lazy("horizon:admin:volumes:index") - page_title = _("Migrate Volume") - - def get_context_data(self, **kwargs): - context = super(MigrateVolumeView, self).get_context_data(**kwargs) - args = (self.kwargs['volume_id'],) - context['submit_url'] = reverse(self.submit_url, args=args) - return context - - @memoized.memoized_method - def get_data(self): - try: - volume_id = self.kwargs['volume_id'] - volume = cinder.volume_get(self.request, volume_id) - except Exception: - exceptions.handle(self.request, - _('Unable to retrieve volume details.'), - redirect=self.success_url) - return volume - - @memoized.memoized_method - def get_hosts(self): - try: - return cinder.pool_list(self.request) - except Exception: - exceptions.handle(self.request, - _('Unable to retrieve pools information.'), - redirect=self.success_url) - - def get_initial(self): - volume = self.get_data() - return {'volume_id': self.kwargs["volume_id"], - 'name': volume.name, - 'current_host': getattr(volume, "os-vol-host-attr:host"), - 'hosts': self.get_hosts()} - - -class UpdateStatusView(forms.ModalFormView): - form_class = volumes_forms.UpdateStatus - modal_id = "update_volume_status_modal" - template_name = 'admin/volumes/volumes/update_status.html' - submit_label = _("Update Status") - submit_url = "horizon:admin:volumes:volumes:update_status" - success_url = reverse_lazy('horizon:admin:volumes:index') - page_title = _("Update Volume Status") - - def get_context_data(self, **kwargs): - context = super(UpdateStatusView, self).get_context_data(**kwargs) - context["volume_id"] = self.kwargs['volume_id'] - args = (self.kwargs['volume_id'],) - context['submit_url'] = reverse(self.submit_url, args=args) - return context - - @memoized.memoized_method - def get_data(self): - try: - volume_id = self.kwargs['volume_id'] - volume = cinder.volume_get(self.request, volume_id) - except Exception: - exceptions.handle(self.request, - _('Unable to retrieve volume details.'), - redirect=self.success_url) - return volume - - def get_initial(self): - volume = self.get_data() - return {'volume_id': self.kwargs["volume_id"], - 'status': volume.status} diff --git a/openstack_dashboard/enabled/_2070_admin_volumes_panel.py b/openstack_dashboard/enabled/_2320_admin_volumes_panel.py similarity index 93% rename from openstack_dashboard/enabled/_2070_admin_volumes_panel.py rename to openstack_dashboard/enabled/_2320_admin_volumes_panel.py index 204762076c..ebc979db08 100644 --- a/openstack_dashboard/enabled/_2070_admin_volumes_panel.py +++ b/openstack_dashboard/enabled/_2320_admin_volumes_panel.py @@ -3,7 +3,7 @@ PANEL = 'volumes' # The slug of the dashboard the PANEL associated with. Required. PANEL_DASHBOARD = 'admin' # The slug of the panel group the PANEL is associated with. -PANEL_GROUP = 'admin' +PANEL_GROUP = 'volumes' # Python panel class of the PANEL to be added. ADD_PANEL = 'openstack_dashboard.dashboards.admin.volumes.panel.Volumes'