Browse Source

Add Volume backups support for admin panel

This commit allows admin to list/show cinder volume backups
using horizon dashboard and the user can perform the
following table action :
1. backup-restore
2. backup-delete
3. update backup-state
4. backup force-delete

Implements https://blueprints.launchpad.net/horizon/+spec/add-volume-backups-support-for-admin-panel

Change-Id: I2fea140a972eb4bd4f18ad1c83cfa4df58c23f6c
changes/03/772603/9
manchandavishal 6 months ago
parent
commit
1d39ac761f
  1. 28
      openstack_dashboard/api/cinder.py
  2. 0
      openstack_dashboard/dashboards/admin/backups/__init__.py
  3. 72
      openstack_dashboard/dashboards/admin/backups/forms.py
  4. 18
      openstack_dashboard/dashboards/admin/backups/panel.py
  5. 119
      openstack_dashboard/dashboards/admin/backups/tables.py
  6. 23
      openstack_dashboard/dashboards/admin/backups/tabs.py
  7. 62
      openstack_dashboard/dashboards/admin/backups/templates/backups/_detail_overview.html
  8. 11
      openstack_dashboard/dashboards/admin/backups/templates/backups/_update_status.html
  9. 7
      openstack_dashboard/dashboards/admin/backups/templates/backups/update_status.html
  10. 307
      openstack_dashboard/dashboards/admin/backups/tests.py
  11. 29
      openstack_dashboard/dashboards/admin/backups/urls.py
  12. 138
      openstack_dashboard/dashboards/admin/backups/views.py
  13. 5
      openstack_dashboard/dashboards/project/backups/forms.py
  14. 8
      openstack_dashboard/dashboards/project/backups/tables.py
  15. 3
      openstack_dashboard/dashboards/project/backups/tabs.py
  16. 9
      openstack_dashboard/enabled/_2230_admin_backups_panel.py
  17. 5
      releasenotes/notes/volume-backups-a198d0ce16d62636.yaml

28
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
@ -683,6 +691,12 @@ def volume_backup_restore(request, backup_id, volume_id):
volume_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,

0
openstack_dashboard/dashboards/admin/backups/__init__.py

72
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'

18
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"),)

119
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',)

23
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,)

62
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>

11
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 %}

7
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 %}

307
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)

29
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'),
]

138
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')

5
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)

8
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

3
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)

9
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')

5
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.
Loading…
Cancel
Save