diff options
author | Laura Frank <ljfrank@gmail.com> | 2014-03-28 10:50:01 -0500 |
---|---|---|
committer | lin-hua-cheng <lin-hua.cheng@hp.com> | 2014-07-04 03:07:53 -0700 |
commit | 630bf3d5a4b6415dcfe5c47e2abc27b21f364b71 (patch) | |
tree | f0d8a4c060f66650ab8c41300b10aeb204e0d653 /openstack_dashboard/dashboards | |
parent | 67892d789d1a74cfebbba94fd3c4e42ba7858553 (diff) | |
download | horizon-630bf3d5a4b6415dcfe5c47e2abc27b21f364b71.tar.gz |
Adding support for volume backups
Users can create, view, delete and restore volume backups
Change-Id: I85b372013c4573018d39178314e769ec8adfd3c7
Co-Authored-By: Lin Hua Cheng <lin-hua.cheng@hp.com>
Implements: blueprint volume-backups
Diffstat (limited to 'openstack_dashboard/dashboards')
21 files changed, 834 insertions, 8 deletions
diff --git a/openstack_dashboard/dashboards/project/volumes/backups/__init__.py b/openstack_dashboard/dashboards/project/volumes/backups/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/backups/__init__.py diff --git a/openstack_dashboard/dashboards/project/volumes/backups/forms.py b/openstack_dashboard/dashboards/project/volumes/backups/forms.py new file mode 100644 index 000000000..885fbaa42 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/backups/forms.py @@ -0,0 +1,109 @@ +# 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. + + +""" +Views for managing backups. +""" + +import operator + +from django.core.urlresolvers 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 import api +from openstack_dashboard.dashboards.project.containers.forms \ + import no_slash_validator + + +class CreateBackupForm(forms.SelfHandlingForm): + name = forms.CharField(max_length="255", label=_("Backup Name")) + description = forms.CharField(widget=forms.Textarea, + label=_("Description"), + required=False) + container_name = forms.CharField(max_length="255", + label=_("Container Name"), + validators=[no_slash_validator], + required=False) + volume_id = forms.CharField(widget=forms.HiddenInput()) + + def handle(self, request, data): + # Create a container for the user if no input is given + if not data['container_name']: + data['container_name'] = 'volumebackups' + + try: + backup = api.cinder.volume_backup_create(request, + data['volume_id'], + data['container_name'], + data['name'], + data['description']) + + message = _('Creating volume backup "%s"') % data['name'] + messages.success(request, message) + return backup + + except Exception: + redirect = reverse('horizon:project:volumes:index') + exceptions.handle(request, + _('Unable to create volume backup.'), + redirect=redirect) + return False + + +class RestoreBackupForm(forms.SelfHandlingForm): + volume_id = forms.ChoiceField(label=_('Select Volume'), required=False) + backup_id = forms.CharField(widget=forms.HiddenInput()) + backup_name = forms.CharField(widget=forms.HiddenInput()) + + def __init__(self, request, *args, **kwargs): + super(RestoreBackupForm, self).__init__(request, *args, **kwargs) + + try: + volumes = api.cinder.volume_list(request) + except Exception: + msg = _('Unable to lookup volume or backup information.') + redirect = reverse('horizon:project:volumes:index') + exceptions.handle(request, msg, redirect=redirect) + raise exceptions.Http302(redirect) + + volumes.sort(key=operator.attrgetter('name', 'created_at')) + choices = [('', _('Create a New Volume'))] + choices.extend((volume.id, volume.name) for volume in volumes) + self.fields['volume_id'].choices = choices + + def handle(self, request, data): + backup_id = data['backup_id'] + backup_name = data['backup_name'] or None + volume_id = data['volume_id'] or None + + try: + restore = api.cinder.volume_backup_restore(request, + backup_id, + volume_id) + + # Needed for cases when a new volume is created. + volume_id = restore.volume_id + + message = _('Successfully restored backup %(backup_name)s ' + 'to volume with id: %(volume_id)s') + messages.success(request, message % {'backup_name': backup_name, + 'volume_id': volume_id}) + return restore + except Exception: + msg = _('Unable to restore backup.') + exceptions.handle(request, msg) + return False diff --git a/openstack_dashboard/dashboards/project/volumes/backups/tables.py b/openstack_dashboard/dashboards/project/volumes/backups/tables.py new file mode 100644 index 000000000..c0c245588 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/backups/tables.py @@ -0,0 +1,130 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from django.core.urlresolvers import reverse +from django.template.defaultfilters import title # noqa +from django.utils import html +from django.utils import http +from django.utils import safestring +from django.utils.translation import ugettext_lazy as _ + +from horizon import tables + +from openstack_dashboard import api +from openstack_dashboard.api import cinder + + +DELETABLE_STATES = ("available", "error",) + + +class BackupVolumeNameColumn(tables.Column): + def get_raw_data(self, backup): + volume = backup.volume + if volume: + volume_name = volume.name + volume_name = html.escape(volume_name) + else: + volume_name = _("Unknown") + return safestring.mark_safe(volume_name) + + def get_link_url(self, backup): + volume = backup.volume + if volume: + volume_id = volume.id + return reverse(self.link, args=(volume_id,)) + + +class DeleteBackup(tables.DeleteAction): + data_type_singular = _("Volume Backup") + data_type_plural = _("Volume Backups") + action_past = _("Scheduled deletion of") + policy_rules = (("volume", "backup:delete"),) + + def delete(self, request, obj_id): + api.cinder.volume_backup_delete(request, obj_id) + + def allowed(self, request, volume=None): + if volume: + return volume.status in DELETABLE_STATES + return True + + +class RestoreBackup(tables.LinkAction): + name = "restore" + verbose_name = _("Restore Backup") + classes = ("ajax-modal",) + policy_rules = (("volume", "backup:restore"),) + + def allowed(self, request, volume=None): + return volume.status == "available" + + def get_link_url(self, datum): + backup_id = datum.id + backup_name = datum.name + volume_id = getattr(datum, 'volume_id', None) + url = reverse("horizon:project:volumes:backups:restore", + args=(backup_id,)) + url += '?%s' % http.urlencode({'backup_name': backup_name, + 'volume_id': volume_id}) + return url + + +class UpdateRow(tables.Row): + ajax = True + + def get_data(self, request, backup_id): + backup = cinder.volume_backup_get(request, backup_id) + try: + backup.volume = cinder.volume_get(request, + backup.volume_id) + except Exception: + pass + return backup + + +def get_size(backup): + return _("%sGB") % backup.size + + +class BackupsTable(tables.DataTable): + STATUS_CHOICES = ( + ("available", True), + ("creating", None), + ("restoring", None), + ("error", False), + ) + name = tables.Column("name", + verbose_name=_("Name"), + link="horizon:project:volumes:backups:detail") + description = tables.Column("description", + verbose_name=_("Description"), + truncate=40) + size = tables.Column(get_size, + verbose_name=_("Size"), + attrs={'data-type': 'size'}) + status = tables.Column("status", + filters=(title,), + verbose_name=_("Status"), + status=True, + status_choices=STATUS_CHOICES) + volume_name = BackupVolumeNameColumn("name", + verbose_name=_("Volume Name"), + link="horizon:project" + ":volumes:volumes:detail") + + class Meta: + name = "volume_backups" + verbose_name = _("Volume Backups") + status_columns = ("status",) + row_class = UpdateRow + table_actions = (DeleteBackup,) + row_actions = (RestoreBackup, DeleteBackup) diff --git a/openstack_dashboard/dashboards/project/volumes/backups/tabs.py b/openstack_dashboard/dashboards/project/volumes/backups/tabs.py new file mode 100644 index 000000000..87bcd4c44 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/backups/tabs.py @@ -0,0 +1,44 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +from django.core.urlresolvers import reverse +from django.utils.translation import ugettext_lazy as _ + +from horizon import exceptions +from horizon import tabs + +from openstack_dashboard.api import cinder + + +class BackupOverviewTab(tabs.Tab): + name = _("Overview") + slug = "overview" + template_name = ("project/volumes/backups/" + "_detail_overview.html") + + def get_context_data(self, request): + try: + backup = self.tab_group.kwargs['backup'] + volume = cinder.volume_get(request, backup.volume_id) + return {'backup': backup, + 'volume': volume} + except Exception: + redirect = reverse('horizon:project:volumes:index') + exceptions.handle(self.request, + _('Unable to retrieve backup details.'), + redirect=redirect) + + +class BackupDetailTabs(tabs.TabGroup): + slug = "backup_details" + tabs = (BackupOverviewTab,) diff --git a/openstack_dashboard/dashboards/project/volumes/backups/tests.py b/openstack_dashboard/dashboards/project/volumes/backups/tests.py new file mode 100644 index 000000000..8b22b140d --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/backups/tests.py @@ -0,0 +1,186 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from django.core.urlresolvers import reverse +from django import http +from django.utils.http import urlencode +from mox import IsA # noqa + +from openstack_dashboard import api +from openstack_dashboard.test import helpers as test +from openstack_dashboard.usage import quotas + + +INDEX_URL = reverse('horizon:project:volumes:index') +VOLUME_BACKUPS_TAB_URL = reverse('horizon:project:volumes:backups_tab') + + +class VolumeBackupsViewTests(test.TestCase): + + @test.create_stubs({api.cinder: ('volume_backup_create',)}) + def test_create_backup_post(self): + volume = self.volumes.first() + backup = self.cinder_volume_backups.first() + + api.cinder.volume_backup_create(IsA(http.HttpRequest), + volume.id, + backup.container_name, + backup.name, + backup.description) \ + .AndReturn(backup) + self.mox.ReplayAll() + + formData = {'method': 'CreateBackupForm', + 'tenant_id': self.tenant.id, + 'volume_id': volume.id, + 'container_name': backup.container_name, + 'name': backup.name, + 'description': backup.description} + url = reverse('horizon:project:volumes:volumes:create_backup', + args=[volume.id]) + res = self.client.post(url, formData) + + self.assertNoFormErrors(res) + self.assertMessageCount(error=0, warning=0) + self.assertRedirectsNoFollow(res, VOLUME_BACKUPS_TAB_URL) + + @test.create_stubs({api.nova: ('server_list',), + api.cinder: ('volume_snapshot_list', + 'volume_list', + 'volume_backup_supported', + 'volume_backup_list', + 'volume_backup_delete'), + quotas: ('tenant_quota_usages',)}) + def test_delete_volume_backup(self): + vol_backups = self.cinder_volume_backups.list() + volumes = self.cinder_volumes.list() + backup = self.cinder_volume_backups.first() + + api.cinder.volume_backup_supported(IsA(http.HttpRequest)). \ + MultipleTimes().AndReturn(True) + api.cinder.volume_backup_list(IsA(http.HttpRequest)). \ + AndReturn(vol_backups) + api.cinder.volume_list(IsA(http.HttpRequest)). \ + AndReturn(volumes) + api.cinder.volume_backup_delete(IsA(http.HttpRequest), backup.id) + + api.cinder.volume_list(IsA(http.HttpRequest), search_opts=None). \ + AndReturn(volumes) + api.nova.server_list(IsA(http.HttpRequest), search_opts=None). \ + AndReturn([self.servers.list(), False]) + api.cinder.volume_snapshot_list(IsA(http.HttpRequest)). \ + AndReturn([]) + api.cinder.volume_list(IsA(http.HttpRequest)). \ + AndReturn(volumes) + api.cinder.volume_backup_list(IsA(http.HttpRequest)). \ + AndReturn(vol_backups) + api.cinder.volume_list(IsA(http.HttpRequest)). \ + AndReturn(volumes) + quotas.tenant_quota_usages(IsA(http.HttpRequest)).MultipleTimes(). \ + AndReturn(self.quota_usages.first()) + self.mox.ReplayAll() + + formData = {'action': + 'volume_backups__delete__%s' % backup.id} + res = self.client.post(INDEX_URL + + "?tab=volumes_and_snapshots__backups_tab", + formData, follow=True) + + self.assertIn("Scheduled deletion of Volume Backup: backup1", + [m.message for m in res.context['messages']]) + + @test.create_stubs({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) + + api.cinder.volume_backup_get(IsA(http.HttpRequest), backup.id). \ + AndReturn(backup) + api.cinder.volume_get(IsA(http.HttpRequest), backup.volume_id). \ + AndReturn(volume) + self.mox.ReplayAll() + + url = reverse('horizon:project:volumes:backups:detail', + args=[backup.id]) + res = self.client.get(url) + + self.assertContains(res, + "<h2>Volume Backup Details: %s</h2>" % + backup.name, + 1, 200) + self.assertContains(res, "<dd>%s</dd>" % backup.name, 1, 200) + self.assertContains(res, "<dd>%s</dd>" % backup.id, 1, 200) + self.assertContains(res, "<dd>Available</dd>", 1, 200) + + @test.create_stubs({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() + + api.cinder.volume_backup_get(IsA(http.HttpRequest), backup.id).\ + AndRaise(self.exceptions.cinder) + self.mox.ReplayAll() + + url = reverse('horizon:project:volumes:backups:detail', + args=[backup.id]) + res = self.client.get(url) + + self.assertNoFormErrors(res) + self.assertMessageCount(error=1) + self.assertRedirectsNoFollow(res, INDEX_URL) + + @test.create_stubs({api.cinder: ('volume_backup_get', 'volume_get')}) + def test_volume_backup_detail_with_volume_get_exception(self): + # Test to verify redirect if get volume fails + backup = self.cinder_volume_backups.first() + + api.cinder.volume_backup_get(IsA(http.HttpRequest), backup.id). \ + AndReturn(backup) + api.cinder.volume_get(IsA(http.HttpRequest), backup.volume_id). \ + AndRaise(self.exceptions.cinder) + self.mox.ReplayAll() + + url = reverse('horizon:project:volumes:backups:detail', + args=[backup.id]) + res = self.client.get(url) + + self.assertNoFormErrors(res) + self.assertMessageCount(error=1) + self.assertRedirectsNoFollow(res, INDEX_URL) + + @test.create_stubs({api.cinder: ('volume_list', + 'volume_backup_restore',)}) + def test_restore_backup(self): + backup = self.cinder_volume_backups.first() + volumes = self.cinder_volumes.list() + + api.cinder.volume_list(IsA(http.HttpRequest)). \ + AndReturn(volumes) + api.cinder.volume_backup_restore(IsA(http.HttpRequest), + backup.id, + backup.volume_id). \ + AndReturn(backup) + self.mox.ReplayAll() + + formData = {'method': 'RestoreBackupForm', + 'backup_id': backup.id, + 'backup_name': backup.name, + 'volume_id': backup.volume_id} + url = reverse('horizon:project:volumes:backups:restore', + args=[backup.id]) + url += '?%s' % urlencode({'backup_name': backup.name, + 'volume_id': backup.volume_id}) + res = self.client.post(url, formData) + + self.assertNoFormErrors(res) + self.assertMessageCount(success=1) + self.assertRedirectsNoFollow(res, INDEX_URL) diff --git a/openstack_dashboard/dashboards/project/volumes/backups/urls.py b/openstack_dashboard/dashboards/project/volumes/backups/urls.py new file mode 100644 index 000000000..9d3d9ab92 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/backups/urls.py @@ -0,0 +1,30 @@ +# 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 patterns # noqa +from django.conf.urls import url # noqa + +from openstack_dashboard.dashboards.project.volumes.backups import views + + +VIEWS_MOD = ('openstack_dashboard.dashboards.project' + '.volumes.backups.views') + + +urlpatterns = patterns(VIEWS_MOD, + url(r'^(?P<backup_id>[^/]+)/$', + views.BackupDetailView.as_view(), + name='detail'), + url(r'^(?P<backup_id>[^/]+)/restore/$', + views.RestoreBackupView.as_view(), + name='restore'), +) diff --git a/openstack_dashboard/dashboards/project/volumes/backups/views.py b/openstack_dashboard/dashboards/project/volumes/backups/views.py new file mode 100644 index 000000000..a04fee850 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/backups/views.py @@ -0,0 +1,88 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from django.core.urlresolvers import reverse +from django.core.urlresolvers import reverse_lazy +from django.utils.translation import ugettext_lazy as _ + +from horizon import exceptions +from horizon import forms +from horizon import tabs +from horizon.utils import memoized + +from openstack_dashboard import api +from openstack_dashboard.dashboards.project.volumes.backups \ + import forms as backup_forms +from openstack_dashboard.dashboards.project.volumes.backups \ + import tabs as backup_tabs + + +class CreateBackupView(forms.ModalFormView): + form_class = backup_forms.CreateBackupForm + template_name = 'project/volumes/backups/create_backup.html' + success_url = reverse_lazy("horizon:project:volumes:backups_tab") + + def get_context_data(self, **kwargs): + context = super(CreateBackupView, self).get_context_data(**kwargs) + context['volume_id'] = self.kwargs['volume_id'] + return context + + def get_initial(self): + return {"volume_id": self.kwargs["volume_id"]} + + +class BackupDetailView(tabs.TabView): + tab_group_class = backup_tabs.BackupDetailTabs + template_name = 'project/volumes/backups/detail.html' + + def get_context_data(self, **kwargs): + context = super(BackupDetailView, self).get_context_data(**kwargs) + context["backup"] = self.get_data() + return context + + @memoized.memoized_method + def get_data(self): + try: + backup_id = self.kwargs['backup_id'] + backup = api.cinder.volume_backup_get(self.request, + backup_id) + except Exception: + redirect = reverse('horizon:project:volumes:index') + exceptions.handle(self.request, + _('Unable to retrieve backup details.'), + redirect=redirect) + return backup + + def get_tabs(self, request, *args, **kwargs): + backup = self.get_data() + return self.tab_group_class(request, backup=backup, **kwargs) + + +class RestoreBackupView(forms.ModalFormView): + form_class = backup_forms.RestoreBackupForm + template_name = 'project/volumes/backups/restore_backup.html' + success_url = reverse_lazy('horizon:project:volumes:index') + + def get_context_data(self, **kwargs): + context = super(RestoreBackupView, self).get_context_data(**kwargs) + context['backup_id'] = self.kwargs['backup_id'] + return context + + def get_initial(self): + backup_id = self.kwargs['backup_id'] + backup_name = self.request.GET.get('backup_name') + volume_id = self.request.GET.get('volume_id') + return { + 'backup_id': backup_id, + 'backup_name': backup_name, + 'volume_id': volume_id, + } diff --git a/openstack_dashboard/dashboards/project/volumes/snapshots/tests.py b/openstack_dashboard/dashboards/project/volumes/snapshots/tests.py index 426b7ba9f..858d8fea1 100644 --- a/openstack_dashboard/dashboards/project/volumes/snapshots/tests.py +++ b/openstack_dashboard/dashboards/project/volumes/snapshots/tests.py @@ -107,13 +107,18 @@ class VolumeSnapshotsViewTests(test.TestCase): @test.create_stubs({api.nova: ('server_list',), api.cinder: ('volume_snapshot_list', 'volume_list', + 'volume_backup_supported', + 'volume_backup_list', 'volume_snapshot_delete'), quotas: ('tenant_quota_usages',)}) def test_delete_volume_snapshot(self): vol_snapshots = self.cinder_volume_snapshots.list() volumes = self.cinder_volumes.list() + vol_backups = self.cinder_volume_backups.list() snapshot = self.cinder_volume_snapshots.first() + api.cinder.volume_backup_supported(IsA(http.HttpRequest)). \ + MultipleTimes().AndReturn(True) api.cinder.volume_snapshot_list(IsA(http.HttpRequest)). \ AndReturn(vol_snapshots) api.cinder.volume_list(IsA(http.HttpRequest)). \ @@ -128,6 +133,10 @@ class VolumeSnapshotsViewTests(test.TestCase): AndReturn([]) api.cinder.volume_list(IsA(http.HttpRequest)). \ AndReturn(volumes) + api.cinder.volume_backup_list(IsA(http.HttpRequest)). \ + AndReturn(vol_backups) + api.cinder.volume_list(IsA(http.HttpRequest)). \ + AndReturn(volumes) quotas.tenant_quota_usages(IsA(http.HttpRequest)).MultipleTimes(). \ AndReturn(self.quota_usages.first()) self.mox.ReplayAll() diff --git a/openstack_dashboard/dashboards/project/volumes/tabs.py b/openstack_dashboard/dashboards/project/volumes/tabs.py index 3b9e426a3..e067c9f51 100644 --- a/openstack_dashboard/dashboards/project/volumes/tabs.py +++ b/openstack_dashboard/dashboards/project/volumes/tabs.py @@ -20,6 +20,8 @@ from horizon import tabs from openstack_dashboard import api +from openstack_dashboard.dashboards.project.volumes.backups \ + import tables as backups_tables from openstack_dashboard.dashboards.project.volumes.snapshots \ import tables as vol_snapshot_tables from openstack_dashboard.dashboards.project.volumes.volumes \ @@ -95,7 +97,30 @@ class SnapshotTab(tabs.TableTab): return snapshots +class BackupsTab(tabs.TableTab, VolumeTableMixIn): + table_classes = (backups_tables.BackupsTable,) + name = _("Volume Backups") + slug = "backups_tab" + template_name = ("horizon/common/_detail_table.html") + + def allowed(self, request): + return api.cinder.volume_backup_supported(self.request) + + def get_volume_backups_data(self): + try: + backups = api.cinder.volume_backup_list(self.request) + volumes = api.cinder.volume_list(self.request) + volumes = dict((v.id, v) for v in volumes) + for backup in backups: + backup.volume = volumes.get(backup.volume_id) + except Exception: + backups = [] + exceptions.handle(self.request, _("Unable to retrieve " + "volume backups.")) + return backups + + class VolumeAndSnapshotTabs(tabs.TabGroup): slug = "volumes_and_snapshots" - tabs = (VolumeTab, SnapshotTab,) + tabs = (VolumeTab, SnapshotTab, BackupsTab) sticky = True diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/backups/_create_backup.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/backups/_create_backup.html new file mode 100644 index 000000000..21c0d34ef --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/backups/_create_backup.html @@ -0,0 +1,26 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} +{% load url from future %} + +{% block form_id %}{% endblock %} +{% block form_action %}{% url 'horizon:project:volumes:volumes:create_backup' volume_id %}{% endblock %} + +{% block modal_id %}create_volume_backup_modal{% endblock %} +{% block modal-header %}{% trans "Create Volume Backup" %}{% endblock %} + +{% block modal-body %} + <div class="left"> + <fieldset> + {% include "horizon/common/_form_fields.html" %} + </fieldset> + </div> + <div class="right"> + <p><strong>{% trans "Volume Backup" %}</strong>: {% trans "Volume Backups are stored using the Object Storage service. You must have this service activated in order to create a backup." %}</p> + <p>{% trans "If no container name is provided, a default container named volumebackups will be provisioned for you. Backups will be the same size as the volume they originate from." %}</p> + </div> +{% endblock %} + +{% block modal-footer %} + <input class="btn btn-primary pull-right" type="submit" value="{% trans "Create Volume Backup" %}" /> + <a href="{% url 'horizon:project:volumes:index' %}" class="btn secondary cancel close">{% trans "Cancel" %}</a> +{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/backups/_detail_overview.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/backups/_detail_overview.html new file mode 100644 index 000000000..8bb859763 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/backups/_detail_overview.html @@ -0,0 +1,51 @@ +{% load i18n sizeformat parse_date %} +{% load url from future %} + +<h3>{% trans "Volume Backup Overview" %}: {{backup.display_name }}</h3> + +<div class="info row-fluid detail"> + <h4>{% trans "Info" %}</h4> + <hr class="header_rule"> + <dl> + <dt>{% trans "Name" %}</dt> + <dd>{{ backup.name }}</dd> + <dt>{% trans "ID" %}</dt> + <dd>{{ backup.id }}</dd> + {% if backup.description %} + <dt>{% trans "Description" %}</dt> + <dd>{{ backup.description }}</dd> + {% endif %} + <dt>{% trans "Status" %}</dt> + <dd>{{ backup.status|capfirst }}</dd> + <dt>{% trans "Volume" %}</dt> + <dd> + <a href="{% url 'horizon:project:volumes:volumes:detail' backup.volume_id %}"> + {{ volume.name }} + </a> + </dd> + + </dl> +</div> + +<div class="specs row-fluid detail"> + <h4>{% trans "Specs" %}</h4> + <hr class="header_rule"> + <dl> + <dt>{% trans "Size" %}</dt> + <dd>{{ backup.size }} {% trans "GB" %}</dd> + <dt>{% trans "Created" %}</dt> + <dd>{{ backup.created_at|parse_date }}</dd> + </dl> +</div> + + +<div class="status row-fluid detail"> + <h4>{% trans "Metadata" %}</h4> + <hr class="header_rule"> + <dl> + {% for key, value in backup.metadata.items %} + <dt>{{ key }}</dt> + <dd>{{ value }}</dd> + {% endfor %} + </dl> +</div> diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/backups/_restore_backup.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/backups/_restore_backup.html new file mode 100644 index 000000000..d0df6278c --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/backups/_restore_backup.html @@ -0,0 +1,26 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} +{% load url from future %} + +{% block form_id %}{% endblock %} +{% block form_action %}{% url 'horizon:project:volumes:backups:restore' backup_id %}{% endblock %} + +{% block modal_id %}restore_volume_backup_modal{% endblock %} +{% block modal-header %}{% trans "Restore Volume Backup" %}{% endblock %} + +{% block modal-body %} + <div class="left"> + <fieldset> + {% include "horizon/common/_form_fields.html" %} + </fieldset> + </div> + <div class="right"> + <p><strong>{% trans "Restore Backup" %}</strong>: {% trans "Select a volume to restore to." %}</p> + <p>{% trans "Optionally, you may choose to create a new volume." %}</p> + </div> +{% endblock %} + +{% block modal-footer %} + <input class="btn btn-primary pull-right" type="submit" value="{% trans "Restore Backup to Volume"%}" /> + <a href="{% url 'horizon:project:volumes:index' %}" class="btn secondary cancel close">{% trans "Cancel" %}</a> +{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/backups/create_backup.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/backups/create_backup.html new file mode 100644 index 000000000..abdc3bc4e --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/backups/create_backup.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Create Volume Backup" %}{% endblock %} + +{% block page_header %} + {% include "horizon/common/_page_header.html" with title=_("Create a Volume Backup") %} +{% endblock page_header %} + +{% block main %} + {% include 'project/volumes/backups/_create_backup.html' %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/backups/detail.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/backups/detail.html new file mode 100644 index 000000000..c195f0120 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/backups/detail.html @@ -0,0 +1,14 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Volume Backup Details" %}{% endblock %} + +{% block page_header %} + {% include "horizon/common/_page_header.html" with title=_("Volume Backup Details: ")|add:backup.name|default:_("Volume Backup Details:") %} +{% endblock page_header %} +{% block main %} +<div class="row-fluid"> + <div class="span12"> + {{ tab_group.render }} + </div> +</div> +{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/backups/restore_backup.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/backups/restore_backup.html new file mode 100644 index 000000000..ff053ea55 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/backups/restore_backup.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Restore Volume Backup" %}{% endblock %} + +{% block page_header %} + {% include "horizon/common/_page_header.html" with title=_("Restore a Volume Backup") %} +{% endblock page_header %} + +{% block main %} + {% include 'project/volumes/backups/_restore_backup.html' %} +{% endblock %}
\ No newline at end of file diff --git a/openstack_dashboard/dashboards/project/volumes/templates/volumes/index.html b/openstack_dashboard/dashboards/project/volumes/templates/volumes/index.html index 5df0fcaae..57333ed5e 100644 --- a/openstack_dashboard/dashboards/project/volumes/templates/volumes/index.html +++ b/openstack_dashboard/dashboards/project/volumes/templates/volumes/index.html @@ -1,9 +1,9 @@ {% extends 'base.html' %} {% load i18n %} -{% block title %}{% trans "Volumes & Snapshots" %}{% endblock %} +{% block title %}{% trans "Volumes" %}{% endblock %} {% block page_header %} - {% include "horizon/common/_page_header.html" with title=_("Volumes & Snapshots")%} + {% include "horizon/common/_page_header.html" with title=_("Volumes")%} {% endblock page_header %} {% block main %} diff --git a/openstack_dashboard/dashboards/project/volumes/test.py b/openstack_dashboard/dashboards/project/volumes/test.py index 5a1694447..405461c79 100644 --- a/openstack_dashboard/dashboards/project/volumes/test.py +++ b/openstack_dashboard/dashboards/project/volumes/test.py @@ -27,13 +27,19 @@ INDEX_URL = reverse('horizon:project:volumes:index') class VolumeAndSnapshotsTests(test.TestCase): @test.create_stubs({api.cinder: ('volume_list', - 'volume_snapshot_list',), + 'volume_snapshot_list', + 'volume_backup_supported', + 'volume_backup_list', + ), api.nova: ('server_list',), quotas: ('tenant_quota_usages',)}) - def test_index(self): + def _test_index(self, backup_supported=True): + vol_backups = self.cinder_volume_backups.list() vol_snaps = self.cinder_volume_snapshots.list() volumes = self.cinder_volumes.list() + api.cinder.volume_backup_supported(IsA(http.HttpRequest)).\ + MultipleTimes().AndReturn(backup_supported) api.cinder.volume_list(IsA(http.HttpRequest), search_opts=None).\ AndReturn(volumes) api.nova.server_list(IsA(http.HttpRequest), search_opts=None).\ @@ -41,6 +47,10 @@ class VolumeAndSnapshotsTests(test.TestCase): api.cinder.volume_snapshot_list(IsA(http.HttpRequest)).\ AndReturn(vol_snaps) api.cinder.volume_list(IsA(http.HttpRequest)).AndReturn(volumes) + if backup_supported: + api.cinder.volume_backup_list(IsA(http.HttpRequest)).\ + AndReturn(vol_backups) + api.cinder.volume_list(IsA(http.HttpRequest)).AndReturn(volumes) quotas.tenant_quota_usages(IsA(http.HttpRequest)).MultipleTimes(). \ AndReturn(self.quota_usages.first()) self.mox.ReplayAll() @@ -48,3 +58,9 @@ class VolumeAndSnapshotsTests(test.TestCase): res = self.client.get(INDEX_URL) self.assertEqual(res.status_code, 200) self.assertTemplateUsed(res, 'project/volumes/index.html') + + def test_index_back_supported(self): + self._test_index(backup_supported=True) + + def test_index_backup_not_supported(self): + self._test_index(backup_supported=False) diff --git a/openstack_dashboard/dashboards/project/volumes/urls.py b/openstack_dashboard/dashboards/project/volumes/urls.py index 4831f5fc0..c6e66a724 100644 --- a/openstack_dashboard/dashboards/project/volumes/urls.py +++ b/openstack_dashboard/dashboards/project/volumes/urls.py @@ -16,19 +16,23 @@ from django.conf.urls import include # noqa from django.conf.urls import patterns # noqa from django.conf.urls import url # noqa +from openstack_dashboard.dashboards.project.volumes.backups \ + import urls as backups_urls from openstack_dashboard.dashboards.project.volumes.snapshots \ import urls as snapshot_urls from openstack_dashboard.dashboards.project.volumes import views from openstack_dashboard.dashboards.project.volumes.volumes \ import urls as volume_urls - urlpatterns = patterns('', url(r'^$', views.IndexView.as_view(), name='index'), url(r'^\?tab=volumes_and_snapshots__snapshots_tab$', views.IndexView.as_view(), name='snapshots_tab'), url(r'^\?tab=volumes_and_snapshots__volumes_tab$', views.IndexView.as_view(), name='volumes_tab'), + url(r'^\?tab=volumes_and_snapshots__backups_tab$', + views.IndexView.as_view(), name='backups_tab'), url(r'', include(volume_urls, namespace='volumes')), + url(r'backups/', include(backups_urls, namespace='backups')), url(r'snapshots/', include(snapshot_urls, namespace='snapshots')), ) diff --git a/openstack_dashboard/dashboards/project/volumes/volumes/tables.py b/openstack_dashboard/dashboards/project/volumes/volumes/tables.py index 7568296ce..eaacbe8b0 100644 --- a/openstack_dashboard/dashboards/project/volumes/volumes/tables.py +++ b/openstack_dashboard/dashboards/project/volumes/volumes/tables.py @@ -162,6 +162,24 @@ class CreateSnapshot(tables.LinkAction): return volume.status in ("available", "in-use") +class CreateBackup(tables.LinkAction): + name = "backups" + verbose_name = _("Create Backup") + url = "horizon:project:volumes:volumes:create_backup" + classes = ("ajax-modal",) + policy_rules = (("volume", "backup:create"),) + + def get_policy_target(self, request, datum=None): + project_id = None + if datum: + project_id = getattr(datum, "os-vol-tenant-attr:tenant_id", None) + return {"project_id": project_id} + + def allowed(self, request, volume=None): + return (cinder.volume_backup_supported(request) and + volume.status == "available") + + class EditVolume(tables.LinkAction): name = "edit" verbose_name = _("Edit Volume") @@ -298,7 +316,7 @@ class VolumesTable(VolumesTableBase): row_class = UpdateRow table_actions = (CreateVolume, DeleteVolume, VolumesFilterAction) row_actions = (EditVolume, ExtendVolume, LaunchVolume, EditAttachments, - CreateSnapshot, DeleteVolume) + CreateSnapshot, CreateBackup, DeleteVolume) class DetachVolume(tables.BatchAction): diff --git a/openstack_dashboard/dashboards/project/volumes/volumes/tests.py b/openstack_dashboard/dashboards/project/volumes/volumes/tests.py index cdee82127..f893a98c5 100644 --- a/openstack_dashboard/dashboards/project/volumes/volumes/tests.py +++ b/openstack_dashboard/dashboards/project/volumes/volumes/tests.py @@ -706,6 +706,8 @@ class VolumeViewTests(test.TestCase): @test.create_stubs({cinder: ('volume_list', 'volume_snapshot_list', + 'volume_backup_supported', + 'volume_backup_list', 'volume_delete',), api.nova: ('server_list',), quotas: ('tenant_quota_usages',)}) @@ -715,6 +717,8 @@ class VolumeViewTests(test.TestCase): formData = {'action': 'volumes__delete__%s' % volume.id} + cinder.volume_backup_supported(IsA(http.HttpRequest)). \ + MultipleTimes().AndReturn(True) cinder.volume_list(IsA(http.HttpRequest), search_opts=None).\ AndReturn(volumes) cinder.volume_delete(IsA(http.HttpRequest), volume.id) @@ -724,6 +728,10 @@ class VolumeViewTests(test.TestCase): AndReturn(self.cinder_volume_snapshots.list()) cinder.volume_list(IsA(http.HttpRequest), search_opts=None).\ AndReturn(volumes) + cinder.volume_backup_list(IsA(http.HttpRequest)).\ + AndReturn(self.cinder_volume_backups.list()) + cinder.volume_list(IsA(http.HttpRequest)).\ + AndReturn(volumes) api.nova.server_list(IsA(http.HttpRequest), search_opts=None).\ AndReturn([self.servers.list(), False]) cinder.volume_list(IsA(http.HttpRequest)).AndReturn(volumes) @@ -739,6 +747,8 @@ class VolumeViewTests(test.TestCase): @test.create_stubs({cinder: ('volume_list', 'volume_snapshot_list', + 'volume_backup_supported', + 'volume_backup_list', 'volume_delete',), api.nova: ('server_list',), quotas: ('tenant_quota_usages',)}) @@ -750,6 +760,8 @@ class VolumeViewTests(test.TestCase): exc = self.exceptions.cinder.__class__(400, "error: dependent snapshots") + cinder.volume_backup_supported(IsA(http.HttpRequest)). \ + MultipleTimes().AndReturn(True) cinder.volume_list(IsA(http.HttpRequest), search_opts=None).\ AndReturn(volumes) cinder.volume_delete(IsA(http.HttpRequest), volume.id).\ @@ -763,6 +775,10 @@ class VolumeViewTests(test.TestCase): cinder.volume_snapshot_list(IsA(http.HttpRequest))\ .AndReturn(self.cinder_volume_snapshots.list()) cinder.volume_list(IsA(http.HttpRequest)).AndReturn(volumes) + cinder.volume_backup_list(IsA(http.HttpRequest)).\ + AndReturn(self.cinder_volume_backups.list()) + cinder.volume_list(IsA(http.HttpRequest)).\ + AndReturn(volumes) quotas.tenant_quota_usages(IsA(http.HttpRequest)).MultipleTimes().\ AndReturn(self.quota_usages.first()) @@ -857,7 +873,9 @@ class VolumeViewTests(test.TestCase): self.assertEqual(res.status_code, 200) @test.create_stubs({cinder: ('volume_list', - 'volume_snapshot_list'), + 'volume_snapshot_list', + 'volume_backup_supported', + 'volume_backup_list',), api.nova: ('server_list',), quotas: ('tenant_quota_usages',)}) def test_create_button_disabled_when_quota_exceeded(self): @@ -865,6 +883,8 @@ class VolumeViewTests(test.TestCase): quota_usages['volumes']['available'] = 0 volumes = self.cinder_volumes.list() + api.cinder.volume_backup_supported(IsA(http.HttpRequest)). \ + MultipleTimes().AndReturn(True) cinder.volume_list(IsA(http.HttpRequest), search_opts=None)\ .AndReturn(volumes) api.nova.server_list(IsA(http.HttpRequest), search_opts=None)\ @@ -872,6 +892,9 @@ class VolumeViewTests(test.TestCase): cinder.volume_snapshot_list(IsA(http.HttpRequest))\ .AndReturn(self.cinder_volume_snapshots.list()) cinder.volume_list(IsA(http.HttpRequest)).AndReturn(volumes) + cinder.volume_backup_list(IsA(http.HttpRequest))\ + .AndReturn(self.cinder_volume_backups.list()) + cinder.volume_list(IsA(http.HttpRequest)).AndReturn(volumes) quotas.tenant_quota_usages(IsA(http.HttpRequest))\ .MultipleTimes().AndReturn(quota_usages) diff --git a/openstack_dashboard/dashboards/project/volumes/volumes/urls.py b/openstack_dashboard/dashboards/project/volumes/volumes/urls.py index ced497b13..a2d8ce314 100644 --- a/openstack_dashboard/dashboards/project/volumes/volumes/urls.py +++ b/openstack_dashboard/dashboards/project/volumes/volumes/urls.py @@ -17,6 +17,8 @@ from django.conf.urls import url # noqa from openstack_dashboard.dashboards.project.volumes \ .volumes import views +from openstack_dashboard.dashboards.project.volumes.backups \ + import views as backup_views VIEWS_MOD = ('openstack_dashboard.dashboards.project.volumes.volumes.views') @@ -32,6 +34,9 @@ urlpatterns = patterns(VIEWS_MOD, url(r'^(?P<volume_id>[^/]+)/create_snapshot/$', views.CreateSnapshotView.as_view(), name='create_snapshot'), + url(r'^(?P<volume_id>[^/]+)/create_backup/$', + backup_views.CreateBackupView.as_view(), + name='create_backup'), url(r'^(?P<volume_id>[^/]+)/$', views.DetailView.as_view(), name='detail'), |