diff --git a/openstack_dashboard/api/cinder.py b/openstack_dashboard/api/cinder.py index e831ea27b6..aa9753b77e 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 @@ -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, diff --git a/openstack_dashboard/dashboards/admin/backups/__init__.py b/openstack_dashboard/dashboards/admin/backups/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openstack_dashboard/dashboards/admin/backups/forms.py b/openstack_dashboard/dashboards/admin/backups/forms.py new file mode 100644 index 0000000000..da15cb7b96 --- /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 0000000000..ddea514d23 --- /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 0000000000..2255edc806 --- /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 0000000000..d4631822cd --- /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 0000000000..948df205bc --- /dev/null +++ b/openstack_dashboard/dashboards/admin/backups/templates/backups/_detail_overview.html @@ -0,0 +1,62 @@ +{% load i18n sizeformat parse_date %} + +
+
+
{% trans "Name" %}
+
{{ backup.name|default:_("-") }}
+
{% trans "ID" %}
+
{{ backup.id }}
+ {% if backup.description %} +
{% trans "Description" %}
+
{{ backup.description }}
+ {% endif %} +
{% trans "Project ID" %}
+
{{ backup.project_id|default:_("-") }}
+
{% trans "Status" %}
+
{{ backup.status|capfirst }}
+ {% if volume %} +
{% trans "Volume" %}
+
+ + {{ volume.name }} + +
+ {% endif %} + {% if backup.snapshot_id %} +
{% trans "Snapshot" %}
+ {% if snapshot %} +
+ + {{ snapshot.name }} + +
+ {% elif backup.snapshot_id %} +
+ {{ backup.snapshot_id }} + {% endif %} +
+ {% endif %} +
+ +

{% trans "Specs" %}

+
+
+
{% trans "Size" %}
+
{{ backup.size }} {% trans "GB" %}
+
{% trans "Created" %}
+
{{ backup.created_at|parse_date }}
+
+ +

{% trans "Metadata" %}

+
+
+ {% if backup.metadata.items %} + {% for key, value in backup.metadata.items %} +
{{ key }}
+
{{ value }}
+ {% endfor %} + {% else %} +
{% trans "None" %}
+ {% endif %} +
+
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 0000000000..6cae1e03bd --- /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 %} +

{% trans "Description:" %}

+

{% 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 cinder backup-reset-state command. + {% endblocktrans %}

+{% 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 0000000000..6b4fd8d247 --- /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 0000000000..060614636e --- /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 0000000000..a90b68aaa6 --- /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[^/]+)/$', + views.AdminBackupDetailView.as_view(), + name='detail'), + url(r'^(?P[^/]+)/restore/$', + views.AdminRestoreBackupView.as_view(), + name='restore'), + url(r'^(?P[^/]+)/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 0000000000..139b02b0a3 --- /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 3e57b7f130..14cdeb2b45 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 e631a5ccba..5d88f173c7 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 54fd93c180..8c5a4eb9dd 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 0000000000..b19772344f --- /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 0000000000..ee6013237f --- /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.