summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorZuul <zuul@review.opendev.org>2021-03-09 18:51:38 +0000
committerGerrit Code Review <review@openstack.org>2021-03-09 18:51:38 +0000
commit3ea81ff5932664b7cb99a06e40785aa355eb995a (patch)
tree0675604836676d039a25dc482e5c759288f532b5
parent87edcb619394aebdd022683441f66536f7d3a9c0 (diff)
parent1d39ac761f0926d790063fda7e8f5491fdfc3b37 (diff)
downloadhorizon-3ea81ff5932664b7cb99a06e40785aa355eb995a.tar.gz
Merge "Add Volume backups support for admin panel"
-rw-r--r--openstack_dashboard/api/cinder.py28
-rw-r--r--openstack_dashboard/dashboards/admin/backups/__init__.py0
-rw-r--r--openstack_dashboard/dashboards/admin/backups/forms.py72
-rw-r--r--openstack_dashboard/dashboards/admin/backups/panel.py18
-rw-r--r--openstack_dashboard/dashboards/admin/backups/tables.py119
-rw-r--r--openstack_dashboard/dashboards/admin/backups/tabs.py23
-rw-r--r--openstack_dashboard/dashboards/admin/backups/templates/backups/_detail_overview.html62
-rw-r--r--openstack_dashboard/dashboards/admin/backups/templates/backups/_update_status.html11
-rw-r--r--openstack_dashboard/dashboards/admin/backups/templates/backups/update_status.html7
-rw-r--r--openstack_dashboard/dashboards/admin/backups/tests.py307
-rw-r--r--openstack_dashboard/dashboards/admin/backups/urls.py29
-rw-r--r--openstack_dashboard/dashboards/admin/backups/views.py138
-rw-r--r--openstack_dashboard/dashboards/project/backups/forms.py5
-rw-r--r--openstack_dashboard/dashboards/project/backups/tables.py8
-rw-r--r--openstack_dashboard/dashboards/project/backups/tabs.py3
-rw-r--r--openstack_dashboard/enabled/_2230_admin_backups_panel.py9
-rw-r--r--releasenotes/notes/volume-backups-a198d0ce16d62636.yaml5
17 files changed, 831 insertions, 13 deletions
diff --git a/openstack_dashboard/api/cinder.py b/openstack_dashboard/api/cinder.py
index e831ea27b..aa9753b77 100644
--- a/openstack_dashboard/api/cinder.py
+++ b/openstack_dashboard/api/cinder.py
@@ -123,7 +123,8 @@ class VolumeType(BaseCinderAPIResourceWrapper):
class VolumeBackup(BaseCinderAPIResourceWrapper):
_attrs = ['id', 'name', 'description', 'container', 'size', 'status',
- 'created_at', 'volume_id', 'availability_zone', 'snapshot_id']
+ 'created_at', 'volume_id', 'availability_zone', 'snapshot_id',
+ 'os-backup-project-attr:project_id']
_volume = None
_snapshot = None
@@ -143,6 +144,10 @@ class VolumeBackup(BaseCinderAPIResourceWrapper):
def snapshot(self, value):
self._snapshot = value
+ @property
+ def project_id(self):
+ return getattr(self, 'os-backup-project-attr:project_id', "")
+
class QosSpecs(BaseCinderAPIResourceWrapper):
@@ -581,7 +586,7 @@ def volume_backup_supported(request):
@profiler.trace
def volume_backup_get(request, backup_id):
- backup = cinderclient(request).backups.get(backup_id)
+ backup = cinderclient(request, '3.18').backups.get(backup_id)
return VolumeBackup(backup)
@@ -592,7 +597,8 @@ def volume_backup_list(request):
@profiler.trace
def volume_backup_list_paged_with_page_menu(request, page_number=1,
- sort_dir="desc"):
+ sort_dir="desc",
+ all_tenants=False):
backups = []
count = 0
pages_count = 0
@@ -606,8 +612,10 @@ def volume_backup_list_paged_with_page_menu(request, page_number=1,
sort = 'created_at:' + sort_dir
bkps, count = c_client.backups.list(limit=page_size,
sort=sort,
- search_opts={'with_count': True,
- 'offset': offset})
+ search_opts={
+ 'with_count': True,
+ 'offset': offset,
+ 'all_tenants': all_tenants})
if not bkps:
return backups, page_size, count, pages_count
@@ -673,8 +681,8 @@ def volume_backup_create(request,
@profiler.trace
-def volume_backup_delete(request, backup_id):
- return cinderclient(request).backups.delete(backup_id)
+def volume_backup_delete(request, backup_id, force=None):
+ return cinderclient(request).backups.delete(backup_id, force=force)
@profiler.trace
@@ -684,6 +692,12 @@ def volume_backup_restore(request, backup_id, volume_id):
@profiler.trace
+def volume_backup_reset_state(request, backup_id, state):
+ return cinderclient(request).backups.reset_state(
+ backup_id, state)
+
+
+@profiler.trace
def volume_manage(request,
host,
identifier,
diff --git a/openstack_dashboard/dashboards/admin/backups/__init__.py b/openstack_dashboard/dashboards/admin/backups/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/openstack_dashboard/dashboards/admin/backups/__init__.py
diff --git a/openstack_dashboard/dashboards/admin/backups/forms.py b/openstack_dashboard/dashboards/admin/backups/forms.py
new file mode 100644
index 000000000..da15cb7b9
--- /dev/null
+++ b/openstack_dashboard/dashboards/admin/backups/forms.py
@@ -0,0 +1,72 @@
+# 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.urls import reverse
+from django.utils.translation import ugettext_lazy as _
+
+from horizon import exceptions
+from horizon import forms
+from horizon import messages
+
+from openstack_dashboard.api import cinder
+from openstack_dashboard.dashboards.admin.snapshots.forms \
+ import populate_status_choices
+from openstack_dashboard.dashboards.project.backups \
+ import forms as project_forms
+from openstack_dashboard.dashboards.project.backups.tables \
+ import BackupsTable as backups_table
+
+
+SETTABLE_STATUSES = ('available', 'error')
+STATUS_CHOICES = tuple(
+ status for status in backups_table.STATUS_DISPLAY_CHOICES
+ if status[0] in SETTABLE_STATUSES
+)
+
+
+class UpdateStatus(forms.SelfHandlingForm):
+ status = forms.ThemableChoiceField(label=_("Status"))
+
+ def __init__(self, request, *args, **kwargs):
+ current_status = kwargs['initial']['status']
+ kwargs['initial'].pop('status')
+
+ super().__init__(request, *args, **kwargs)
+
+ self.fields['status'].choices = populate_status_choices(
+ current_status, STATUS_CHOICES)
+
+ def handle(self, request, data):
+ for choice in self.fields['status'].choices:
+ if choice[0] == data['status']:
+ new_status = choice[1]
+ break
+ else:
+ new_status = data['status']
+
+ try:
+ cinder.volume_backup_reset_state(request,
+ self.initial['backup_id'],
+ data['status'])
+ messages.success(request,
+ _('Successfully updated volume backup'
+ ' status to "%s".') % new_status)
+ return True
+ except Exception:
+ redirect = reverse("horizon:admin:backups:index")
+ exceptions.handle(request,
+ _('Unable to update volume backup status to '
+ '"%s".') % new_status, redirect=redirect)
+
+
+class AdminRestoreBackupForm(project_forms.RestoreBackupForm):
+ redirect_url = 'horizon:admin:backups:index'
diff --git a/openstack_dashboard/dashboards/admin/backups/panel.py b/openstack_dashboard/dashboards/admin/backups/panel.py
new file mode 100644
index 000000000..ddea514d2
--- /dev/null
+++ b/openstack_dashboard/dashboards/admin/backups/panel.py
@@ -0,0 +1,18 @@
+# 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 openstack_dashboard.dashboards.project.backups \
+ import panel as project_panel
+
+
+class Backups(project_panel.Backups):
+ policy_rules = (("volume", "context_is_admin"),)
diff --git a/openstack_dashboard/dashboards/admin/backups/tables.py b/openstack_dashboard/dashboards/admin/backups/tables.py
new file mode 100644
index 000000000..2255edc80
--- /dev/null
+++ b/openstack_dashboard/dashboards/admin/backups/tables.py
@@ -0,0 +1,119 @@
+# 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.utils.translation import ugettext_lazy as _
+from django.utils.translation import ungettext_lazy
+
+from horizon import exceptions
+from horizon import tables
+
+from openstack_dashboard import api
+from openstack_dashboard.dashboards.project.backups \
+ import tables as project_tables
+from openstack_dashboard import policy
+
+
+FORCE_DELETABLE_STATES = ("error_deleting", "restoring", "creating")
+
+
+class AdminSnapshotColumn(project_tables.SnapshotColumn):
+ url = "horizon:admin:snapshots:detail"
+
+
+class AdminDeleteBackup(project_tables.DeleteBackup):
+ pass
+
+
+class ForceDeleteBackup(policy.PolicyTargetMixin, tables.DeleteAction):
+ help_text = _("Deleted volume backups are not recoverable.")
+ name = "delete_force"
+ policy_rules = (("volume",
+ "volume_extension:backup_admin_actions:force_delete"),)
+
+ @staticmethod
+ def action_present(count):
+ return ungettext_lazy(
+ u"Delete Force Volume Backup",
+ u"Delete Force Volume Backups",
+ count
+ )
+
+ @staticmethod
+ def action_past(count):
+ return ungettext_lazy(
+ u"Scheduled force deletion of Volume Backup",
+ u"Scheduled force deletion of Volume Backups",
+ count
+ )
+
+ def delete(self, request, obj_id):
+ api.cinder.volume_backup_delete(request, obj_id, force=True)
+
+ def allowed(self, request, backup=None):
+ if backup:
+ return backup.status in FORCE_DELETABLE_STATES
+ return True
+
+
+class UpdateRow(project_tables.UpdateRow):
+ ajax = True
+
+ def get_data(self, request, backup_id):
+ backup = super().get_data(request, backup_id)
+ tenant_id = getattr(backup, 'project_id')
+ try:
+ tenant = api.keystone.tenant_get(request, tenant_id)
+ backup.tenant_name = getattr(tenant, "name")
+ except Exception:
+ msg = _('Unable to retrieve volume backup project information.')
+ exceptions.handle(request, msg)
+
+ return backup
+
+
+class AdminRestoreBackup(project_tables.RestoreBackup):
+ url = "horizon:admin:backups:restore"
+
+
+class UpdateVolumeBackupStatusAction(tables.LinkAction):
+ name = "update_status"
+ verbose_name = _("Update Volume backup Status")
+ url = "horizon:admin:backups:update_status"
+ classes = ("ajax-modal",)
+ icon = "pencil"
+ policy_rules = (("volume",
+ "volume_extension:backup_admin_actions:reset_status"),)
+
+
+class AdminBackupsTable(project_tables.BackupsTable):
+ project = tables.Column("tenant_name", verbose_name=_("Project"))
+ name = tables.Column("name",
+ verbose_name=_("Name"),
+ link="horizon:admin:backups:detail")
+ volume_name = project_tables.BackupVolumeNameColumn(
+ "name", verbose_name=_("Volume Name"),
+ link="horizon:admin:volumes:detail")
+ snapshot = AdminSnapshotColumn("snapshot",
+ verbose_name=_("Snapshot"),
+ link="horizon:admin:snapshots:detail")
+
+ class Meta(object):
+ name = "volume_backups"
+ verbose_name = _("Volume Backups")
+ pagination_param = 'page'
+ status_columns = ("status",)
+ row_class = UpdateRow
+ table_actions = (AdminDeleteBackup,)
+ row_actions = (AdminRestoreBackup, ForceDeleteBackup,
+ AdminDeleteBackup, UpdateVolumeBackupStatusAction,)
+ columns = ('project', 'name', 'description', 'size', 'status',
+ 'volume_name', 'snapshot',)
diff --git a/openstack_dashboard/dashboards/admin/backups/tabs.py b/openstack_dashboard/dashboards/admin/backups/tabs.py
new file mode 100644
index 000000000..d4631822c
--- /dev/null
+++ b/openstack_dashboard/dashboards/admin/backups/tabs.py
@@ -0,0 +1,23 @@
+# 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 openstack_dashboard.dashboards.project.backups \
+ import tabs as project_tabs
+
+
+class AdminBackupOverviewTab(project_tabs.BackupOverviewTab):
+ template_name = "admin/backups/_detail_overview.html"
+ redirect_url = 'horizon:admin:backups:index'
+
+
+class AdminBackupDetailTabs(project_tabs.BackupDetailTabs):
+ tabs = (AdminBackupOverviewTab,)
diff --git a/openstack_dashboard/dashboards/admin/backups/templates/backups/_detail_overview.html b/openstack_dashboard/dashboards/admin/backups/templates/backups/_detail_overview.html
new file mode 100644
index 000000000..948df205b
--- /dev/null
+++ b/openstack_dashboard/dashboards/admin/backups/templates/backups/_detail_overview.html
@@ -0,0 +1,62 @@
+{% load i18n sizeformat parse_date %}
+
+<div class="detail">
+ <dl class="dl-horizontal">
+ <dt>{% trans "Name" %}</dt>
+ <dd class="word-wrap">{{ backup.name|default:_("-") }}</dd>
+ <dt>{% trans "ID" %}</dt>
+ <dd>{{ backup.id }}</dd>
+ {% if backup.description %}
+ <dt>{% trans "Description" %}</dt>
+ <dd>{{ backup.description }}</dd>
+ {% endif %}
+ <dt>{% trans "Project ID" %}</dt>
+ <dd>{{ backup.project_id|default:_("-") }}</dd>
+ <dt>{% trans "Status" %}</dt>
+ <dd>{{ backup.status|capfirst }}</dd>
+ {% if volume %}
+ <dt>{% trans "Volume" %}</dt>
+ <dd>
+ <a href="{% url 'horizon:admin:volumes:detail' backup.volume_id %}">
+ {{ volume.name }}
+ </a>
+ </dd>
+ {% endif %}
+ {% if backup.snapshot_id %}
+ <dt>{% trans "Snapshot" %}</dt>
+ {% if snapshot %}
+ <dd>
+ <a href="{% url 'horizon:admin:snapshots:detail' backup.snapshot_id %}">
+ {{ snapshot.name }}
+ </a>
+ </dd>
+ {% elif backup.snapshot_id %}
+ <dd>
+ {{ backup.snapshot_id }}
+ {% endif %}
+ </dd>
+ {% endif %}
+ </dl>
+
+ <h4>{% trans "Specs" %}</h4>
+ <hr class="header_rule">
+ <dl class="dl-horizontal">
+ <dt>{% trans "Size" %}</dt>
+ <dd>{{ backup.size }} {% trans "GB" %}</dd>
+ <dt>{% trans "Created" %}</dt>
+ <dd>{{ backup.created_at|parse_date }}</dd>
+ </dl>
+
+ <h4>{% trans "Metadata" %}</h4>
+ <hr class="header_rule">
+ <dl class="dl-horizontal">
+ {% if backup.metadata.items %}
+ {% for key, value in backup.metadata.items %}
+ <dt>{{ key }}</dt>
+ <dd>{{ value }}</dd>
+ {% endfor %}
+ {% else %}
+ <dd>{% trans "None" %}</dd>
+ {% endif %}
+ </dl>
+</div>
diff --git a/openstack_dashboard/dashboards/admin/backups/templates/backups/_update_status.html b/openstack_dashboard/dashboards/admin/backups/templates/backups/_update_status.html
new file mode 100644
index 000000000..6cae1e03b
--- /dev/null
+++ b/openstack_dashboard/dashboards/admin/backups/templates/backups/_update_status.html
@@ -0,0 +1,11 @@
+{% extends "horizon/common/_modal_form.html" %}
+{% load i18n %}
+
+{% block modal-body-right %}
+ <h3>{% trans "Description:" %}</h3>
+ <p>{% blocktrans trimmed %}
+ The status of a volume backup is normally managed automatically. In some circumstances
+ an administrator may need to explicitly update the status value. This is equivalent to
+ the <tt>cinder backup-reset-state</tt> command.
+ {% endblocktrans %}</p>
+{% endblock %}
diff --git a/openstack_dashboard/dashboards/admin/backups/templates/backups/update_status.html b/openstack_dashboard/dashboards/admin/backups/templates/backups/update_status.html
new file mode 100644
index 000000000..6b4fd8d24
--- /dev/null
+++ b/openstack_dashboard/dashboards/admin/backups/templates/backups/update_status.html
@@ -0,0 +1,7 @@
+{% extends 'base.html' %}
+{% load i18n %}
+{% block title %}{% trans "Update Volume backup Status" %}{% endblock %}
+
+{% block main %}
+ {% include 'admin/backups/_update_status.html' %}
+{% endblock %}
diff --git a/openstack_dashboard/dashboards/admin/backups/tests.py b/openstack_dashboard/dashboards/admin/backups/tests.py
new file mode 100644
index 000000000..060614636
--- /dev/null
+++ b/openstack_dashboard/dashboards/admin/backups/tests.py
@@ -0,0 +1,307 @@
+# 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 import settings
+from django.test.utils import override_settings
+from django.urls import reverse
+from django.utils.http import urlencode
+from django.utils.http import urlunquote
+
+from openstack_dashboard import api
+from openstack_dashboard.dashboards.admin.backups \
+ import tables as admin_tables
+from openstack_dashboard.test import helpers as test
+
+INDEX_URL = reverse('horizon:admin:backups:index')
+
+
+class AdminVolumeBackupsViewTests(test.BaseAdminViewTests):
+
+ @test.create_mocks({
+ api.keystone: ['tenant_list'],
+ api.cinder: ['volume_list', 'volume_snapshot_list',
+ 'volume_backup_list_paged_with_page_menu']})
+ def _test_backups_index_paginated(self, page_number, backups,
+ url, page_size, total_of_entries,
+ number_of_pages, has_prev, has_more):
+ self.mock_volume_backup_list_paged_with_page_menu.return_value = [
+ backups, page_size, total_of_entries, number_of_pages]
+ self.mock_volume_list.return_value = self.cinder_volumes.list()
+ self.mock_volume_snapshot_list.return_value \
+ = self.cinder_volume_snapshots.list()
+ self.mock_tenant_list.return_value = [self.tenants.list(), False]
+
+ res = self.client.get(urlunquote(url))
+
+ self.assertEqual(res.status_code, 200)
+ self.assertTemplateUsed(res, 'horizon/common/_data_table_view.html')
+ self.assertEqual(has_more,
+ res.context_data['view'].has_more_data(None))
+ self.assertEqual(has_prev,
+ res.context_data['view'].has_prev_data(None))
+ self.assertEqual(
+ page_number, res.context_data['view'].current_page(None))
+ self.assertEqual(
+ number_of_pages, res.context_data['view'].number_of_pages(None))
+ self.mock_volume_backup_list_paged_with_page_menu.\
+ assert_called_once_with(test.IsHttpRequest(),
+ page_number=page_number,
+ all_tenants=True)
+ self.mock_volume_list.assert_called_once_with(
+ test.IsHttpRequest(), search_opts={'all_tenants': 1})
+ self.mock_volume_snapshot_list.assert_called_once_with(
+ test.IsHttpRequest(),
+ search_opts={'all_tenants': 1})
+ return res
+
+ @override_settings(API_RESULT_PAGE_SIZE=1)
+ def test_backups_index_paginated(self):
+ backups = self.cinder_volume_backups.list()
+ expected_snapshosts = self.cinder_volume_snapshots.list()
+ size = settings.API_RESULT_PAGE_SIZE
+ base_url = INDEX_URL
+ number_of_pages = len(backups)
+ pag = admin_tables.AdminBackupsTable._meta.pagination_param
+ page_number = 1
+
+ # get first page
+ expected_backups = backups[:size]
+ res = self._test_backups_index_paginated(
+ page_number=page_number, backups=expected_backups, url=base_url,
+ has_more=True, has_prev=False, page_size=size,
+ number_of_pages=number_of_pages, total_of_entries=number_of_pages)
+ result = res.context['volume_backups_table'].data
+ self.assertCountEqual(result, expected_backups)
+
+ # get second page
+ expected_backups = backups[size:2 * size]
+ page_number = 2
+ url = base_url + "?%s=%s" % (pag, page_number)
+ res = self._test_backups_index_paginated(
+ page_number=page_number, backups=expected_backups, url=url,
+ has_more=True, has_prev=True, page_size=size,
+ number_of_pages=number_of_pages, total_of_entries=number_of_pages)
+ result = res.context['volume_backups_table'].data
+ self.assertCountEqual(result, expected_backups)
+ self.assertEqual(result[0].snapshot.id, expected_snapshosts[1].id)
+ # get last page
+ expected_backups = backups[-size:]
+ page_number = 3
+ url = base_url + "?%s=%s" % (pag, page_number)
+ res = self._test_backups_index_paginated(
+ page_number=page_number, backups=expected_backups, url=url,
+ has_more=False, has_prev=True, page_size=size,
+ number_of_pages=number_of_pages, total_of_entries=number_of_pages)
+ result = res.context['volume_backups_table'].data
+ self.assertCountEqual(result, expected_backups)
+
+ @override_settings(API_RESULT_PAGE_SIZE=1)
+ def test_backups_index_paginated_prev_page(self):
+ backups = self.cinder_volume_backups.list()
+ size = settings.API_RESULT_PAGE_SIZE
+ number_of_pages = len(backups)
+ base_url = INDEX_URL
+ pag = admin_tables.AdminBackupsTable._meta.pagination_param
+
+ # prev from some page
+ expected_backups = backups[size:2 * size]
+ page_number = 2
+ url = base_url + "?%s=%s" % (pag, page_number)
+ res = self._test_backups_index_paginated(
+ page_number=page_number, backups=expected_backups, url=url,
+ has_more=True, has_prev=True, page_size=size,
+ number_of_pages=number_of_pages, total_of_entries=number_of_pages)
+ result = res.context['volume_backups_table'].data
+ self.assertCountEqual(result, expected_backups)
+
+ # back to first page
+ expected_backups = backups[:size]
+ page_number = 1
+ url = base_url + "?%s=%s" % (pag, page_number)
+ res = self._test_backups_index_paginated(
+ page_number=page_number, backups=expected_backups, url=url,
+ has_more=True, has_prev=False, page_size=size,
+ number_of_pages=number_of_pages, total_of_entries=number_of_pages)
+ result = res.context['volume_backups_table'].data
+ self.assertCountEqual(result, expected_backups)
+
+ @test.create_mocks({
+ api.keystone: ['tenant_list'],
+ api.cinder: ['volume_list',
+ 'volume_snapshot_list',
+ 'volume_backup_list_paged_with_page_menu',
+ 'volume_backup_delete']})
+ def test_delete_volume_backup(self):
+ vol_backups = self.cinder_volume_backups.list()
+ volumes = self.cinder_volumes.list()
+ backup = self.cinder_volume_backups.first()
+ snapshots = self.cinder_volume_snapshots.list()
+ page_number = 1
+ page_size = 1
+ total_of_entries = 1
+ number_of_pages = 1
+
+ self.mock_volume_backup_list_paged_with_page_menu.return_value = [
+ vol_backups, page_size, total_of_entries, number_of_pages]
+ self.mock_volume_list.return_value = volumes
+ self.mock_volume_backup_delete.return_value = None
+ self.mock_volume_snapshot_list.return_value = snapshots
+ self.mock_tenant_list.return_value = [self.tenants.list(), False]
+ formData = {'action':
+ 'volume_backups__delete__%s' % backup.id}
+ res = self.client.post(INDEX_URL, formData)
+
+ self.assertRedirectsNoFollow(res, INDEX_URL)
+ self.assertMessageCount(success=1)
+ self.mock_volume_backup_list_paged_with_page_menu.\
+ assert_called_once_with(test.IsHttpRequest(),
+ page_number=page_number,
+ all_tenants=True)
+ self.mock_volume_list.assert_called_once_with(
+ test.IsHttpRequest(), search_opts={'all_tenants': 1})
+ self.mock_volume_snapshot_list.assert_called_once_with(
+ test.IsHttpRequest(), search_opts={'all_tenants': 1})
+ self.mock_volume_backup_delete.assert_called_once_with(
+ test.IsHttpRequest(), backup.id)
+
+ @test.create_mocks({
+ api.cinder: ['volume_backup_get',
+ 'volume_get']})
+ def test_volume_backup_detail_get(self):
+ backup = self.cinder_volume_backups.first()
+ volume = self.cinder_volumes.get(id=backup.volume_id)
+
+ self.mock_volume_backup_get.return_value = backup
+ self.mock_volume_get.return_value = volume
+
+ url = reverse('horizon:admin:backups:detail',
+ args=[backup.id])
+ res = self.client.get(url)
+
+ self.assertTemplateUsed(res, 'horizon/common/_detail.html')
+ self.assertEqual(res.context['backup'].id, backup.id)
+ self.mock_volume_backup_get.assert_called_once_with(
+ test.IsHttpRequest(), backup.id)
+ self.mock_volume_get.assert_called_once_with(
+ test.IsHttpRequest(), backup.volume_id)
+
+ @test.create_mocks({
+ api.cinder: ['volume_backup_get',
+ 'volume_snapshot_get',
+ 'volume_get']})
+ def test_volume_backup_detail_get_with_snapshot(self):
+ backup = self.cinder_volume_backups.list()[1]
+ volume = self.cinder_volumes.get(id=backup.volume_id)
+
+ self.mock_volume_backup_get.return_value = backup
+ self.mock_volume_get.return_value = volume
+ self.mock_volume_snapshot_get.return_value \
+ = self.cinder_volume_snapshots.list()[1]
+ url = reverse('horizon:admin:backups:detail',
+ args=[backup.id])
+ res = self.client.get(url)
+
+ self.assertTemplateUsed(res, 'horizon/common/_detail.html')
+ self.assertEqual(res.context['backup'].id, backup.id)
+ self.assertEqual(res.context['snapshot'].id, backup.snapshot_id)
+ self.mock_volume_backup_get.assert_called_once_with(
+ test.IsHttpRequest(), backup.id)
+ self.mock_volume_get.assert_called_once_with(
+ test.IsHttpRequest(), backup.volume_id)
+ self.mock_volume_snapshot_get.assert_called_once_with(
+ test.IsHttpRequest(), backup.snapshot_id)
+
+ @test.create_mocks({api.cinder: ('volume_backup_get',)})
+ def test_volume_backup_detail_get_with_exception(self):
+ # Test to verify redirect if get volume backup fails
+ backup = self.cinder_volume_backups.first()
+
+ self.mock_volume_backup_get.side_effect = self.exceptions.cinder
+
+ url = reverse('horizon:admin:backups:detail',
+ args=[backup.id])
+ res = self.client.get(url)
+
+ self.assertNoFormErrors(res)
+ self.assertMessageCount(error=1)
+ self.assertRedirectsNoFollow(res, INDEX_URL)
+ self.mock_volume_backup_get.assert_called_once_with(
+ test.IsHttpRequest(), backup.id)
+
+ @test.create_mocks({api.cinder: ('volume_backup_get',
+ 'volume_get')})
+ def test_volume_backup_detail_with_missing_volume(self):
+ # Test to check page still loads even if volume is deleted
+ backup = self.cinder_volume_backups.first()
+
+ self.mock_volume_backup_get.return_value = backup
+ self.mock_volume_get.side_effect = self.exceptions.cinder
+
+ url = reverse('horizon:admin:backups:detail',
+ args=[backup.id])
+ res = self.client.get(url)
+
+ self.assertTemplateUsed(res, 'horizon/common/_detail.html')
+ self.assertEqual(res.context['backup'].id, backup.id)
+ self.mock_volume_backup_get.assert_called_once_with(
+ test.IsHttpRequest(), backup.id)
+ self.mock_volume_get.assert_called_once_with(
+ test.IsHttpRequest(), backup.volume_id)
+
+ @test.create_mocks({api.cinder: ('volume_list',
+ 'volume_backup_restore')})
+ def test_restore_backup(self):
+ mock_backup = self.cinder_volume_backups.first()
+ volumes = self.cinder_volumes.list()
+ expected_volumes = [vol for vol in volumes
+ if vol.status == 'available']
+
+ self.mock_volume_list.return_value = expected_volumes
+ self.mock_volume_backup_restore.return_value = mock_backup
+
+ formData = {'method': 'RestoreBackupForm',
+ 'backup_id': mock_backup.id,
+ 'backup_name': mock_backup.name,
+ 'volume_id': mock_backup.volume_id}
+ url = reverse('horizon:admin:backups:restore',
+ args=[mock_backup.id])
+ url += '?%s' % urlencode({'backup_name': mock_backup.name,
+ 'volume_id': mock_backup.volume_id})
+ res = self.client.post(url, formData)
+
+ self.assertNoFormErrors(res)
+ self.assertMessageCount(info=1)
+ self.assertRedirectsNoFollow(res,
+ reverse('horizon:admin:volumes:index'))
+ self.mock_volume_list.assert_called_once_with(test.IsHttpRequest(),
+ {'status': 'available'})
+ self.mock_volume_backup_restore.assert_called_once_with(
+ test.IsHttpRequest(), mock_backup.id, mock_backup.volume_id)
+
+ @test.create_mocks({api.cinder: ('volume_backup_get',
+ 'volume_backup_reset_state')})
+ def test_update_volume_backup_status(self):
+ backup = self.cinder_volume_backups.first()
+ form_data = {'status': 'error'}
+
+ self.mock_volume_backup_reset_state.return_value = None
+ self.mock_volume_backup_get.return_value = backup
+
+ res = self.client.post(
+ reverse('horizon:admin:backups:update_status',
+ args=(backup.id,)), form_data)
+
+ self.mock_volume_backup_reset_state.assert_called_once_with(
+ test.IsHttpRequest(), backup.id, form_data['status'])
+ self.mock_volume_backup_get.assert_called_once_with(
+ test.IsHttpRequest(), backup.id)
+ self.assertNoFormErrors(res)
diff --git a/openstack_dashboard/dashboards/admin/backups/urls.py b/openstack_dashboard/dashboards/admin/backups/urls.py
new file mode 100644
index 000000000..a90b68aaa
--- /dev/null
+++ b/openstack_dashboard/dashboards/admin/backups/urls.py
@@ -0,0 +1,29 @@
+# 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.backups import views
+
+
+urlpatterns = [
+ url(r'^$', views.AdminBackupsView.as_view(), name='index'),
+ url(r'^(?P<backup_id>[^/]+)/$',
+ views.AdminBackupDetailView.as_view(),
+ name='detail'),
+ url(r'^(?P<backup_id>[^/]+)/restore/$',
+ views.AdminRestoreBackupView.as_view(),
+ name='restore'),
+ url(r'^(?P<backup_id>[^/]+)/update_status$',
+ views.UpdateStatusView.as_view(),
+ name='update_status'),
+]
diff --git a/openstack_dashboard/dashboards/admin/backups/views.py b/openstack_dashboard/dashboards/admin/backups/views.py
new file mode 100644
index 000000000..139b02b0a
--- /dev/null
+++ b/openstack_dashboard/dashboards/admin/backups/views.py
@@ -0,0 +1,138 @@
+# 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.
+
+import logging
+
+from django.urls import reverse
+from django.urls import reverse_lazy
+from django.utils.translation import ugettext_lazy as _
+
+from horizon import exceptions
+from horizon import forms
+from horizon import tables
+from horizon.utils import memoized
+
+from openstack_dashboard import api
+from openstack_dashboard.api import cinder
+from openstack_dashboard.dashboards.admin.backups \
+ import forms as admin_forms
+from openstack_dashboard.dashboards.admin.backups \
+ import tables as admin_tables
+from openstack_dashboard.dashboards.admin.backups \
+ import tabs as admin_tabs
+from openstack_dashboard.dashboards.project.backups \
+ import views as project_views
+from openstack_dashboard.dashboards.project.volumes \
+ import views as volumes_views
+
+LOG = logging.getLogger(__name__)
+
+
+class AdminBackupsView(tables.PagedTableWithPageMenu, tables.DataTableView,
+ volumes_views.VolumeTableMixIn):
+ table_class = admin_tables.AdminBackupsTable
+ page_title = _("Volume Backups")
+
+ def allowed(self, request):
+ return api.cinder.volume_backup_supported(self.request)
+
+ def get_data(self):
+ try:
+ search_opts = {'all_tenants': 1}
+ self._current_page = self._get_page_number()
+ (backups, self._page_size, self._total_of_entries,
+ self._number_of_pages) = \
+ api.cinder.volume_backup_list_paged_with_page_menu(
+ self.request, page_number=self._current_page,
+ all_tenants=True)
+ except Exception as e:
+ LOG.exception(e)
+ backups = []
+ exceptions.handle(self.request, _("Unable to retrieve "
+ "volume backups."))
+ if not backups:
+ return backups
+ volumes = api.cinder.volume_list(self.request, search_opts=search_opts)
+ volumes = dict((v.id, v) for v in volumes)
+ snapshots = api.cinder.volume_snapshot_list(self.request,
+ search_opts=search_opts)
+ snapshots = dict((s.id, s) for s in snapshots)
+
+ # Gather our tenants to correlate against Backup IDs
+ try:
+ tenants, has_more = api.keystone.tenant_list(self.request)
+ except Exception:
+ tenants = []
+ msg = _('Unable to retrieve volume backup project information.')
+ exceptions.handle(self.request, msg)
+
+ tenant_dict = dict((t.id, t) for t in tenants)
+ for backup in backups:
+ backup.volume = volumes.get(backup.volume_id)
+ backup.snapshot = snapshots.get(backup.snapshot_id)
+ tenant_id = getattr(backup, "project_id", None)
+ tenant = tenant_dict.get(tenant_id)
+ backup.tenant_name = getattr(tenant, "name", None)
+ return backups
+
+
+class UpdateStatusView(forms.ModalFormView):
+ form_class = admin_forms.UpdateStatus
+ modal_id = "update_backup_status_modal"
+ template_name = 'admin/backups/update_status.html'
+ submit_label = _("Update Status")
+ submit_url = "horizon:admin:backups:update_status"
+ success_url = reverse_lazy('horizon:admin:backups:index')
+ page_title = _("Update Volume backup Status")
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context["backup_id"] = self.kwargs['backup_id']
+ args = (self.kwargs['backup_id'],)
+ context['submit_url'] = reverse(self.submit_url, args=args)
+ return context
+
+ @memoized.memoized_method
+ def get_data(self):
+ try:
+ backup_id = self.kwargs['backup_id']
+ backup = cinder.volume_backup_get(self.request, backup_id)
+ except Exception:
+ exceptions.handle(self.request,
+ _('Unable to retrieve volume backup details.'),
+ redirect=self.success_url)
+ return backup
+
+ def get_initial(self):
+ backup = self.get_data()
+ return {'backup_id': self.kwargs["backup_id"],
+ 'status': backup.status}
+
+
+class AdminBackupDetailView(project_views.BackupDetailView):
+ tab_group_class = admin_tabs.AdminBackupDetailTabs
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ table = admin_tables.AdminBackupsTable(self.request)
+ context["actions"] = table.render_row_actions(context["backup"])
+ return context
+
+ @staticmethod
+ def get_redirect_url():
+ return reverse('horizon:admin:backups:index')
+
+
+class AdminRestoreBackupView(project_views.RestoreBackupView):
+ form_class = admin_forms.AdminRestoreBackupForm
+ submit_url = "horizon:admin:backups:restore"
+ success_url = reverse_lazy('horizon:admin:volumes:index')
diff --git a/openstack_dashboard/dashboards/project/backups/forms.py b/openstack_dashboard/dashboards/project/backups/forms.py
index 3e57b7f13..14cdeb2b4 100644
--- a/openstack_dashboard/dashboards/project/backups/forms.py
+++ b/openstack_dashboard/dashboards/project/backups/forms.py
@@ -109,6 +109,7 @@ class RestoreBackupForm(forms.SelfHandlingForm):
required=False)
backup_id = forms.CharField(widget=forms.HiddenInput())
backup_name = forms.CharField(widget=forms.HiddenInput())
+ redirect_url = 'horizon:project:backups:index'
def __init__(self, request, *args, **kwargs):
super().__init__(request, *args, **kwargs)
@@ -118,7 +119,7 @@ class RestoreBackupForm(forms.SelfHandlingForm):
volumes = api.cinder.volume_list(request, search_opts)
except Exception:
msg = _('Unable to lookup volume or backup information.')
- redirect = reverse('horizon:project:backups:index')
+ redirect = reverse(self.redirect_url)
exceptions.handle(request, msg, redirect=redirect)
raise exceptions.Http302(redirect)
@@ -148,5 +149,5 @@ class RestoreBackupForm(forms.SelfHandlingForm):
return restore
except Exception:
msg = _('Unable to restore backup.')
- redirect = reverse('horizon:project:backups:index')
+ redirect = reverse(self.redirect_url)
exceptions.handle(request, msg, redirect=redirect)
diff --git a/openstack_dashboard/dashboards/project/backups/tables.py b/openstack_dashboard/dashboards/project/backups/tables.py
index e631a5ccb..5d88f173c 100644
--- a/openstack_dashboard/dashboards/project/backups/tables.py
+++ b/openstack_dashboard/dashboards/project/backups/tables.py
@@ -45,6 +45,8 @@ class BackupVolumeNameColumn(tables.Column):
class SnapshotColumn(tables.Column):
+ url = "horizon:project:snapshots:detail"
+
def get_raw_data(self, backup):
snapshot = backup.snapshot
if snapshot:
@@ -58,7 +60,7 @@ class SnapshotColumn(tables.Column):
def get_link_url(self, backup):
if backup.snapshot:
- return reverse('horizon:project:snapshots:detail',
+ return reverse(self.url,
args=(backup.snapshot_id,))
@@ -94,6 +96,7 @@ class DeleteBackup(tables.DeleteAction):
class RestoreBackup(tables.LinkAction):
name = "restore"
verbose_name = _("Restore Backup")
+ url = "horizon:project:backups:restore"
classes = ("ajax-modal",)
policy_rules = (("volume", "backup:restore"),)
@@ -104,8 +107,7 @@ class RestoreBackup(tables.LinkAction):
backup_id = datum.id
backup_name = datum.name
volume_id = getattr(datum, 'volume_id', None)
- url = reverse("horizon:project:backups:restore",
- args=(backup_id,))
+ url = reverse(self.url, args=(backup_id,))
url += '?%s' % http.urlencode({'backup_name': backup_name,
'volume_id': volume_id})
return url
diff --git a/openstack_dashboard/dashboards/project/backups/tabs.py b/openstack_dashboard/dashboards/project/backups/tabs.py
index 54fd93c18..8c5a4eb9d 100644
--- a/openstack_dashboard/dashboards/project/backups/tabs.py
+++ b/openstack_dashboard/dashboards/project/backups/tabs.py
@@ -24,6 +24,7 @@ class BackupOverviewTab(tabs.Tab):
name = _("Overview")
slug = "overview"
template_name = "project/backups/_detail_overview.html"
+ redirect_url = 'horizon:project:backups:index'
def get_context_data(self, request):
try:
@@ -46,7 +47,7 @@ class BackupOverviewTab(tabs.Tab):
'snapshot': snapshot}
except Exception:
- redirect = reverse('horizon:project:backups:index')
+ redirect = reverse(self.redirect_url)
exceptions.handle(self.request,
_('Unable to retrieve backup details.'),
redirect=redirect)
diff --git a/openstack_dashboard/enabled/_2230_admin_backups_panel.py b/openstack_dashboard/enabled/_2230_admin_backups_panel.py
new file mode 100644
index 000000000..b19772344
--- /dev/null
+++ b/openstack_dashboard/enabled/_2230_admin_backups_panel.py
@@ -0,0 +1,9 @@
+# The slug of the panel to be added to HORIZON_CONFIG. Required.
+PANEL = 'backups'
+# 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 = 'volume'
+
+# Python panel class of the PANEL to be added.
+ADD_PANEL = ('openstack_dashboard.dashboards.admin.backups.panel.Backups')
diff --git a/releasenotes/notes/volume-backups-a198d0ce16d62636.yaml b/releasenotes/notes/volume-backups-a198d0ce16d62636.yaml
new file mode 100644
index 000000000..ee6013237
--- /dev/null
+++ b/releasenotes/notes/volume-backups-a198d0ce16d62636.yaml
@@ -0,0 +1,5 @@
+---
+features:
+ - |
+ Volume backups is now supported for admin panel.
+ Admin is now able to view all volume backups for differenet users.