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
This commit is contained in:
parent
1dd12d8deb
commit
1d39ac761f
@ -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,
|
||||
|
72
openstack_dashboard/dashboards/admin/backups/forms.py
Normal file
72
openstack_dashboard/dashboards/admin/backups/forms.py
Normal file
@ -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
Normal file
18
openstack_dashboard/dashboards/admin/backups/panel.py
Normal file
@ -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
Normal file
119
openstack_dashboard/dashboards/admin/backups/tables.py
Normal file
@ -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
Normal file
23
openstack_dashboard/dashboards/admin/backups/tabs.py
Normal file
@ -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,)
|
@ -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>
|
@ -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 %}
|
@ -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
Normal file
307
openstack_dashboard/dashboards/admin/backups/tests.py
Normal file
@ -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
Normal file
29
openstack_dashboard/dashboards/admin/backups/urls.py
Normal file
@ -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
Normal file
138
openstack_dashboard/dashboards/admin/backups/views.py
Normal file
@ -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')
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
Normal file
9
openstack_dashboard/enabled/_2230_admin_backups_panel.py
Normal file
@ -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
Normal file
5
releasenotes/notes/volume-backups-a198d0ce16d62636.yaml
Normal file
@ -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…
Reference in New Issue
Block a user