diff --git a/openstack_dashboard/api/base.py b/openstack_dashboard/api/base.py index c8d2ebdc2d..e7d67064aa 100644 --- a/openstack_dashboard/api/base.py +++ b/openstack_dashboard/api/base.py @@ -136,6 +136,10 @@ class APIResourceWrapper(object): obj[key] = getattr(self, key, None) return obj + @property + def name_or_id(self): + return self.name or '(%s)' % self.id[:13] + class APIDictWrapper(object): """Simple wrapper for api dictionaries diff --git a/openstack_dashboard/api/cinder.py b/openstack_dashboard/api/cinder.py index 4fe9792498..1587429c99 100644 --- a/openstack_dashboard/api/cinder.py +++ b/openstack_dashboard/api/cinder.py @@ -87,7 +87,7 @@ class Volume(BaseCinderAPIResourceWrapper): _attrs = ['id', 'name', 'description', 'size', 'status', 'created_at', 'volume_type', 'availability_zone', 'imageRef', 'bootable', 'snapshot_id', 'source_volid', 'attachments', 'tenant_name', - 'consistencygroup_id', 'os-vol-host-attr:host', + 'group_id', 'consistencygroup_id', 'os-vol-host-attr:host', 'os-vol-tenant-attr:tenant_id', 'metadata', 'volume_image_metadata', 'encrypted', 'transfer', 'multiattach'] @@ -172,6 +172,21 @@ class VolumePool(base.APIResourceWrapper): 'storage_protocol', 'extra_specs'] +class Group(base.APIResourceWrapper): + _attrs = ['id', 'status', 'availability_zone', 'created_at', 'name', + 'description', 'group_type', 'volume_types', + 'group_snapshot_id', 'source_group_id', 'replication_status'] + + +class GroupSnapshot(base.APIResourceWrapper): + _attrs = ['id', 'name', 'description', 'status', 'created_at', + 'group_id', 'group_type_id'] + + +class GroupType(base.APIResourceWrapper): + _attrs = ['id', 'name', 'description', 'is_public', 'group_specs'] + + def get_auth_params_from_request(request): auth_url = base.url_for(request, 'identity') cinder_urls = [] @@ -248,6 +263,13 @@ def get_microversion(request, features): 'cinder', features, api_versions.APIVersion, min_ver, max_ver)) +def _cinderclient_with_generic_groups(request): + version = get_microversion(request, 'groups') + if version is not None: + version = version.get_string() + return cinderclient(request, version=version) + + def version_get(): api_version = VERSIONS.get_active_version() return api_version['version'] @@ -289,7 +311,8 @@ def volume_list_paged(request, search_opts=None, marker=None, paginate=False, has_prev_data = False volumes = [] - c_client = cinderclient(request) + # To support filtering with group_id, we need to use the microversion. + c_client = _cinderclient_with_generic_groups(request) if c_client is None: return volumes, has_more_data, has_prev_data @@ -1079,3 +1102,162 @@ def volume_type_add_project_access(request, volume_type, project_id): def volume_type_remove_project_access(request, volume_type, project_id): return cinderclient(request).volume_type_access.remove_project_access( volume_type, project_id) + + +@profiler.trace +def group_type_list(request): + client = _cinderclient_with_generic_groups(request) + return [GroupType(t) for t in client.group_types.list()] + + +@profiler.trace +def group_type_get(request, group_type_id): + client = _cinderclient_with_generic_groups(request) + return GroupType(client.group_types.get(group_type_id)) + + +@profiler.trace +def group_type_create(request, name, description=None, is_public=None): + client = _cinderclient_with_generic_groups(request) + params = {'name': name} + if description is not None: + params['description'] = description + if is_public is not None: + params['is_public'] = is_public + return GroupType(client.group_types.create(**params)) + + +@profiler.trace +def group_type_update(request, group_type_id, data): + client = _cinderclient_with_generic_groups(request) + return GroupType(client.group_types.update(group_type_id, **data)) + + +@profiler.trace +def group_type_delete(request, group_type_id): + client = _cinderclient_with_generic_groups(request) + client.group_types.delete(group_type_id) + + +@profiler.trace +def group_type_spec_set(request, group_type_id, metadata): + client = _cinderclient_with_generic_groups(request) + client.group_types.set_keys(metadata) + + +@profiler.trace +def group_type_spec_unset(request, group_type_id, keys): + client = _cinderclient_with_generic_groups(request) + client.group_types.unset_keys(keys) + + +@profiler.trace +def group_list(request, search_opts=None): + client = _cinderclient_with_generic_groups(request) + return [Group(g) for g in client.groups.list(search_opts=search_opts)] + + +@profiler.trace +def group_list_with_vol_type_names(request, search_opts=None): + groups = group_list(request, search_opts) + vol_types = volume_type_list(request) + for group in groups: + group.volume_type_names = [] + for vol_type_id in group.volume_types: + for vol_type in vol_types: + if vol_type.id == vol_type_id: + group.volume_type_names.append(vol_type.name) + break + + return groups + + +@profiler.trace +def group_get(request, group_id): + client = _cinderclient_with_generic_groups(request) + group = client.groups.get(group_id) + return Group(group) + + +@profiler.trace +def group_get_with_vol_type_names(request, group_id): + group = group_get(request, group_id) + vol_types = volume_type_list(request) + group.volume_type_names = [] + for vol_type_id in group.volume_types: + for vol_type in vol_types: + if vol_type.id == vol_type_id: + group.volume_type_names.append(vol_type.name) + break + return group + + +@profiler.trace +def group_create(request, name, group_type, volume_types, + description=None, availability_zone=None): + client = _cinderclient_with_generic_groups(request) + params = {'name': name, + 'group_type': group_type, + # cinderclient expects a comma-separated list of volume types. + 'volume_types': ','.join(volume_types)} + if description is not None: + params['description'] = description + if availability_zone is not None: + params['availability_zone'] = availability_zone + return Group(client.groups.create(**params)) + + +@profiler.trace +def group_create_from_source(request, name, group_snapshot_id=None, + source_group_id=None, description=None, + user_id=None, project_id=None): + client = _cinderclient_with_generic_groups(request) + return Group(client.groups.create_from_src( + group_snapshot_id, source_group_id, name, description, + user_id, project_id)) + + +@profiler.trace +def group_delete(request, group_id, delete_volumes=False): + client = _cinderclient_with_generic_groups(request) + client.groups.delete(group_id, delete_volumes) + + +@profiler.trace +def group_update(request, group_id, name=None, description=None, + add_volumes=None, remove_volumes=None): + data = {} + if name is not None: + data['name'] = name + if description is not None: + data['description'] = description + if add_volumes: + # cinderclient expects a comma-separated list of volume types. + data['add_volumes'] = ','.join(add_volumes) + if remove_volumes: + # cinderclient expects a comma-separated list of volume types. + data['remove_volumes'] = ','.join(remove_volumes) + client = _cinderclient_with_generic_groups(request) + return client.groups.update(group_id, **data) + + +def group_snapshot_create(request, group_id, name, description=None): + client = _cinderclient_with_generic_groups(request) + return GroupSnapshot(client.group_snapshots.create(group_id, name, + description)) + + +def group_snapshot_get(request, group_snapshot_id): + client = _cinderclient_with_generic_groups(request) + return GroupSnapshot(client.group_snapshots.get(group_snapshot_id)) + + +def group_snapshot_list(request, search_opts=None): + client = _cinderclient_with_generic_groups(request) + return [GroupSnapshot(s) for s + in client.group_snapshots.list(search_opts=search_opts)] + + +def group_snapshot_delete(request, group_snapshot_id): + client = _cinderclient_with_generic_groups(request) + client.group_snapshots.delete(group_snapshot_id) diff --git a/openstack_dashboard/api/microversions.py b/openstack_dashboard/api/microversions.py index a9c26f7bd7..f36482e77f 100644 --- a/openstack_dashboard/api/microversions.py +++ b/openstack_dashboard/api/microversions.py @@ -37,6 +37,7 @@ MICROVERSION_FEATURES = { "auto_allocated_network": ["2.37", "2.42"], }, "cinder": { + "groups": ["3.27", "3.43", "3.48"], "consistency_groups": ["2.0", "3.10"], "message_list": ["3.5", "3.29"] } diff --git a/openstack_dashboard/dashboards/project/cg_snapshots/panel.py b/openstack_dashboard/dashboards/project/cg_snapshots/panel.py index 1defa39265..eae401de34 100644 --- a/openstack_dashboard/dashboards/project/cg_snapshots/panel.py +++ b/openstack_dashboard/dashboards/project/cg_snapshots/panel.py @@ -12,10 +12,17 @@ # License for the specific language governing permissions and limitations # under the License. +import logging + from django.utils.translation import ugettext_lazy as _ import horizon +from openstack_dashboard import api +from openstack_dashboard import policy + +LOG = logging.getLogger(__name__) + class CGSnapshots(horizon.Panel): name = _("Consistency Group Snapshots") @@ -25,3 +32,20 @@ class CGSnapshots(horizon.Panel): 'openstack.services.volumev3'), ) policy_rules = (("volume", "consistencygroup:get_all_cgsnapshots"),) + + def allowed(self, context): + request = context['request'] + try: + return ( + super(CGSnapshots, self).allowed(context) and + request.user.has_perms(self.permissions) and + policy.check(self.policy_rules, request) and + api.cinder.get_microversion(request, 'consistency_groups') and + not api.cinder.get_microversion(request, 'groups') + ) + except Exception: + LOG.error("Call to list enabled services failed. This is likely " + "due to a problem communicating with the Cinder " + "endpoint. Consistency Group Snapshot panel will not be " + "displayed.") + return False diff --git a/openstack_dashboard/dashboards/project/cgroups/panel.py b/openstack_dashboard/dashboards/project/cgroups/panel.py index f4650d29b4..bda7986fc0 100644 --- a/openstack_dashboard/dashboards/project/cgroups/panel.py +++ b/openstack_dashboard/dashboards/project/cgroups/panel.py @@ -12,10 +12,17 @@ # License for the specific language governing permissions and limitations # under the License. +import logging + from django.utils.translation import ugettext_lazy as _ import horizon +from openstack_dashboard import api +from openstack_dashboard import policy + +LOG = logging.getLogger(__name__) + class CGroups(horizon.Panel): name = _("Consistency Groups") @@ -25,3 +32,20 @@ class CGroups(horizon.Panel): 'openstack.services.volumev3'), ) policy_rules = (("volume", "consistencygroup:get_all"),) + + def allowed(self, context): + request = context['request'] + try: + return ( + super(CGroups, self).allowed(context) and + request.user.has_perms(self.permissions) and + policy.check(self.policy_rules, request) and + api.cinder.get_microversion(request, 'consistency_groups') and + not api.cinder.get_microversion(request, 'groups') + ) + except Exception: + LOG.error("Call to list enabled services failed. This is likely " + "due to a problem communicating with the Cinder " + "endpoint. Consistency Group panel will not be " + "displayed.") + return False diff --git a/openstack_dashboard/dashboards/project/vg_snapshots/__init__.py b/openstack_dashboard/dashboards/project/vg_snapshots/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openstack_dashboard/dashboards/project/vg_snapshots/forms.py b/openstack_dashboard/dashboards/project/vg_snapshots/forms.py new file mode 100644 index 0000000000..7b4bbbfc5f --- /dev/null +++ b/openstack_dashboard/dashboards/project/vg_snapshots/forms.py @@ -0,0 +1,75 @@ +# 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 + + +class CreateGroupForm(forms.SelfHandlingForm): + name = forms.CharField(max_length=255, label=_("Group Name")) + description = forms.CharField(max_length=255, + widget=forms.Textarea(attrs={'rows': 4}), + label=_("Description"), + required=False) + snapshot_source = forms.ChoiceField( + label=_("Use snapshot as a source"), + widget=forms.ThemableSelectWidget( + attrs={'class': 'snapshot-selector'}, + data_attrs=('name'), + transform=lambda x: "%s" % (x.name)), + required=False) + + def prepare_snapshot_source_field(self, request, vg_snapshot_id): + try: + vg_snapshot = cinder.group_snapshot_get(request, vg_snapshot_id) + self.fields['snapshot_source'].choices = ((vg_snapshot_id, + vg_snapshot),) + except Exception: + exceptions.handle(request, + _('Unable to load the specified snapshot.')) + + def __init__(self, request, *args, **kwargs): + super(CreateGroupForm, self).__init__(request, *args, **kwargs) + + # populate cgroup_id + vg_snapshot_id = kwargs.get('initial', {}).get('vg_snapshot_id', []) + self.fields['vg_snapshot_id'] = forms.CharField( + widget=forms.HiddenInput(), + initial=vg_snapshot_id) + self.prepare_snapshot_source_field(request, vg_snapshot_id) + + def handle(self, request, data): + try: + + message = _('Creating group "%s".') % data['name'] + group = cinder.group_create_from_source( + request, + data['name'], + group_snapshot_id=data['vg_snapshot_id'], + description=data['description']) + + messages.info(request, message) + return group + except Exception: + redirect = reverse("horizon:project:vg_snapshots:index") + msg = (_('Unable to create group "%s" from snapshot.') + % data['name']) + exceptions.handle(request, + msg, + redirect=redirect) diff --git a/openstack_dashboard/dashboards/project/vg_snapshots/panel.py b/openstack_dashboard/dashboards/project/vg_snapshots/panel.py new file mode 100644 index 0000000000..eef8cd94cf --- /dev/null +++ b/openstack_dashboard/dashboards/project/vg_snapshots/panel.py @@ -0,0 +1,49 @@ +# Copyright 2017 Rackspace, Inc. +# +# 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.utils.translation import ugettext_lazy as _ + +import horizon + +from openstack_dashboard import api +from openstack_dashboard import policy + +LOG = logging.getLogger(__name__) + + +class GroupSnapshots(horizon.Panel): + name = _("Group Snapshots") + slug = 'vg_snapshots' + permissions = ( + ('openstack.services.volume', 'openstack.services.volumev3'), + ) + policy_rules = (("volume", "group:get_all_group_snapshots"),) + + def allowed(self, context): + request = context['request'] + try: + return ( + super(GroupSnapshots, self).allowed(context) and + request.user.has_perms(self.permissions) and + policy.check(self.policy_rules, request) and + api.cinder.get_microversion(request, 'groups') + ) + except Exception: + LOG.error("Call to list enabled services failed. This is likely " + "due to a problem communicating with the Cinder " + "endpoint. Volume Group Snapshot panel will not be " + "displayed.") + return False diff --git a/openstack_dashboard/dashboards/project/vg_snapshots/tables.py b/openstack_dashboard/dashboards/project/vg_snapshots/tables.py new file mode 100644 index 0000000000..e173a9d3ac --- /dev/null +++ b/openstack_dashboard/dashboards/project/vg_snapshots/tables.py @@ -0,0 +1,141 @@ +# 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 pgettext_lazy +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.api import cinder +from openstack_dashboard import policy + + +class CreateGroup(policy.PolicyTargetMixin, tables.LinkAction): + name = "create_group" + verbose_name = _("Create Group") + url = "horizon:project:vg_snapshots:create_group" + classes = ("ajax-modal",) + policy_rules = (("volume", "group:create"),) + + +class DeleteGroupSnapshot(policy.PolicyTargetMixin, tables.DeleteAction): + name = "delete_vg_snapshot" + policy_rules = (("volume", "group:delete_group_snapshot"),) + + @staticmethod + def action_present(count): + return ungettext_lazy( + u"Delete Snapshot", + u"Delete Snapshots", + count + ) + + @staticmethod + def action_past(count): + return ungettext_lazy( + u"Scheduled deletion of Snapshot", + u"Scheduled deletion of Snapshots", + count + ) + + def delete(self, request, obj_id): + cinder.group_snapshot_delete(request, obj_id) + + +class UpdateRow(tables.Row): + ajax = True + + def get_data(self, request, vg_snapshot_id): + vg_snapshot = cinder.group_snapshot_get(request, vg_snapshot_id) + if getattr(vg_snapshot, 'group_id', None): + try: + vg_snapshot._group = cinder.group_get(request, + vg_snapshot.group_id) + except Exception: + exceptions.handle(request, _("Unable to retrieve group")) + vg_snapshot._group = None + return vg_snapshot + + +class GroupNameColumn(tables.WrappingColumn): + def get_raw_data(self, snapshot): + group = snapshot._group + return group.name_or_id if group else _("-") + + def get_link_url(self, snapshot): + group = snapshot._group + if group: + return reverse(self.link, args=(group.id,)) + + +class GroupSnapshotsFilterAction(tables.FilterAction): + + def filter(self, table, vg_snapshots, filter_string): + """Naive case-insensitive search.""" + query = filter_string.lower() + return [vg_snapshot for vg_snapshot in vg_snapshots + if query in vg_snapshot.name.lower()] + + +class GroupSnapshotsTable(tables.DataTable): + STATUS_CHOICES = ( + ("in-use", True), + ("available", True), + ("creating", None), + ("error", False), + ) + STATUS_DISPLAY_CHOICES = ( + ("available", + pgettext_lazy("Current status of Volume Group Snapshot", + u"Available")), + ("in-use", + pgettext_lazy("Current status of Volume Group Snapshot", + u"In-use")), + ("error", + pgettext_lazy("Current status of Volume Group Snapshot", + u"Error")), + ) + + name = tables.Column("name_or_id", + verbose_name=_("Name"), + link="horizon:project:vg_snapshots:detail") + description = tables.Column("description", + verbose_name=_("Description"), + truncate=40) + status = tables.Column("status", + verbose_name=_("Status"), + status=True, + status_choices=STATUS_CHOICES, + display_choices=STATUS_DISPLAY_CHOICES) + group = GroupNameColumn( + "name", + verbose_name=_("Group"), + link="horizon:project:volume_groups:detail") + + def get_object_id(self, vg_snapshot): + return vg_snapshot.id + + class Meta(object): + name = "volume_vg_snapshots" + verbose_name = _("Group Snapshots") + table_actions = (GroupSnapshotsFilterAction, + DeleteGroupSnapshot) + row_actions = (CreateGroup, + DeleteGroupSnapshot,) + row_class = UpdateRow + status_columns = ("status",) + permissions = [ + ('openstack.services.volume', 'openstack.services.volumev3') + ] diff --git a/openstack_dashboard/dashboards/project/vg_snapshots/tabs.py b/openstack_dashboard/dashboards/project/vg_snapshots/tabs.py new file mode 100644 index 0000000000..418f7e1a4d --- /dev/null +++ b/openstack_dashboard/dashboards/project/vg_snapshots/tabs.py @@ -0,0 +1,34 @@ +# 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 tabs + + +class OverviewTab(tabs.Tab): + name = _("Overview") + slug = "overview" + template_name = "project/vg_snapshots/_detail_overview.html" + + def get_context_data(self, request): + vg_snapshot = self.tab_group.kwargs['vg_snapshot'] + return {"vg_snapshot": vg_snapshot} + + def get_redirect_url(self): + return reverse('horizon:project:vg_snapshots:index') + + +class DetailTabs(tabs.TabGroup): + slug = "vg_snapshots_details" + tabs = (OverviewTab,) diff --git a/openstack_dashboard/dashboards/project/vg_snapshots/templates/vg_snapshots/_create.html b/openstack_dashboard/dashboards/project/vg_snapshots/templates/vg_snapshots/_create.html new file mode 100644 index 0000000000..7f00672c30 --- /dev/null +++ b/openstack_dashboard/dashboards/project/vg_snapshots/templates/vg_snapshots/_create.html @@ -0,0 +1,9 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} + +{% block modal-body-right %} +
+

{% blocktrans %}Create a Group that will contain newly created volumes cloned from each of the snapshots in the source Group Snapshot.{% endblocktrans %}

+ {% include "project/volumes/_volume_limits.html" with usages=usages %} +
+{% endblock %} diff --git a/openstack_dashboard/dashboards/project/vg_snapshots/templates/vg_snapshots/_detail_overview.html b/openstack_dashboard/dashboards/project/vg_snapshots/templates/vg_snapshots/_detail_overview.html new file mode 100644 index 0000000000..816ca3a89e --- /dev/null +++ b/openstack_dashboard/dashboards/project/vg_snapshots/templates/vg_snapshots/_detail_overview.html @@ -0,0 +1,50 @@ +{% load i18n sizeformat parse_date %} + +
+
+
{% trans "Name" %}
+
{{ vg_snapshot.name }}
+
{% trans "ID" %}
+
{{ vg_snapshot.id }}
+ {% if vg_snapshot.description %} +
{% trans "Description" %}
+
{{ vg_snapshot.description }}
+ {% endif %} +
{% trans "Status" %}
+
{{ vg_snapshot.status|capfirst }}
+
{% trans "Group" %}
+
+ + {% if vg_snapshot.vg_name %} + {{ vg_snapshot.vg_name }} + {% else %} + {{ vg_snapshot.group_id }} + {% endif %} + +
+
{% trans "Group Type" %}
+
{{ vg_snapshot.group_type_id }}
+
{% trans "Created" %}
+
{{ vg_snapshot.created_at|parse_isotime }}
+
+ +

{% trans "Snapshot Volume Types" %}

+
+
+ {% for vol_type_names in vg_snapshot.volume_type_names %} +
{{ vol_type_names }}
+ {% endfor %} +
+ +

{% trans "Snapshot Volumes" %}

+
+
+ {% for vol_names in vg_snapshot.volume_names %} +
{{ vol_names }}
+ {% empty %} +
+ {% trans "No assigned volumes" %} +
+ {% endfor %} +
+
diff --git a/openstack_dashboard/dashboards/project/vg_snapshots/templates/vg_snapshots/create.html b/openstack_dashboard/dashboards/project/vg_snapshots/templates/vg_snapshots/create.html new file mode 100644 index 0000000000..eda6f352cd --- /dev/null +++ b/openstack_dashboard/dashboards/project/vg_snapshots/templates/vg_snapshots/create.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{{ page_title }}{% endblock %} + +{% block main %} + {% include 'project/vg_snapshots/_create.html' %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/project/vg_snapshots/tests.py b/openstack_dashboard/dashboards/project/vg_snapshots/tests.py new file mode 100644 index 0000000000..a749de7151 --- /dev/null +++ b/openstack_dashboard/dashboards/project/vg_snapshots/tests.py @@ -0,0 +1,198 @@ +# 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 +import mock + +from openstack_dashboard.api import cinder +from openstack_dashboard.test import helpers as test + + +INDEX_URL = reverse('horizon:project:vg_snapshots:index') + + +class GroupSnapshotTests(test.TestCase): + @mock.patch.object(cinder, 'group_snapshot_get') + @mock.patch.object(cinder, 'group_create_from_source') + def test_create_group_from_snapshot(self, + mock_group_create_from_source, + mock_group_snapshot_get): + group = self.cinder_groups.first() + vg_snapshot = self.cinder_group_snapshots.first() + formData = {'vg_snapshot_id': vg_snapshot.id, + 'name': 'test VG SS Create', + 'description': 'test desc'} + + mock_group_snapshot_get.return_value = vg_snapshot + mock_group_create_from_source.return_value = group + + url = reverse('horizon:project:vg_snapshots:create_group', + args=[vg_snapshot.id]) + res = self.client.post(url, formData) + self.assertNoFormErrors(res) + self.assertRedirectsNoFollow( + res, reverse('horizon:project:volume_groups:index')) + + mock_group_snapshot_get.assert_called_once_with( + test.IsHttpRequest(), vg_snapshot.id) + mock_group_create_from_source.assert_called_once_with( + test.IsHttpRequest(), + formData['name'], + group_snapshot_id=formData['vg_snapshot_id'], + description=formData['description']) + + @mock.patch.object(cinder, 'group_snapshot_get') + @mock.patch.object(cinder, 'group_create_from_source') + def test_create_group_from_snapshot_exception( + self, + mock_group_create_from_source, + mock_group_snapshot_get): + vg_snapshot = self.cinder_group_snapshots.first() + new_cg_name = 'test VG SS Create' + formData = {'vg_snapshot_id': vg_snapshot.id, + 'name': new_cg_name, + 'description': 'test desc'} + + mock_group_snapshot_get.return_value = vg_snapshot + mock_group_create_from_source.side_effect = \ + self.exceptions.cinder + + url = reverse('horizon:project:vg_snapshots:create_group', + args=[vg_snapshot.id]) + res = self.client.post(url, formData) + self.assertNoFormErrors(res) + # There are a bunch of backslashes for formatting in the message from + # the response, so remove them when validating the error message. + self.assertIn('Unable to create group "%s" from snapshot.' + % new_cg_name, + res.cookies.output().replace('\\', '')) + self.assertRedirectsNoFollow(res, INDEX_URL) + + mock_group_snapshot_get.assert_called_once_with( + test.IsHttpRequest(), vg_snapshot.id) + mock_group_create_from_source.assert_called_once_with( + test.IsHttpRequest(), + formData['name'], + group_snapshot_id=formData['vg_snapshot_id'], + description=formData['description']) + + @mock.patch.object(cinder, 'group_snapshot_list') + @mock.patch.object(cinder, 'group_snapshot_delete') + @mock.patch.object(cinder, 'group_list') + def test_delete_group_snapshot(self, + mock_group_list, + mock_group_snapshot_delete, + mock_group_snapshot_list): + vg_snapshots = self.cinder_group_snapshots.list() + vg_snapshot = self.cinder_group_snapshots.first() + + mock_group_snapshot_list.return_value = vg_snapshots + mock_group_snapshot_delete.return_value = None + mock_group_list.return_value = self.cinder_groups.list() + + form_data = {'action': 'volume_vg_snapshots__delete_vg_snapshot__%s' + % vg_snapshot.id} + res = self.client.post(INDEX_URL, form_data, follow=True) + self.assertEqual(res.status_code, 200) + self.assertIn("Scheduled deletion of Snapshot: %s" % vg_snapshot.name, + [m.message for m in res.context['messages']]) + + self.assert_mock_multiple_calls_with_same_arguments( + mock_group_snapshot_list, 2, + mock.call(test.IsHttpRequest())) + mock_group_snapshot_delete.assert_called_once_with( + test.IsHttpRequest(), vg_snapshot.id) + self.assert_mock_multiple_calls_with_same_arguments( + mock_group_list, 2, + mock.call(test.IsHttpRequest())) + + @mock.patch.object(cinder, 'group_snapshot_list') + @mock.patch.object(cinder, 'group_snapshot_delete') + @mock.patch.object(cinder, 'group_list') + def test_delete_group_snapshot_exception(self, + mock_group_list, + mock_group_snapshot_delete, + mock_group_snapshot_list): + vg_snapshots = self.cinder_group_snapshots.list() + vg_snapshot = self.cinder_group_snapshots.first() + + mock_group_snapshot_list.return_value = vg_snapshots + mock_group_snapshot_delete.side_effect = self.exceptions.cinder + mock_group_list.return_value = self.cinder_groups.list() + + form_data = {'action': 'volume_vg_snapshots__delete_vg_snapshot__%s' + % vg_snapshot.id} + res = self.client.post(INDEX_URL, form_data, follow=True) + self.assertEqual(res.status_code, 200) + self.assertIn("Unable to delete snapshot: %s" % vg_snapshot.name, + [m.message for m in res.context['messages']]) + + self.assert_mock_multiple_calls_with_same_arguments( + mock_group_snapshot_list, 2, + mock.call(test.IsHttpRequest())) + mock_group_snapshot_delete.assert_called_once_with( + test.IsHttpRequest(), vg_snapshot.id) + self.assert_mock_multiple_calls_with_same_arguments( + mock_group_list, 2, + mock.call(test.IsHttpRequest())) + + @mock.patch.object(cinder, 'group_snapshot_get') + @mock.patch.object(cinder, 'group_get') + @mock.patch.object(cinder, 'volume_type_get') + @mock.patch.object(cinder, 'volume_list') + def test_detail_view(self, + mock_volume_list, + mock_volume_type_get, + mock_group_get, + mock_group_snapshot_get): + vg_snapshot = self.cinder_group_snapshots.first() + group = self.cinder_groups.first() + volume_type = self.cinder_volume_types.first() + volumes = self.cinder_volumes.list() + + mock_group_snapshot_get.return_value = vg_snapshot + mock_group_get.return_value = group + mock_volume_type_get.return_value = volume_type + mock_volume_list.return_value = volumes + + url = reverse( + 'horizon:project:vg_snapshots:detail', + args=[vg_snapshot.id]) + res = self.client.get(url) + self.assertNoFormErrors(res) + self.assertEqual(res.status_code, 200) + + mock_group_snapshot_get.assert_called_once_with( + test.IsHttpRequest(), vg_snapshot.id) + mock_group_get.assert_called_once_with( + test.IsHttpRequest(), group.id) + mock_volume_type_get.assert_called_once_with( + test.IsHttpRequest(), volume_type.id) + search_opts = {'group_id': group.id} + mock_volume_list.assert_called_once_with( + test.IsHttpRequest(), search_opts=search_opts) + + @mock.patch.object(cinder, 'group_snapshot_get') + def test_detail_view_with_exception(self, mock_group_snapshot_get): + vg_snapshot = self.cinder_group_snapshots.first() + + mock_group_snapshot_get.side_effect = self.exceptions.cinder + + url = reverse( + 'horizon:project:vg_snapshots:detail', + args=[vg_snapshot.id]) + res = self.client.get(url) + self.assertNoFormErrors(res) + self.assertRedirectsNoFollow(res, INDEX_URL) + + mock_group_snapshot_get.assert_called_once_with( + test.IsHttpRequest(), vg_snapshot.id) diff --git a/openstack_dashboard/dashboards/project/vg_snapshots/urls.py b/openstack_dashboard/dashboards/project/vg_snapshots/urls.py new file mode 100644 index 0000000000..859547ffab --- /dev/null +++ b/openstack_dashboard/dashboards/project/vg_snapshots/urls.py @@ -0,0 +1,25 @@ +# 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.project.vg_snapshots import views + +urlpatterns = [ + url(r'^$', views.IndexView.as_view(), name='index'), + url(r'^(?P[^/]+)/detail/$', + views.DetailView.as_view(), + name='detail'), + url(r'^(?P[^/]+)/create_group/$', + views.CreateGroupView.as_view(), + name='create_group'), +] diff --git a/openstack_dashboard/dashboards/project/vg_snapshots/views.py b/openstack_dashboard/dashboards/project/vg_snapshots/views.py new file mode 100644 index 0000000000..ce28ae83c0 --- /dev/null +++ b/openstack_dashboard/dashboards/project/vg_snapshots/views.py @@ -0,0 +1,158 @@ +# 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.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 import tabs +from horizon.utils import memoized + +from openstack_dashboard import api +from openstack_dashboard.api import cinder +from openstack_dashboard.usage import quotas + +from openstack_dashboard.dashboards.project.vg_snapshots \ + import forms as vg_snapshot_forms +from openstack_dashboard.dashboards.project.vg_snapshots \ + import tables as vg_snapshot_tables +from openstack_dashboard.dashboards.project.vg_snapshots \ + import tabs as vg_snapshot_tabs + +GROUP_INFO_FIELDS = ("name", + "description") + +INDEX_URL = "horizon:project:vg_snapshots:index" + + +class IndexView(tables.DataTableView): + table_class = vg_snapshot_tables.GroupSnapshotsTable + page_title = _("Group Snapshots") + + def get_data(self): + try: + vg_snapshots = api.cinder.group_snapshot_list(self.request) + except Exception: + vg_snapshots = [] + exceptions.handle(self.request, _("Unable to retrieve " + "volume group snapshots.")) + try: + groups = dict((g.id, g) for g + in api.cinder.group_list(self.request)) + except Exception: + groups = {} + exceptions.handle(self.request, + _("Unable to retrieve volume groups.")) + for gs in vg_snapshots: + gs._group = groups.get(gs.group_id) + return vg_snapshots + + +class DetailView(tabs.TabView): + tab_group_class = vg_snapshot_tabs.DetailTabs + template_name = 'horizon/common/_detail.html' + page_title = "{{ vg_snapshot.name|default:vg_snapshot.id }}" + + def get_context_data(self, **kwargs): + context = super(DetailView, self).get_context_data(**kwargs) + vg_snapshot = self.get_data() + table = vg_snapshot_tables.GroupSnapshotsTable(self.request) + context["vg_snapshot"] = vg_snapshot + context["url"] = self.get_redirect_url() + context["actions"] = table.render_row_actions(vg_snapshot) + return context + + @memoized.memoized_method + def get_data(self): + try: + vg_snapshot_id = self.kwargs['vg_snapshot_id'] + vg_snapshot = api.cinder.group_snapshot_get(self.request, + vg_snapshot_id) + + group_id = vg_snapshot.group_id + group = api.cinder.group_get(self.request, group_id) + vg_snapshot.vg_name = group.name + vg_snapshot.volume_type_names = [] + for vol_type_id in group.volume_types: + vol_type = api.cinder.volume_type_get(self.request, + vol_type_id) + vg_snapshot.volume_type_names.append(vol_type.name) + + vg_snapshot.volume_names = [] + search_opts = {'group_id': group_id} + volumes = api.cinder.volume_list(self.request, + search_opts=search_opts) + for volume in volumes: + vg_snapshot.volume_names.append(volume.name) + + except Exception: + redirect = self.get_redirect_url() + exceptions.handle(self.request, + _('Unable to retrieve group snapshot details.'), + redirect=redirect) + return vg_snapshot + + @staticmethod + def get_redirect_url(): + return reverse(INDEX_URL) + + def get_tabs(self, request, *args, **kwargs): + vg_snapshot = self.get_data() + return self.tab_group_class(request, vg_snapshot=vg_snapshot, **kwargs) + + +class CreateGroupView(forms.ModalFormView): + form_class = vg_snapshot_forms.CreateGroupForm + template_name = 'project/vg_snapshots/create.html' + submit_url = "horizon:project:vg_snapshots:create_group" + success_url = reverse_lazy('horizon:project:volume_groups:index') + page_title = _("Create Volume Group") + + def get_context_data(self, **kwargs): + context = super(CreateGroupView, self).get_context_data(**kwargs) + context['vg_snapshot_id'] = self.kwargs['vg_snapshot_id'] + args = (self.kwargs['vg_snapshot_id'],) + context['submit_url'] = reverse(self.submit_url, args=args) + try: + # get number of volumes we will be creating + vg_snapshot = cinder.group_snapshot_get( + self.request, context['vg_snapshot_id']) + + group_id = vg_snapshot.group_id + + search_opts = {'group_id': group_id} + volumes = api.cinder.volume_list(self.request, + search_opts=search_opts) + num_volumes = len(volumes) + usages = quotas.tenant_limit_usages(self.request) + + if usages['totalVolumesUsed'] + num_volumes > \ + usages['maxTotalVolumes']: + raise ValueError(_('Unable to create group due to ' + 'exceeding volume quota limit.')) + else: + usages['numRequestedItems'] = num_volumes + context['usages'] = usages + + except ValueError as e: + exceptions.handle(self.request, e.message) + return None + except Exception: + exceptions.handle(self.request, + _('Unable to retrieve group information.')) + return context + + def get_initial(self): + return {'vg_snapshot_id': self.kwargs["vg_snapshot_id"]} diff --git a/openstack_dashboard/dashboards/project/volume_groups/__init__.py b/openstack_dashboard/dashboards/project/volume_groups/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openstack_dashboard/dashboards/project/volume_groups/forms.py b/openstack_dashboard/dashboards/project/volume_groups/forms.py new file mode 100644 index 0000000000..312bbeb038 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volume_groups/forms.py @@ -0,0 +1,198 @@ +# 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 + + +class UpdateForm(forms.SelfHandlingForm): + name = forms.CharField(max_length=255, label=_("Name")) + description = forms.CharField(max_length=255, + widget=forms.Textarea(attrs={'rows': 4}), + label=_("Description"), + required=False) + + def clean(self): + cleaned_data = super(UpdateForm, self).clean() + new_desc = cleaned_data.get('description') + old_desc = self.initial['description'] + if old_desc and not new_desc: + error_msg = _("Description is required.") + self._errors['description'] = self.error_class([error_msg]) + return cleaned_data + + return cleaned_data + + def handle(self, request, data): + group_id = self.initial['group_id'] + + try: + cinder.group_update(request, group_id, + data['name'], + data['description']) + + message = _('Updating volume group "%s"') % data['name'] + messages.info(request, message) + return True + except Exception: + redirect = reverse("horizon:project:volume_groups:index") + exceptions.handle(request, + _('Unable to update volume group.'), + redirect=redirect) + + +class RemoveVolsForm(forms.SelfHandlingForm): + def handle(self, request, data): + group_id = self.initial['group_id'] + name = self.initial['name'] + search_opts = {'group_id': group_id} + + try: + # get list of assigned volumes + assigned_vols = [] + volumes = cinder.volume_list(request, + search_opts=search_opts) + for volume in volumes: + assigned_vols.append(volume.id) + + cinder.group_update(request, group_id, + remove_volumes=assigned_vols) + + message = _('Removing volumes from volume group "%s"') % name + messages.info(request, message) + return True + + except Exception: + redirect = reverse("horizon:project:volume_groups:index") + exceptions.handle(request, + _('Errors occurred in removing volumes ' + 'from group.'), + redirect=redirect) + + +class DeleteForm(forms.SelfHandlingForm): + delete_volumes = forms.BooleanField(label=_("Delete Volumes"), + required=False) + + def handle(self, request, data): + group_id = self.initial['group_id'] + name = self.initial['name'] + delete_volumes = data['delete_volumes'] + + try: + cinder.group_delete(request, group_id, + delete_volumes=delete_volumes) + message = _('Deleting volume group "%s"') % name + messages.success(request, message) + return True + + except Exception: + redirect = reverse("horizon:project:volume_groups:index") + exceptions.handle(request, _('Errors occurred in deleting group.'), + redirect=redirect) + + +class CreateSnapshotForm(forms.SelfHandlingForm): + name = forms.CharField(max_length=255, label=_("Snapshot Name")) + description = forms.CharField(max_length=255, + widget=forms.Textarea(attrs={'rows': 4}), + label=_("Description"), + required=False) + + def handle(self, request, data): + group_id = self.initial['group_id'] + try: + message = _('Creating group snapshot "%s".') \ + % data['name'] + snapshot = cinder.group_snapshot_create(request, + group_id, + data['name'], + data['description']) + + messages.info(request, message) + return snapshot + except Exception as e: + redirect = reverse("horizon:project:volume_groups:index") + msg = _('Unable to create group snapshot.') + if e.code == 413: + msg = _('Requested snapshot would exceed the allowed quota.') + else: + search_opts = {'group_id': group_id} + volumes = cinder.volume_list(request, + search_opts=search_opts) + if len(volumes) == 0: + msg = _('Unable to create snapshot. ' + 'group must contain volumes.') + + exceptions.handle(request, + msg, + redirect=redirect) + + +class CloneGroupForm(forms.SelfHandlingForm): + name = forms.CharField(max_length=255, label=_("Group Name")) + description = forms.CharField(max_length=255, + widget=forms.Textarea(attrs={'rows': 4}), + label=_("Description"), + required=False) + group_source = forms.ChoiceField( + label=_("Use a group as source"), + widget=forms.ThemableSelectWidget( + attrs={'class': 'image-selector'}, + data_attrs=('name'), + transform=lambda x: "%s" % (x.name)), + required=False) + + def prepare_group_source_field(self, request): + try: + group_id = self.initial['group_id'] + group = cinder.group_get(request, group_id) + self.fields['group_source'].choices = ((group_id, group),) + except Exception: + exceptions.handle(request, + _('Unable to load the specified group.')) + + def __init__(self, request, *args, **kwargs): + super(CloneGroupForm, self).__init__(request, *args, **kwargs) + self.prepare_group_source_field(request) + + def handle(self, request, data): + group_id = self.initial['group_id'] + try: + message = _('Creating consistency group "%s".') % data['name'] + group = cinder.group_create_from_source( + request, + data['name'], + source_group_id=group_id, + description=data['description']) + + messages.info(request, message) + return group + except Exception: + redirect = reverse("horizon:project:volume_groups:index") + msg = _('Unable to clone group.') + + search_opts = {'group_id': group_id} + volumes = cinder.volume_list(request, search_opts=search_opts) + if len(volumes) == 0: + msg = _('Unable to clone empty group.') + + exceptions.handle(request, + msg, + redirect=redirect) diff --git a/openstack_dashboard/dashboards/project/volume_groups/panel.py b/openstack_dashboard/dashboards/project/volume_groups/panel.py new file mode 100644 index 0000000000..51116c3451 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volume_groups/panel.py @@ -0,0 +1,48 @@ +# Copyright 2017 NEC Corporation +# +# 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.utils.translation import ugettext_lazy as _ + +import horizon + +from openstack_dashboard import api +from openstack_dashboard import policy + +LOG = logging.getLogger(__name__) + + +class VolumeGroups(horizon.Panel): + name = _("Groups") + slug = 'volume_groups' + permissions = ( + ('openstack.services.volume', 'openstack.services.volumev3'), + ) + policy_rules = (("volume", "group:get_all"),) + + def allowed(self, context): + request = context['request'] + try: + return ( + super(VolumeGroups, self).allowed(context) and + request.user.has_perms(self.permissions) and + policy.check(self.policy_rules, request) and + api.cinder.get_microversion(request, 'groups') + ) + except Exception: + LOG.error("Call to list enabled services failed. This is likely " + "due to a problem communicating with the Cinder " + "endpoint. Volume Group panel will not be displayed.") + return False diff --git a/openstack_dashboard/dashboards/project/volume_groups/tables.py b/openstack_dashboard/dashboards/project/volume_groups/tables.py new file mode 100644 index 0000000000..5c0395e579 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volume_groups/tables.py @@ -0,0 +1,190 @@ +# 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 cinderclient import exceptions as cinder_exc + +from django.template import defaultfilters as filters +from django.utils.translation import pgettext_lazy +from django.utils.translation import ugettext_lazy as _ + +from horizon import exceptions +from horizon import tables + +from openstack_dashboard.api import cinder +from openstack_dashboard import policy + + +class CreateGroup(policy.PolicyTargetMixin, tables.LinkAction): + name = "create" + verbose_name = _("Create Group") + url = "horizon:project:volume_groups:create" + classes = ("ajax-modal",) + icon = "plus" + policy_rules = (("volume", "group:create"),) + + +class DeleteGroup(policy.PolicyTargetMixin, tables.LinkAction): + name = "deletecg" + verbose_name = _("Delete Group") + url = "horizon:project:volume_groups:delete" + classes = ("ajax-modal", "btn-danger") + policy_rules = (("volume", "group:delete"), ) + + def allowed(self, request, datum=None): + if datum and datum.has_snapshots: + return False + return True + + +class RemoveAllVolumes(policy.PolicyTargetMixin, tables.LinkAction): + name = "remove_vols" + verbose_name = _("Remove Volumes from Group") + url = "horizon:project:volume_groups:remove_volumes" + classes = ("ajax-modal",) + policy_rules = (("volume", "group:update"), ) + + +class EditGroup(policy.PolicyTargetMixin, tables.LinkAction): + name = "edit" + verbose_name = _("Edit Group") + url = "horizon:project:volume_groups:update" + classes = ("ajax-modal",) + policy_rules = (("volume", "group:update"),) + + +class ManageVolumes(policy.PolicyTargetMixin, tables.LinkAction): + name = "manage" + verbose_name = _("Manage Volumes") + url = "horizon:project:volume_groups:manage" + classes = ("ajax-modal",) + policy_rules = (("volume", "group:update"),) + + def allowed(self, request, group=None): + if hasattr(group, 'status'): + return group.status != 'error' + else: + return False + + +class CreateSnapshot(policy.PolicyTargetMixin, tables.LinkAction): + name = "create_snapshot" + verbose_name = _("Create Snapshot") + url = "horizon:project:volume_groups:create_snapshot" + classes = ("ajax-modal",) + policy_rules = (("volume", "group:create_group_snapshot"),) + + def allowed(self, request, group=None): + if hasattr(group, 'status'): + return group.status != 'error' + else: + return False + + +class CloneGroup(policy.PolicyTargetMixin, tables.LinkAction): + name = "clone_group" + verbose_name = _("Clone Group") + url = "horizon:project:volume_groups:clone_group" + classes = ("ajax-modal",) + policy_rules = (("volume", "group:create"),) + + def allowed(self, request, group=None): + if hasattr(group, 'status'): + return group.status != 'error' + else: + return False + + +class UpdateRow(tables.Row): + ajax = True + + def get_data(self, request, group_id): + try: + return cinder.group_get_with_vol_type_names(request, group_id) + except cinder_exc.NotFound: + # NotFound error must be raised to make ajax UpdateRow work. + raise + except Exception: + exceptions.handle(request, _('Unable to display group.')) + + +class GroupsFilterAction(tables.FilterAction): + + def filter(self, table, groups, filter_string): + """Naive case-insensitive search.""" + query = filter_string.lower() + return [group for group in groups + if query in group.name.lower()] + + +def get_volume_types(group): + vtypes_str = '' + if hasattr(group, 'volume_type_names'): + vtypes_str = ",".join(group.volume_type_names) + return vtypes_str + + +class GroupsTable(tables.DataTable): + STATUS_CHOICES = ( + ("in-use", True), + ("available", True), + ("creating", None), + ("error", False), + ) + STATUS_DISPLAY_CHOICES = ( + ("available", + pgettext_lazy("Current status of Volume Group", u"Available")), + ("in-use", + pgettext_lazy("Current status of Volume Group", u"In-use")), + ("error", + pgettext_lazy("Current status of Volume Group", u"Error")), + ) + + name = tables.WrappingColumn("name_or_id", + verbose_name=_("Name"), + link="horizon:project:volume_groups:detail") + description = tables.Column("description", + verbose_name=_("Description"), + truncate=40) + status = tables.Column("status", + verbose_name=_("Status"), + status=True, + status_choices=STATUS_CHOICES, + display_choices=STATUS_DISPLAY_CHOICES) + availability_zone = tables.Column("availability_zone", + verbose_name=_("Availability Zone")) + volume_type = tables.Column(get_volume_types, + verbose_name=_("Volume Type(s)")) + has_snapshots = tables.Column("has_snapshots", + verbose_name=_("Has Snapshots"), + filters=(filters.yesno,)) + + def get_object_id(self, group): + return group.id + + class Meta(object): + name = "volume_groups" + verbose_name = _("Volume Groups") + table_actions = ( + CreateGroup, + GroupsFilterAction, + ) + row_actions = ( + CreateSnapshot, + ManageVolumes, + EditGroup, + CloneGroup, + RemoveAllVolumes, + DeleteGroup, + ) + row_class = UpdateRow + status_columns = ("status",) + permissions = ['openstack.services.volume'] diff --git a/openstack_dashboard/dashboards/project/volume_groups/tabs.py b/openstack_dashboard/dashboards/project/volume_groups/tabs.py new file mode 100644 index 0000000000..5dabbcceb6 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volume_groups/tabs.py @@ -0,0 +1,34 @@ +# 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 tabs + + +class OverviewTab(tabs.Tab): + name = _("Overview") + slug = "overview" + template_name = ("project/volume_groups/_detail_overview.html") + + def get_context_data(self, request): + group = self.tab_group.kwargs['group'] + return {"group": group} + + def get_redirect_url(self): + return reverse('horizon:project:volume_groups:index') + + +class GroupsDetailTabs(tabs.TabGroup): + slug = "group_details" + tabs = (OverviewTab,) diff --git a/openstack_dashboard/dashboards/project/volume_groups/templates/volume_groups/_clone_group.html b/openstack_dashboard/dashboards/project/volume_groups/templates/volume_groups/_clone_group.html new file mode 100644 index 0000000000..62470be40d --- /dev/null +++ b/openstack_dashboard/dashboards/project/volume_groups/templates/volume_groups/_clone_group.html @@ -0,0 +1,9 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} + +{% block modal-body-right %} +
+

{% blocktrans %}Clone each of the volumes in the source Group, and then add them to a newly created Group.{% endblocktrans %}

+ {% include "project/volumes/_volume_limits.html" with usages=usages snapshot_quota=False %} +
+{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volume_groups/templates/volume_groups/_create_snapshot.html b/openstack_dashboard/dashboards/project/volume_groups/templates/volume_groups/_create_snapshot.html new file mode 100644 index 0000000000..2c1d6fa75b --- /dev/null +++ b/openstack_dashboard/dashboards/project/volume_groups/templates/volume_groups/_create_snapshot.html @@ -0,0 +1,10 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} + +{% block modal-body-right %} +
+

{% blocktrans %}Create a snapshot for each volume contained in the Group.{% endblocktrans %}

+

{% blocktrans %}Snapshots can only be created for Groups that contain volumes.{% endblocktrans %}

+ {% include "project/volume_groups/_snapshot_limits.html" with usages=usages snapshot_quota=True %} +
+{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volume_groups/templates/volume_groups/_delete.html b/openstack_dashboard/dashboards/project/volume_groups/templates/volume_groups/_delete.html new file mode 100644 index 0000000000..79f26df15f --- /dev/null +++ b/openstack_dashboard/dashboards/project/volume_groups/templates/volume_groups/_delete.html @@ -0,0 +1,9 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} +{% block title %}{{ page_title }}{% endblock %} + +{% block modal-body-right %} +

{% trans "Volume groups can not be deleted if they contain volumes." %}

+

{% trans "Check the "Delete Volumes" box to also delete any volumes associated with this group." %}

+

{% trans "Note that a volume can not be deleted if it is "attached" or has any dependent snapshots." %}

+{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volume_groups/templates/volume_groups/_detail_overview.html b/openstack_dashboard/dashboards/project/volume_groups/templates/volume_groups/_detail_overview.html new file mode 100644 index 0000000000..078f2b43fc --- /dev/null +++ b/openstack_dashboard/dashboards/project/volume_groups/templates/volume_groups/_detail_overview.html @@ -0,0 +1,42 @@ +{% load i18n sizeformat parse_date %} + +
+
+
{% trans "Name" %}
+
{{ group.name|default:_("-") }}
+
{% trans "ID" %}
+
{{ group.id }}
+
{% trans "Description" %}
+
{{ group.description|default:_("-") }}
+
{% trans "Status" %}
+
{{ group.status|capfirst }}
+
{% trans "Availability Zone" %}
+
{{ group.availability_zone }}
+
{% trans "Group Type" %}
+
{{ group.group_type }}
+
{% trans "Created" %}
+
{{ group.created_at|parse_isotime }}
+
{% trans "Replication Status" %}
+
{{ group.replication_status }}
+
+ +

{% trans "Volume Types" %}

+
+
+ {% for vol_type_name in group.volume_type_names %} +
{{ vol_type_name }}
+ {% endfor %} +
+ +

{% trans "Volumes" %}

+
+
+ {% for vol in group.volume_names %} +
{{ vol.name }}
+ {% empty %} +
+ {% trans "No assigned volumes" %} +
+ {% endfor %} +
+
diff --git a/openstack_dashboard/dashboards/project/volume_groups/templates/volume_groups/_remove_vols.html b/openstack_dashboard/dashboards/project/volume_groups/templates/volume_groups/_remove_vols.html new file mode 100644 index 0000000000..48c5b29ac5 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volume_groups/templates/volume_groups/_remove_vols.html @@ -0,0 +1,7 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} +{% block title %}{{ page_title }}{% endblock %} + +{% block modal-body %} +

{% trans "This action will unassign all volumes that are currently contained in this group." %}

+{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volume_groups/templates/volume_groups/_snapshot_limits.html b/openstack_dashboard/dashboards/project/volume_groups/templates/volume_groups/_snapshot_limits.html new file mode 100644 index 0000000000..1c58a5a887 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volume_groups/templates/volume_groups/_snapshot_limits.html @@ -0,0 +1,42 @@ +{% extends "project/volumes/_volume_limits.html" %} +{% load i18n horizon humanize %} + +{% block title %} + {% trans "From here you can create a snapshot of a volume." %} +{% endblock %} + +{% block head %} + {% trans "Snapshot Limits" %} +{% endblock %} + +{% block gigabytes_used %} + {{ usages.totalGigabytesUsed|intcomma }} +{% endblock %} + +{% block gigabytes_used_progress %} + "{{ usages.totalGigabytesUsed }}" +{% endblock %} + +{% block type_title %} + {% trans "Number of Snapshots" %} +{% endblock %} + +{% block used %} + {{ usages.totalSnapshotsUsed|intcomma }} +{% endblock %} + +{% block total %} + {{ usages.maxTotalSnapshots|intcomma|quota }} +{% endblock %} + +{% block type_id %} + "quota_snapshots" +{% endblock %} + +{% block total_progress %} + "{{ usages.maxTotalSnapshots }}" +{% endblock %} + +{% block used_progress %} + "{{ usages.totalSnapshotsUsed }}" +{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volume_groups/templates/volume_groups/_update.html b/openstack_dashboard/dashboards/project/volume_groups/templates/volume_groups/_update.html new file mode 100644 index 0000000000..13e9d69efa --- /dev/null +++ b/openstack_dashboard/dashboards/project/volume_groups/templates/volume_groups/_update.html @@ -0,0 +1,7 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} +{% block title %}{{ page_title }}{% endblock %} + +{% block modal-body-right %} +

{% trans "Modify the name and description of a volume group." %}

+{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volume_groups/templates/volume_groups/clone_group.html b/openstack_dashboard/dashboards/project/volume_groups/templates/volume_groups/clone_group.html new file mode 100644 index 0000000000..b5e75bd70c --- /dev/null +++ b/openstack_dashboard/dashboards/project/volume_groups/templates/volume_groups/clone_group.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{{ page_title }}{% endblock %} + +{% block main %} + {% include 'project/volume_groups/_clone_cgroup.html' %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volume_groups/templates/volume_groups/create.html b/openstack_dashboard/dashboards/project/volume_groups/templates/volume_groups/create.html new file mode 100644 index 0000000000..ec93bb561f --- /dev/null +++ b/openstack_dashboard/dashboards/project/volume_groups/templates/volume_groups/create.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{{ page_title }}{% endblock %} + +{% block main %} + {% include 'horizon/common/_workflow.html' %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volume_groups/templates/volume_groups/create_snapshot.html b/openstack_dashboard/dashboards/project/volume_groups/templates/volume_groups/create_snapshot.html new file mode 100644 index 0000000000..83302c8062 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volume_groups/templates/volume_groups/create_snapshot.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{{ page_title }}{% endblock %} + +{% block main %} + {% include 'project/volume_groups/_create_snapshot.html' %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volume_groups/templates/volume_groups/delete.html b/openstack_dashboard/dashboards/project/volume_groups/templates/volume_groups/delete.html new file mode 100644 index 0000000000..95edd2b387 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volume_groups/templates/volume_groups/delete.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{{ page_title }}{% endblock %} + +{% block main %} + {% include 'project/volumes/volume_groups/_delete.html' %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volume_groups/templates/volume_groups/remove_vols.html b/openstack_dashboard/dashboards/project/volume_groups/templates/volume_groups/remove_vols.html new file mode 100644 index 0000000000..ddda9a7469 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volume_groups/templates/volume_groups/remove_vols.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{{ page_title }}{% endblock %} + +{% block main %} + {% include 'project/volume_groups/_remove_vols.html' %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volume_groups/templates/volume_groups/update.html b/openstack_dashboard/dashboards/project/volume_groups/templates/volume_groups/update.html new file mode 100644 index 0000000000..9a64714109 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volume_groups/templates/volume_groups/update.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{{ page_title }}{% endblock %} + +{% block main %} + {% include 'project/volume_groups/_update.html' %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/project/volume_groups/tests.py b/openstack_dashboard/dashboards/project/volume_groups/tests.py new file mode 100644 index 0000000000..9e057e1e91 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volume_groups/tests.py @@ -0,0 +1,378 @@ +# 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 functools + +from django.urls import reverse +from django.utils.http import urlunquote +import mock + +from openstack_dashboard.api import cinder +from openstack_dashboard.test import helpers as test + + +INDEX_URL = reverse('horizon:project:volume_groups:index') +VOLUME_GROUPS_SNAP_INDEX_URL = urlunquote(reverse( + 'horizon:project:vg_snapshots:index')) + + +def create_mocks(target, methods): + def wrapper(function): + @functools.wraps(function) + def wrapped(inst, *args, **kwargs): + for method in methods: + if isinstance(method, str): + method_mocked = method + attr_name = method + else: + method_mocked = method[0] + attr_name = method[1] + m = mock.patch.object(target, method_mocked) + setattr(inst, 'mock_%s' % attr_name, m.start()) + return function(inst, *args, **kwargs) + return wrapped + return wrapper + + +class VolumeGroupTests(test.TestCase): + @create_mocks(cinder, [ + 'extension_supported', + 'availability_zone_list', + 'volume_type_list', + 'group_list', + 'group_type_list', + 'group_create', + ]) + def test_create_group(self): + group = self.cinder_groups.first() + volume_types = self.cinder_volume_types.list() + volume_type_id = self.cinder_volume_types.first().id + selected_types = [volume_type_id] + az = self.cinder_availability_zones.first().zoneName + + formData = { + 'volume_types': '1', + 'name': 'test VG', + 'description': 'test desc', + 'availability_zone': az, + 'group_type': group.group_type, + 'add_vtypes_to_group_role_member': selected_types, + } + + self.mock_extension_supported.return_value = True + self.mock_availability_zone_list.return_value = \ + self.cinder_availability_zones.list() + self.mock_volume_type_list.return_value = volume_types + self.mock_group_list.return_value = self.cinder_groups.list() + self.mock_group_type_list.return_value = self.cinder_group_types.list() + self.mock_group_create.return_value = group + + url = reverse('horizon:project:volume_groups:create') + res = self.client.post(url, formData) + self.assertNoFormErrors(res) + self.assertRedirectsNoFollow(res, INDEX_URL) + + self.mock_extension_supported.assert_called_once_with( + test.IsHttpRequest(), 'AvailabilityZones') + self.mock_availability_zone_list.assert_called_once_with( + test.IsHttpRequest()) + self.mock_volume_type_list.assert_called_once_with( + test.IsHttpRequest()) + self.mock_group_list.assert_called_once_with(test.IsHttpRequest()) + self.mock_group_type_list.assert_called_once_with(test.IsHttpRequest()) + self.mock_group_create.assert_called_once_with( + test.IsHttpRequest(), + formData['name'], + formData['group_type'], + selected_types, + description=formData['description'], + availability_zone=formData['availability_zone']) + + @create_mocks(cinder, [ + 'extension_supported', + 'availability_zone_list', + 'volume_type_list', + 'group_list', + 'group_type_list', + 'group_create', + ]) + def test_create_group_exception(self): + group = self.cinder_groups.first() + volume_types = self.cinder_volume_types.list() + volume_type_id = self.cinder_volume_types.first().id + selected_types = [volume_type_id] + az = self.cinder_availability_zones.first().zoneName + formData = { + 'volume_types': '1', + 'name': 'test VG', + 'description': 'test desc', + 'availability_zone': az, + 'group_type': group.group_type, + 'add_vtypes_to_group_role_member': selected_types, + } + + self.mock_extension_supported.return_value = True + self.mock_availability_zone_list.return_value = \ + self.cinder_availability_zones.list() + self.mock_volume_type_list.return_value = volume_types + self.mock_group_list.return_value = self.cinder_groups.list() + self.mock_group_type_list.return_value = self.cinder_group_types.list() + self.mock_group_create.side_effect = self.exceptions.cinder + + url = reverse('horizon:project:volume_groups:create') + res = self.client.post(url, formData) + self.assertNoFormErrors(res) + self.assertRedirectsNoFollow(res, INDEX_URL) + self.assertIn("Unable to create group.", + res.cookies.output()) + + self.mock_extension_supported.assert_called_once_with( + test.IsHttpRequest(), 'AvailabilityZones') + self.mock_availability_zone_list.assert_called_once_with( + test.IsHttpRequest()) + self.mock_volume_type_list.assert_called_once_with( + test.IsHttpRequest()) + self.mock_group_list.assert_called_once_with(test.IsHttpRequest()) + self.mock_group_type_list.assert_called_once_with(test.IsHttpRequest()) + self.mock_group_create.assert_called_once_with( + test.IsHttpRequest(), + formData['name'], + formData['group_type'], + selected_types, + description=formData['description'], + availability_zone=formData['availability_zone']) + + @create_mocks(cinder, ['group_get', 'group_delete']) + def test_delete_group(self): + group = self.cinder_groups.first() + + self.mock_group_get.return_value = group + self.mock_group_delete.return_value = None + + url = reverse('horizon:project:volume_groups:delete', + args=[group.id]) + res = self.client.post(url) + self.assertNoFormErrors(res) + self.assertRedirectsNoFollow(res, INDEX_URL) + + self.mock_group_get.assert_called_once_with(test.IsHttpRequest(), + group.id) + self.mock_group_delete.assert_called_once_with(test.IsHttpRequest(), + group.id, + delete_volumes=False) + + @create_mocks(cinder, ['group_get', 'group_delete']) + def test_delete_group_delete_volumes_flag(self): + group = self.cinder_consistencygroups.first() + formData = {'delete_volumes': True} + + self.mock_group_get.return_value = group + self.mock_group_delete.return_value = None + + url = reverse('horizon:project:volume_groups:delete', + args=[group.id]) + res = self.client.post(url, formData) + self.assertNoFormErrors(res) + self.assertRedirectsNoFollow(res, INDEX_URL) + + self.mock_group_get.assert_called_once_with(test.IsHttpRequest(), + group.id) + self.mock_group_delete.assert_called_once_with(test.IsHttpRequest(), + group.id, + delete_volumes=True) + + @create_mocks(cinder, ['group_get', 'group_delete']) + def test_delete_group_exception(self): + group = self.cinder_groups.first() + formData = {'delete_volumes': False} + + self.mock_group_get.return_value = group + self.mock_group_delete.side_effect = self.exceptions.cinder + + url = reverse('horizon:project:volume_groups:delete', + args=[group.id]) + res = self.client.post(url, formData) + self.assertNoFormErrors(res) + self.assertRedirectsNoFollow(res, INDEX_URL) + + self.mock_group_get.assert_called_once_with(test.IsHttpRequest(), + group.id) + self.mock_group_delete.assert_called_once_with(test.IsHttpRequest(), + group.id, + delete_volumes=False) + + def test_update_group_add_vol(self): + self._test_update_group_add_remove_vol(add=True) + + def test_update_group_remove_vol(self): + self._test_update_group_add_remove_vol(add=False) + + @create_mocks(cinder, ['volume_list', + 'volume_type_list', + 'group_get', + 'group_update']) + def _test_update_group_add_remove_vol(self, add=True): + group = self.cinder_groups.first() + volume_types = self.cinder_volume_types.list() + volumes = (self.cinder_volumes.list() + + self.cinder_group_volumes.list()) + + group_voltype_names = [t.name for t in volume_types + if t.id in group.volume_types] + compat_volumes = [v for v in volumes + if v.volume_type in group_voltype_names] + compat_volume_ids = [v.id for v in compat_volumes] + assigned_volume_ids = [v.id for v in compat_volumes + if getattr(v, 'group_id', None)] + add_volume_ids = [v.id for v in compat_volumes + if v.id not in assigned_volume_ids] + + new_volums = compat_volume_ids if add else [] + formData = { + 'default_add_volumes_to_group_role': 'member', + 'add_volumes_to_group_role_member': new_volums, + } + + self.mock_volume_list.return_value = volumes + self.mock_volume_type_list.return_value = volume_types + self.mock_group_get.return_value = group + self.mock_group_update.return_value = group + + url = reverse('horizon:project:volume_groups:manage', + args=[group.id]) + res = self.client.post(url, formData) + self.assertNoFormErrors(res) + self.assertRedirectsNoFollow(res, INDEX_URL) + + self.assert_mock_multiple_calls_with_same_arguments( + self.mock_volume_list, 2, + mock.call(test.IsHttpRequest())) + self.mock_volume_type_list.assert_called_once_with( + test.IsHttpRequest()) + self.mock_group_get.assert_called_once_with( + test.IsHttpRequest(), group.id) + if add: + self.mock_group_update.assert_called_once_with( + test.IsHttpRequest(), group.id, + add_volumes=add_volume_ids, + remove_volumes=[]) + else: + self.mock_group_update.assert_called_once_with( + test.IsHttpRequest(), group.id, + add_volumes=[], + remove_volumes=assigned_volume_ids) + + @create_mocks(cinder, ['group_get', 'group_update']) + def test_update_group_name_and_description(self): + group = self.cinder_groups.first() + formData = {'name': 'test VG-new', + 'description': 'test desc-new'} + + self.mock_group_get.return_value = group + self.mock_group_update.return_value = group + + url = reverse('horizon:project:volume_groups:update', + args=[group.id]) + res = self.client.post(url, formData) + self.assertNoFormErrors(res) + self.assertRedirectsNoFollow(res, INDEX_URL) + + self.mock_group_get.assert_called_once_with( + test.IsHttpRequest(), group.id) + self.mock_group_update.assert_called_once_with( + test.IsHttpRequest(), group.id, + formData['name'], + formData['description']) + + @create_mocks(cinder, ['group_get', 'group_update']) + def test_update_group_with_exception(self): + group = self.cinder_groups.first() + formData = {'name': 'test VG-new', + 'description': 'test desc-new'} + + self.mock_group_get.return_value = group + self.mock_group_update.side_effect = self.exceptions.cinder + + url = reverse('horizon:project:volume_groups:update', + args=[group.id]) + res = self.client.post(url, formData) + self.assertNoFormErrors(res) + self.assertRedirectsNoFollow(res, INDEX_URL) + + self.mock_group_get.assert_called_once_with( + test.IsHttpRequest(), group.id) + self.mock_group_update.assert_called_once_with( + test.IsHttpRequest(), group.id, + formData['name'], + formData['description']) + + @mock.patch.object(cinder, 'group_get') + def test_detail_view_with_exception(self, mock_group_get): + group = self.cinder_groups.first() + + mock_group_get.side_effect = self.exceptions.cinder + + url = reverse('horizon:project:volume_groups:detail', + args=[group.id]) + res = self.client.get(url) + self.assertNoFormErrors(res) + self.assertRedirectsNoFollow(res, INDEX_URL) + + mock_group_get.assert_called_once_with( + test.IsHttpRequest(), group.id) + + @create_mocks(cinder, ['group_snapshot_create']) + def test_create_snapshot(self): + group = self.cinder_groups.first() + group_snapshot = self.cinder_group_snapshots.first() + formData = {'name': 'test VG Snapshot', + 'description': 'test desc'} + + self.mock_group_snapshot_create.return_value = group_snapshot + + url = reverse('horizon:project:volume_groups:create_snapshot', + args=[group.id]) + res = self.client.post(url, formData) + self.assertNoFormErrors(res) + self.assertRedirectsNoFollow(res, VOLUME_GROUPS_SNAP_INDEX_URL) + + self.mock_group_snapshot_create.assert_called_once_with( + test.IsHttpRequest(), + group.id, + formData['name'], + formData['description']) + + @create_mocks(cinder, ['group_get', + 'group_create_from_source']) + def test_create_clone(self): + group = self.cinder_groups.first() + formData = { + 'group_source': group.id, + 'name': 'test VG Clone', + 'description': 'test desc', + } + self.mock_group_get.return_value = group + self.mock_group_create_from_source.return_value = group + + url = reverse('horizon:project:volume_groups:clone_group', + args=[group.id]) + res = self.client.post(url, formData) + self.assertNoFormErrors(res) + self.assertRedirectsNoFollow(res, INDEX_URL) + + self.mock_group_get.assert_called_once_with( + test.IsHttpRequest(), group.id) + self.mock_group_create_from_source.assert_called_once_with( + test.IsHttpRequest(), + formData['name'], + source_group_id=group.id, + description=formData['description']) diff --git a/openstack_dashboard/dashboards/project/volume_groups/urls.py b/openstack_dashboard/dashboards/project/volume_groups/urls.py new file mode 100644 index 0000000000..2ffdff972e --- /dev/null +++ b/openstack_dashboard/dashboards/project/volume_groups/urls.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.conf.urls import url + +from openstack_dashboard.dashboards.project.volume_groups import views + + +urlpatterns = [ + url(r'^$', views.IndexView.as_view(), name='index'), + url(r'^(?P[^/]+)$', + views.DetailView.as_view(), + name='detail'), + url(r'^create/$', + views.CreateView.as_view(), + name='create'), + url(r'^(?P[^/]+)/update/$', + views.UpdateView.as_view(), + name='update'), + url(r'^(?P[^/]+)/remove_volumese/$', + views.RemoveVolumesView.as_view(), + name='remove_volumes'), + url(r'^(?P[^/]+)/delete/$', + views.DeleteView.as_view(), + name='delete'), + url(r'^(?P[^/]+)/manage/$', + views.ManageView.as_view(), + name='manage'), + url(r'^(?P[^/]+)/create_snapshot/$', + views.CreateSnapshotView.as_view(), + name='create_snapshot'), + url(r'^(?P[^/]+)/clone_group/$', + views.CloneGroupView.as_view(), + name='clone_group'), +] diff --git a/openstack_dashboard/dashboards/project/volume_groups/views.py b/openstack_dashboard/dashboards/project/volume_groups/views.py new file mode 100644 index 0000000000..273be61443 --- /dev/null +++ b/openstack_dashboard/dashboards/project/volume_groups/views.py @@ -0,0 +1,312 @@ +# 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.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 import tabs +from horizon.utils import memoized +from horizon import workflows + +from openstack_dashboard import api +from openstack_dashboard.api import cinder +from openstack_dashboard.usage import quotas + +from openstack_dashboard.dashboards.project.volume_groups \ + import forms as vol_group_forms +from openstack_dashboard.dashboards.project.volume_groups \ + import tables as vol_group_tables +from openstack_dashboard.dashboards.project.volume_groups \ + import tabs as vol_group_tabs +from openstack_dashboard.dashboards.project.volume_groups \ + import workflows as vol_group_workflows + +CGROUP_INFO_FIELDS = ("name", + "description") + +INDEX_URL = "horizon:project:cgroups:index" + + +class IndexView(tables.DataTableView): + table_class = vol_group_tables.GroupsTable + page_title = _("Groups") + + def get_data(self): + try: + groups = api.cinder.group_list_with_vol_type_names(self.request) + except Exception: + groups = [] + exceptions.handle(self.request, + _("Unable to retrieve volume groups.")) + if not groups: + return groups + group_snapshots = api.cinder.group_snapshot_list(self.request) + snapshot_groups = {gs.group_id for gs in group_snapshots} + for g in groups: + g.has_snapshots = g.id in snapshot_groups + return groups + + +class CreateView(workflows.WorkflowView): + workflow_class = vol_group_workflows.CreateGroupWorkflow + template_name = 'project/volume_groups/create.html' + page_title = _("Create Volume Group") + + +class UpdateView(forms.ModalFormView): + template_name = 'project/volume_groups/update.html' + page_title = _("Edit Group") + form_class = vol_group_forms.UpdateForm + success_url = reverse_lazy('horizon:project:volume_groups:index') + submit_url = "horizon:project:volume_groups:update" + + def get_initial(self): + group = self.get_object() + return {'group_id': self.kwargs["group_id"], + 'name': group.name, + 'description': group.description} + + def get_context_data(self, **kwargs): + context = super(UpdateView, self).get_context_data(**kwargs) + context['group_id'] = self.kwargs['group_id'] + args = (self.kwargs['group_id'],) + context['submit_url'] = reverse(self.submit_url, args=args) + return context + + def get_object(self): + group_id = self.kwargs['group_id'] + try: + self._object = cinder.group_get(self.request, group_id) + except Exception: + exceptions.handle(self.request, + _('Unable to retrieve group details.'), + redirect=reverse(INDEX_URL)) + return self._object + + +class RemoveVolumesView(forms.ModalFormView): + template_name = 'project/volume_groups/remove_vols.html' + page_title = _("Remove Volumes from Group") + form_class = vol_group_forms.RemoveVolsForm + success_url = reverse_lazy('horizon:project:volume_groups:index') + submit_url = "horizon:project:volume_groups:remove_volumes" + + def get_initial(self): + group = self.get_object() + return {'group_id': self.kwargs["group_id"], + 'name': group.name} + + def get_context_data(self, **kwargs): + context = super(RemoveVolumesView, self).get_context_data(**kwargs) + context['group_id'] = self.kwargs['group_id'] + args = (self.kwargs['group_id'],) + context['submit_url'] = reverse(self.submit_url, args=args) + return context + + def get_object(self): + group_id = self.kwargs['group_id'] + try: + self._object = cinder.group_get(self.request, group_id) + except Exception: + exceptions.handle(self.request, + _('Unable to retrieve group details.'), + redirect=reverse(INDEX_URL)) + return self._object + + +class DeleteView(forms.ModalFormView): + template_name = 'project/volume_groups/delete.html' + page_title = _("Delete Group") + form_class = vol_group_forms.DeleteForm + success_url = reverse_lazy('horizon:project:volume_groups:index') + submit_url = "horizon:project:volume_groups:delete" + submit_label = page_title + + def get_initial(self): + group = self.get_object() + return {'group_id': self.kwargs["group_id"], + 'name': group.name} + + def get_context_data(self, **kwargs): + context = super(DeleteView, self).get_context_data(**kwargs) + context['group_id'] = self.kwargs['group_id'] + args = (self.kwargs['group_id'],) + context['submit_url'] = reverse(self.submit_url, args=args) + return context + + def get_object(self): + group_id = self.kwargs['group_id'] + try: + self._object = cinder.group_get(self.request, group_id) + except Exception: + exceptions.handle(self.request, + _('Unable to retrieve group details.'), + redirect=reverse(INDEX_URL)) + return self._object + + +class ManageView(workflows.WorkflowView): + workflow_class = vol_group_workflows.UpdateGroupWorkflow + + def get_context_data(self, **kwargs): + context = super(ManageView, self).get_context_data(**kwargs) + context['group_id'] = self.kwargs["group_id"] + return context + + def _get_object(self, *args, **kwargs): + group_id = self.kwargs['group_id'] + try: + group = cinder.group_get(self.request, group_id) + except Exception: + exceptions.handle(self.request, + _('Unable to retrieve group details.'), + redirect=reverse(INDEX_URL)) + return group + + def get_initial(self): + group = self._get_object() + return {'group_id': group.id, + 'name': group.name, + 'description': group.description, + 'vtypes': getattr(group, "volume_types")} + + +class CreateSnapshotView(forms.ModalFormView): + form_class = vol_group_forms.CreateSnapshotForm + page_title = _("Create Group Snapshot") + template_name = 'project/volume_groups/create_snapshot.html' + submit_label = _("Create Snapshot") + submit_url = "horizon:project:volume_groups:create_snapshot" + success_url = reverse_lazy('horizon:project:vg_snapshots:index') + + def get_context_data(self, **kwargs): + context = super(CreateSnapshotView, self).get_context_data(**kwargs) + context['group_id'] = self.kwargs['group_id'] + args = (self.kwargs['group_id'],) + context['submit_url'] = reverse(self.submit_url, args=args) + try: + # get number of snapshots we will be creating + search_opts = {'group_id': context['group_id']} + volumes = api.cinder.volume_list(self.request, + search_opts=search_opts) + num_volumes = len(volumes) + usages = quotas.tenant_limit_usages(self.request) + + if usages['totalSnapshotsUsed'] + num_volumes > \ + usages['maxTotalSnapshots']: + raise ValueError(_('Unable to create snapshots due to ' + 'exceeding snapshot quota limit.')) + else: + usages['numRequestedItems'] = num_volumes + context['usages'] = usages + + except ValueError as e: + exceptions.handle(self.request, e.message) + return None + except Exception: + exceptions.handle(self.request, + _('Unable to retrieve group information.')) + return context + + def get_initial(self): + return {'group_id': self.kwargs["group_id"]} + + +class CloneGroupView(forms.ModalFormView): + form_class = vol_group_forms.CloneGroupForm + page_title = _("Clone Group") + template_name = 'project/volume_groups/clone_group.html' + submit_label = _("Clone Group") + submit_url = "horizon:project:volume_groups:clone_group" + success_url = reverse_lazy('horizon:project:volume_groups:index') + + def get_context_data(self, **kwargs): + context = super(CloneGroupView, self).get_context_data(**kwargs) + context['group_id'] = self.kwargs['group_id'] + args = (self.kwargs['group_id'],) + context['submit_url'] = reverse(self.submit_url, args=args) + try: + # get number of volumes we will be creating + group_id = context['group_id'] + + search_opts = {'group_id': group_id} + volumes = api.cinder.volume_list(self.request, + search_opts=search_opts) + num_volumes = len(volumes) + usages = quotas.tenant_limit_usages(self.request) + + if usages['totalVolumesUsed'] + num_volumes > \ + usages['maxTotalVolumes']: + raise ValueError(_('Unable to create group due to ' + 'exceeding volume quota limit.')) + else: + usages['numRequestedItems'] = num_volumes + context['usages'] = usages + + except ValueError as e: + exceptions.handle(self.request, e.message) + return None + except Exception: + exceptions.handle(self.request, + _('Unable to retrieve group information.')) + return context + + def get_initial(self): + return {'group_id': self.kwargs["group_id"]} + + +class DetailView(tabs.TabView): + tab_group_class = vol_group_tabs.GroupsDetailTabs + template_name = 'horizon/common/_detail.html' + page_title = "{{ group.name|default:group.id }}" + + def get_context_data(self, **kwargs): + context = super(DetailView, self).get_context_data(**kwargs) + group = self.get_data() + table = vol_group_tables.GroupsTable(self.request) + context["group"] = group + context["url"] = self.get_redirect_url() + context["actions"] = table.render_row_actions(group) + return context + + @memoized.memoized_method + def get_data(self): + try: + group_id = self.kwargs['group_id'] + group = api.cinder.group_get_with_vol_type_names(self.request, + group_id) + search_opts = {'group_id': group_id} + volumes = api.cinder.volume_list(self.request, + search_opts=search_opts) + group.volume_names = [{'id': vol.id, 'name': vol.name} + for vol in volumes] + group_snapshots = api.cinder.group_snapshot_list( + self.request, search_opts=search_opts) + group.has_snapshots = bool(group_snapshots) + except Exception: + redirect = self.get_redirect_url() + exceptions.handle(self.request, + _('Unable to retrieve group details.'), + redirect=redirect) + return group + + @staticmethod + def get_redirect_url(): + return reverse('horizon:project:volume_groups:index') + + def get_tabs(self, request, *args, **kwargs): + group = self.get_data() + return self.tab_group_class(request, group=group, **kwargs) diff --git a/openstack_dashboard/dashboards/project/volume_groups/workflows.py b/openstack_dashboard/dashboards/project/volume_groups/workflows.py new file mode 100644 index 0000000000..12a0aad19e --- /dev/null +++ b/openstack_dashboard/dashboards/project/volume_groups/workflows.py @@ -0,0 +1,374 @@ +# 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 horizon import exceptions +from horizon import forms +from horizon import workflows + +from openstack_dashboard import api +from openstack_dashboard.api import cinder + +INDEX_URL = "horizon:project:volume_groups:index" +CGROUP_VOLUME_MEMBER_SLUG = "update_members" + + +def cinder_az_supported(request): + try: + return cinder.extension_supported(request, 'AvailabilityZones') + except Exception: + exceptions.handle(request, _('Unable to determine if availability ' + 'zones extension is supported.')) + return False + + +def availability_zones(request): + zone_list = [] + if cinder_az_supported(request): + try: + zones = api.cinder.availability_zone_list(request) + zone_list = [(zone.zoneName, zone.zoneName) + for zone in zones if zone.zoneState['available']] + zone_list.sort() + except Exception: + exceptions.handle(request, _('Unable to retrieve availability ' + 'zones.')) + if not zone_list: + zone_list.insert(0, ("", _("No availability zones found"))) + elif len(zone_list) > 1: + zone_list.insert(0, ("", _("Any Availability Zone"))) + + return zone_list + + +class AddGroupInfoAction(workflows.Action): + name = forms.CharField(label=_("Name"), + max_length=255) + description = forms.CharField(widget=forms.widgets.Textarea( + attrs={'rows': 4}), + label=_("Description"), + required=False) + group_type = forms.ChoiceField( + label=_("Group Type"), + widget=forms.ThemableSelectWidget()) + availability_zone = forms.ChoiceField( + label=_("Availability Zone"), + required=False, + widget=forms.ThemableSelectWidget( + attrs={'class': 'switched', + 'data-switch-on': 'source', + 'data-source-no_source_type': _('Availability Zone'), + 'data-source-image_source': _('Availability Zone')})) + + def __init__(self, request, *args, **kwargs): + super(AddGroupInfoAction, self).__init__(request, + *args, + **kwargs) + self.fields['availability_zone'].choices = \ + availability_zones(request) + try: + group_types = [(t.id, t.name) for t + in api.cinder.group_type_list(request)] + group_types.insert(0, ("", _("Select group type"))) + self.fields['group_type'].choices = group_types + except Exception: + exceptions.handle(request, _('Unable to retrieve group types.')) + + class Meta(object): + name = _("Group Information") + help_text = _("Volume groups provide a mechanism for " + "creating snapshots of multiple volumes at the same " + "point-in-time to ensure data consistency\n\n" + "A volume group can support more than one volume " + "type, but it can only contain volumes hosted by the " + "same back end.") + slug = "set_group_info" + + def clean(self): + cleaned_data = super(AddGroupInfoAction, self).clean() + name = cleaned_data.get('name') + + try: + groups = cinder.group_list(self.request) + except Exception: + msg = _('Unable to get group list') + exceptions.check_message(["Connection", "refused"], msg) + raise + + if groups is not None and name is not None: + for group in groups: + if group.name.lower() == name.lower(): + # ensure new name has reasonable length + formatted_name = name + if len(name) > 20: + formatted_name = name[:14] + "..." + name[-3:] + raise forms.ValidationError( + _('The name "%s" is already used by ' + 'another group.') + % formatted_name + ) + + return cleaned_data + + +class AddGroupInfoStep(workflows.Step): + action_class = AddGroupInfoAction + contributes = ("availability_zone", "group_type", + "description", + "name") + + +class AddVolumeTypesToGroupAction(workflows.MembershipAction): + def __init__(self, request, *args, **kwargs): + super(AddVolumeTypesToGroupAction, self).__init__(request, + *args, + **kwargs) + err_msg = _('Unable to get the available volume types') + + default_role_field_name = self.get_default_role_field_name() + self.fields[default_role_field_name] = forms.CharField(required=False) + self.fields[default_role_field_name].initial = 'member' + + field_name = self.get_member_field_name('member') + self.fields[field_name] = forms.MultipleChoiceField(required=False) + + vtypes = [] + try: + vtypes = cinder.volume_type_list(request) + except Exception: + exceptions.handle(request, err_msg) + + vtype_list = [(vtype.id, vtype.name) + for vtype in vtypes] + self.fields[field_name].choices = vtype_list + + class Meta(object): + name = _("Manage Volume Types") + slug = "add_vtypes_to_group" + + def clean(self): + cleaned_data = super(AddVolumeTypesToGroupAction, self).clean() + volume_types = cleaned_data.get('add_vtypes_to_group_role_member') + if not volume_types: + raise forms.ValidationError( + _('At least one volume type must be assigned ' + 'to a group.') + ) + + return cleaned_data + + +class AddVolTypesToGroupStep(workflows.UpdateMembersStep): + action_class = AddVolumeTypesToGroupAction + help_text = _("Add volume types to this group. " + "Multiple volume types can be added to the same " + "group only if they are associated with " + "same back end.") + available_list_title = _("All available volume types") + members_list_title = _("Selected volume types") + no_available_text = _("No volume types found.") + no_members_text = _("No volume types selected.") + show_roles = False + contributes = ("volume_types",) + + def contribute(self, data, context): + if data: + member_field_name = self.get_member_field_name('member') + context['volume_types'] = data.get(member_field_name, []) + return context + + +class AddVolumesToGroupAction(workflows.MembershipAction): + def __init__(self, request, *args, **kwargs): + super(AddVolumesToGroupAction, self).__init__(request, + *args, + **kwargs) + err_msg = _('Unable to get the available volumes') + + default_role_field_name = self.get_default_role_field_name() + self.fields[default_role_field_name] = forms.CharField(required=False) + self.fields[default_role_field_name].initial = 'member' + + field_name = self.get_member_field_name('member') + self.fields[field_name] = forms.MultipleChoiceField(required=False) + + vtypes = self.initial['vtypes'] + try: + # get names of volume types associated with group + vtype_names = [] + volume_types = cinder.volume_type_list(request) + for volume_type in volume_types: + if volume_type.id in vtypes: + vtype_names.append(volume_type.name) + + # collect volumes that are associated with volume types + vol_list = [] + volumes = cinder.volume_list(request) + for volume in volumes: + if volume.volume_type in vtype_names: + group_id = None + vol_is_available = False + in_this_group = False + if hasattr(volume, 'group_id'): + # this vol already belongs to a group + # only include it here if it belongs to this group + group_id = volume.group_id + + if not group_id: + # put this vol in the available list + vol_is_available = True + elif group_id == self.initial['group_id']: + # put this vol in the assigned to group list + vol_is_available = True + in_this_group = True + + if vol_is_available: + vol_list.append({'volume_name': volume.name, + 'volume_id': volume.id, + 'in_group': in_this_group, + 'is_duplicate': False}) + + sorted_vol_list = sorted(vol_list, key=lambda k: k['volume_name']) + + # mark any duplicate volume names + for index, volume in enumerate(sorted_vol_list): + if index < len(sorted_vol_list) - 1: + if volume['volume_name'] == \ + sorted_vol_list[index + 1]['volume_name']: + volume['is_duplicate'] = True + sorted_vol_list[index + 1]['is_duplicate'] = True + + # update display with all available vols and those already + # assigned to group + available_vols = [] + assigned_vols = [] + for volume in sorted_vol_list: + if volume['is_duplicate']: + # add id to differentiate volumes to user + entry = volume['volume_name'] + \ + " [" + volume['volume_id'] + "]" + else: + entry = volume['volume_name'] + available_vols.append((volume['volume_id'], entry)) + if volume['in_group']: + assigned_vols.append(volume['volume_id']) + + except Exception: + exceptions.handle(request, err_msg) + + self.fields[field_name].choices = available_vols + self.fields[field_name].initial = assigned_vols + + class Meta(object): + name = _("Manage Volumes") + slug = "add_volumes_to_group" + + +class AddVolumesToGroupStep(workflows.UpdateMembersStep): + action_class = AddVolumesToGroupAction + help_text = _("Add/remove volumes to/from this group. " + "Only volumes associated with the volume type(s) assigned " + "to this group will be available for selection.") + available_list_title = _("All available volumes") + members_list_title = _("Selected volumes") + no_available_text = _("No volumes found.") + no_members_text = _("No volumes selected.") + show_roles = False + depends_on = ("group_id", "name", "vtypes") + contributes = ("volumes",) + + def contribute(self, data, context): + if data: + member_field_name = self.get_member_field_name('member') + context['volumes'] = data.get(member_field_name, []) + return context + + +class CreateGroupWorkflow(workflows.Workflow): + slug = "create_group" + name = _("Create Group") + finalize_button_name = _("Create Group") + failure_message = _('Unable to create group.') + success_message = _('Created new volume group') + success_url = INDEX_URL + default_steps = (AddGroupInfoStep, + AddVolTypesToGroupStep) + + def handle(self, request, context): + try: + self.object = cinder.group_create( + request, + context['name'], + context['group_type'], + context['volume_types'], + description=context['description'], + availability_zone=context['availability_zone']) + except Exception: + exceptions.handle(request, _('Unable to create group.')) + return False + + return True + + +class UpdateGroupWorkflow(workflows.Workflow): + slug = "update_group" + name = _("Add/Remove Group Volumes") + finalize_button_name = _("Submit") + success_message = _('Updated volumes for group.') + failure_message = _('Unable to update volumes for group') + success_url = INDEX_URL + default_steps = (AddVolumesToGroupStep,) + + def handle(self, request, context): + group_id = context['group_id'] + add_vols = [] + remove_vols = [] + try: + selected_volumes = context['volumes'] + volumes = cinder.volume_list(request) + + # scan all volumes and make correct consistency group is set + for volume in volumes: + selected = False + for selection in selected_volumes: + if selection == volume.id: + selected = True + break + + if selected: + # ensure this volume is in this consistency group + if hasattr(volume, 'group_id'): + if volume.group_id != group_id: + add_vols.append(volume.id) + else: + add_vols.append(volume.id) + else: + # ensure this volume is not in our consistency group + if hasattr(volume, 'group_id'): + if volume.group_id == group_id: + # remove from this group + remove_vols.append(volume.id) + + if not add_vols and not remove_vols: + # nothing to change + return True + + cinder.group_update(request, group_id, + add_volumes=add_vols, + remove_volumes=remove_vols) + + except Exception: + # error message supplied by form + return False + + return True diff --git a/openstack_dashboard/enabled/_1360_project_volume_groups.py b/openstack_dashboard/enabled/_1360_project_volume_groups.py new file mode 100644 index 0000000000..cc9621c533 --- /dev/null +++ b/openstack_dashboard/enabled/_1360_project_volume_groups.py @@ -0,0 +1,9 @@ +# The slug of the panel to be added to HORIZON_CONFIG. Required. +PANEL = 'volume_groups' +# The slug of the dashboard the PANEL associated with. Required. +PANEL_DASHBOARD = 'project' +# The slug of the panel group the PANEL is associated with. +PANEL_GROUP = 'volumes' + +# Python panel class of the PANEL to be added. +ADD_PANEL = 'openstack_dashboard.dashboards.project.volume_groups.panel.VolumeGroups' diff --git a/openstack_dashboard/enabled/_1370_project_vg_snapshots.py b/openstack_dashboard/enabled/_1370_project_vg_snapshots.py new file mode 100644 index 0000000000..86da94bf69 --- /dev/null +++ b/openstack_dashboard/enabled/_1370_project_vg_snapshots.py @@ -0,0 +1,9 @@ +# The slug of the panel to be added to HORIZON_CONFIG. Required. +PANEL = 'vg_snapshots' +# The slug of the dashboard the PANEL associated with. Required. +PANEL_DASHBOARD = 'project' +# The slug of the panel group the PANEL is associated with. +PANEL_GROUP = 'volumes' + +# Python panel class of the PANEL to be added. +ADD_PANEL = 'openstack_dashboard.dashboards.project.vg_snapshots.panel.GroupSnapshots' diff --git a/openstack_dashboard/test/settings.py b/openstack_dashboard/test/settings.py index bd34eb12e8..7e149124ce 100644 --- a/openstack_dashboard/test/settings.py +++ b/openstack_dashboard/test/settings.py @@ -280,11 +280,26 @@ TEST_GLOBAL_MOCKS_ON_PANELS = { '.aggregates.panel.Aggregates.can_access'), 'return_value': True, }, + 'cgroups': { + 'method': ('openstack_dashboard.dashboards.project' + '.cgroups.panel.CGroups.allowed'), + 'return_value': True, + }, + 'cg_snapshots': { + 'method': ('openstack_dashboard.dashboards.project' + '.cg_snapshots.panel.CGSnapshots.allowed'), + 'return_value': True, + }, 'domains': { 'method': ('openstack_dashboard.dashboards.identity' '.domains.panel.Domains.can_access'), 'return_value': True, }, + 'qos': { + 'method': ('openstack_dashboard.dashboards.project' + '.network_qos.panel.NetworkQoS.can_access'), + 'return_value': True, + }, 'server_groups': { 'method': ('openstack_dashboard.dashboards.project' '.server_groups.panel.ServerGroups.can_access'), @@ -300,9 +315,14 @@ TEST_GLOBAL_MOCKS_ON_PANELS = { '.trunks.panel.Trunks.can_access'), 'return_value': True, }, - 'qos': { + 'volume_groups': { 'method': ('openstack_dashboard.dashboards.project' - '.network_qos.panel.NetworkQoS.can_access'), + '.volume_groups.panel.VolumeGroups.allowed'), + 'return_value': True, + }, + 'vg_snapshots': { + 'method': ('openstack_dashboard.dashboards.project' + '.vg_snapshots.panel.GroupSnapshots.allowed'), 'return_value': True, }, 'application_credentials': { diff --git a/openstack_dashboard/test/test_data/cinder_data.py b/openstack_dashboard/test/test_data/cinder_data.py index d6b57d1d16..78cfd7f867 100644 --- a/openstack_dashboard/test/test_data/cinder_data.py +++ b/openstack_dashboard/test/test_data/cinder_data.py @@ -27,11 +27,14 @@ from cinderclient.v2 import volume_transfers from cinderclient.v2 import volume_type_access from cinderclient.v2 import volume_types from cinderclient.v2 import volumes +from cinderclient.v3 import group_snapshots +from cinderclient.v3 import group_types +from cinderclient.v3 import groups from openstack_dashboard import api -from openstack_dashboard.usage import quotas as usage_quotas - +from openstack_dashboard.api import cinder as cinder_api from openstack_dashboard.test.test_data import utils +from openstack_dashboard.usage import quotas as usage_quotas def data(TEST): @@ -55,6 +58,10 @@ def data(TEST): TEST.cinder_consistencygroups = utils.TestDataContainer() TEST.cinder_cgroup_volumes = utils.TestDataContainer() TEST.cinder_cg_snapshots = utils.TestDataContainer() + TEST.cinder_groups = utils.TestDataContainer() + TEST.cinder_group_types = utils.TestDataContainer() + TEST.cinder_group_snapshots = utils.TestDataContainer() + TEST.cinder_group_volumes = utils.TestDataContainer() # Services service_1 = services.Service(services.ServiceManager(None), { @@ -150,22 +157,24 @@ def data(TEST): TEST.cinder_bootable_volumes.add(api.cinder.Volume(non_bootable_volume)) - vol_type1 = volume_types.VolumeType(volume_types.VolumeTypeManager(None), - {'id': u'1', - 'name': u'vol_type_1', - 'description': 'type 1 description', - 'extra_specs': {'foo': 'bar', - 'volume_backend_name': - 'backend_1'}}) - vol_type2 = volume_types.VolumeType(volume_types.VolumeTypeManager(None), - {'id': u'2', - 'name': u'vol_type_2', - 'description': 'type 2 description'}) - vol_type3 = volume_types.VolumeType(volume_types.VolumeTypeManager(None), - {'id': u'3', - 'name': u'vol_type_3', - 'is_public': False, - 'description': 'type 3 description'}) + vol_type1 = volume_types.VolumeType( + volume_types.VolumeTypeManager(None), + {'id': u'1', + 'name': u'vol_type_1', + 'description': 'type 1 description', + 'extra_specs': {'foo': 'bar', + 'volume_backend_name': 'backend_1'}}) + vol_type2 = volume_types.VolumeType( + volume_types.VolumeTypeManager(None), + {'id': u'2', + 'name': u'vol_type_2', + 'description': 'type 2 description'}) + vol_type3 = volume_types.VolumeType( + volume_types.VolumeTypeManager(None), + {'id': u'3', + 'name': u'vol_type_3', + 'is_public': False, + 'description': 'type 3 description'}) TEST.cinder_volume_types.add(vol_type1, vol_type2, vol_type3) vol_type_access1 = volume_type_access.VolumeTypeAccess( volume_type_access.VolumeTypeAccessManager(None), @@ -488,3 +497,72 @@ def data(TEST): 'description': 'cg_ss 1 description', 'consistencygroup_id': u'1'}) TEST.cinder_cg_snapshots.add(cg_snapshot_1) + + group_type_1 = group_types.GroupType( + group_types.GroupTypeManager(None), + { + "is_public": True, + "group_specs": {}, + "id": "4645cbf7-8aa6-4d42-a5f7-24e6ebe5ba79", + "name": "group-type-1", + "description": None, + }) + TEST.cinder_group_types.add(group_type_1) + + group_1 = groups.Group( + groups.GroupManager(None), + { + "availability_zone": "nova", + "created_at": "2018-01-09T07:27:22.000000", + "description": "description for group1", + "group_snapshot_id": None, + "group_type": group_type_1.id, + "id": "f64646ac-9bf7-483f-bd85-96c34050a528", + "name": "group1", + "replication_status": "disabled", + "source_group_id": None, + "status": "available", + "volume_types": [ + vol_type1.id, + ] + }) + TEST.cinder_groups.add(cinder_api.Group(group_1)) + + group_snapshot_1 = group_snapshots.GroupSnapshot( + group_snapshots.GroupSnapshotManager(None), + { + "created_at": "2018-01-09T07:46:03.000000", + "description": "", + "group_id": group_1.id, + "group_type_id": group_type_1.id, + "id": "1036d913-9cb8-46a1-9f56-2f99dc1f14ed", + "name": "group-snap1", + "status": "available", + }) + TEST.cinder_group_snapshots.add(group_snapshot_1) + + group_volume_1 = volumes.Volume( + volumes.VolumeManager(None), + {'id': "fe9a2664-0f49-4354-bab6-11b2ad352630", + 'status': 'available', + 'size': 2, + 'name': 'group1-volume1', + 'display_description': 'Volume 1 in Group 1', + 'created_at': '2014-01-27 10:30:00', + 'volume_type': 'vol_type_1', + 'group_id': group_1.id, + 'attachments': []}) + + group_volume_2 = volumes.Volume( + volumes.VolumeManager(None), + {'id': "a7fb0402-88dc-45a3-970c-d732da63466e", + 'status': 'available', + 'size': 1, + 'name': 'group1-volume2', + 'display_description': 'Volume 2 in Group 1', + 'created_at': '2014-01-30 10:31:00', + 'volume_type': 'vol_type_1', + 'group_id': group_1.id, + 'attachments': []}) + TEST.cinder_group_volumes.add(group_volume_1) + TEST.cinder_group_volumes.add(group_volume_2) diff --git a/openstack_dashboard/test/unit/api/test_cinder.py b/openstack_dashboard/test/unit/api/test_cinder.py index 491bec7c14..27d22808fb 100644 --- a/openstack_dashboard/test/unit/api/test_cinder.py +++ b/openstack_dashboard/test/unit/api/test_cinder.py @@ -24,16 +24,27 @@ from openstack_dashboard.test import helpers as test class CinderApiTests(test.APIMockTestCase): - @mock.patch.object(api.cinder, 'cinderclient') - def test_volume_list(self, mock_cinderclient): + def _stub_cinderclient_with_generic_group(self): + p = mock.patch.object(api.cinder, + '_cinderclient_with_generic_groups').start() + return p.return_value + + @test.create_mocks({ + api.cinder: [ + 'cinderclient', + ('_cinderclient_with_generic_groups', 'cinderclient_groups'), + ] + }) + def test_volume_list(self): search_opts = {'all_tenants': 1} detailed = True volumes = self.cinder_volumes.list() volume_transfers = self.cinder_volume_transfers.list() - cinderclient = mock_cinderclient.return_value + cinderclient = self.mock_cinderclient.return_value + cinderclient_with_group = self.mock_cinderclient_groups.return_value - volumes_mock = cinderclient.volumes.list + volumes_mock = cinderclient_with_group.volumes.list volumes_mock.return_value = volumes transfers_mock = cinderclient.transfers.list @@ -47,15 +58,21 @@ class CinderApiTests(test.APIMockTestCase): search_opts=search_opts) self.assertEqual(len(volumes), len(api_volumes)) - @mock.patch.object(api.cinder, 'cinderclient') - def test_volume_list_paged(self, mock_cinderclient): + @test.create_mocks({ + api.cinder: [ + 'cinderclient', + ('_cinderclient_with_generic_groups', 'cinderclient_groups'), + ] + }) + def test_volume_list_paged(self): search_opts = {'all_tenants': 1} detailed = True volumes = self.cinder_volumes.list() volume_transfers = self.cinder_volume_transfers.list() - cinderclient = mock_cinderclient.return_value + cinderclient = self.mock_cinderclient.return_value + cinderclient_with_group = self.mock_cinderclient_groups.return_value - volumes_mock = cinderclient.volumes.list + volumes_mock = cinderclient_with_group.volumes.list volumes_mock.return_value = volumes transfers_mock = cinderclient.transfers.list @@ -73,8 +90,13 @@ class CinderApiTests(test.APIMockTestCase): @override_settings(API_RESULT_PAGE_SIZE=2) @override_settings(OPENSTACK_API_VERSIONS={'volume': 2}) - @mock.patch.object(api.cinder, 'cinderclient') - def test_volume_list_paginate_first_page(self, mock_cinderclient): + @test.create_mocks({ + api.cinder: [ + 'cinderclient', + ('_cinderclient_with_generic_groups', 'cinderclient_groups'), + ] + }) + def test_volume_list_paginate_first_page(self): api.cinder.VERSIONS._active = None page_size = settings.API_RESULT_PAGE_SIZE volumes = self.cinder_volumes.list() @@ -84,9 +106,10 @@ class CinderApiTests(test.APIMockTestCase): mock_volumes = volumes[:page_size + 1] expected_volumes = mock_volumes[:-1] - cinderclient = mock_cinderclient.return_value + cinderclient = self.mock_cinderclient.return_value + cinderclient_with_group = self.mock_cinderclient_groups.return_value - volumes_mock = cinderclient.volumes.list + volumes_mock = cinderclient_with_group.volumes.list volumes_mock.return_value = mock_volumes transfers_mock = cinderclient.transfers.list @@ -107,8 +130,13 @@ class CinderApiTests(test.APIMockTestCase): @override_settings(API_RESULT_PAGE_SIZE=2) @override_settings(OPENSTACK_API_VERSIONS={'volume': 2}) - @mock.patch.object(api.cinder, 'cinderclient') - def test_volume_list_paginate_second_page(self, mock_cinderclient): + @test.create_mocks({ + api.cinder: [ + 'cinderclient', + ('_cinderclient_with_generic_groups', 'cinderclient_groups'), + ] + }) + def test_volume_list_paginate_second_page(self): api.cinder.VERSIONS._active = None page_size = settings.API_RESULT_PAGE_SIZE volumes = self.cinder_volumes.list() @@ -119,9 +147,10 @@ class CinderApiTests(test.APIMockTestCase): expected_volumes = mock_volumes[:-1] marker = expected_volumes[0].id - cinderclient = mock_cinderclient.return_value + cinderclient = self.mock_cinderclient.return_value + cinderclient_with_group = self.mock_cinderclient_groups.return_value - volumes_mock = cinderclient.volumes.list + volumes_mock = cinderclient_with_group.volumes.list volumes_mock.return_value = mock_volumes transfers_mock = cinderclient.transfers.list @@ -143,8 +172,13 @@ class CinderApiTests(test.APIMockTestCase): @override_settings(API_RESULT_PAGE_SIZE=2) @override_settings(OPENSTACK_API_VERSIONS={'volume': 2}) - @mock.patch.object(api.cinder, 'cinderclient') - def test_volume_list_paginate_last_page(self, mock_cinderclient): + @test.create_mocks({ + api.cinder: [ + 'cinderclient', + ('_cinderclient_with_generic_groups', 'cinderclient_groups'), + ] + }) + def test_volume_list_paginate_last_page(self): api.cinder.VERSIONS._active = None page_size = settings.API_RESULT_PAGE_SIZE volumes = self.cinder_volumes.list() @@ -155,9 +189,10 @@ class CinderApiTests(test.APIMockTestCase): expected_volumes = mock_volumes marker = expected_volumes[0].id - cinderclient = mock_cinderclient.return_value + cinderclient = self.mock_cinderclient.return_value + cinderclient_with_group = self.mock_cinderclient_groups.return_value - volumes_mock = cinderclient.volumes.list + volumes_mock = cinderclient_with_group.volumes.list volumes_mock.return_value = mock_volumes transfers_mock = cinderclient.transfers.list @@ -179,8 +214,13 @@ class CinderApiTests(test.APIMockTestCase): @override_settings(API_RESULT_PAGE_SIZE=2) @override_settings(OPENSTACK_API_VERSIONS={'volume': 2}) - @mock.patch.object(api.cinder, 'cinderclient') - def test_volume_list_paginate_back_from_some_page(self, mock_cinderclient): + @test.create_mocks({ + api.cinder: [ + 'cinderclient', + ('_cinderclient_with_generic_groups', 'cinderclient_groups'), + ] + }) + def test_volume_list_paginate_back_from_some_page(self): api.cinder.VERSIONS._active = None page_size = settings.API_RESULT_PAGE_SIZE volumes = self.cinder_volumes.list() @@ -191,9 +231,10 @@ class CinderApiTests(test.APIMockTestCase): expected_volumes = mock_volumes[:-1] marker = expected_volumes[0].id - cinderclient = mock_cinderclient.return_value + cinderclient = self.mock_cinderclient.return_value + cinderclient_with_group = self.mock_cinderclient_groups.return_value - volumes_mock = cinderclient.volumes.list + volumes_mock = cinderclient_with_group.volumes.list volumes_mock.return_value = mock_volumes transfers_mock = cinderclient.transfers.list @@ -215,8 +256,13 @@ class CinderApiTests(test.APIMockTestCase): @override_settings(API_RESULT_PAGE_SIZE=2) @override_settings(OPENSTACK_API_VERSIONS={'volume': 2}) - @mock.patch.object(api.cinder, 'cinderclient') - def test_volume_list_paginate_back_to_first_page(self, mock_cinderclient): + @test.create_mocks({ + api.cinder: [ + 'cinderclient', + ('_cinderclient_with_generic_groups', 'cinderclient_groups'), + ] + }) + def test_volume_list_paginate_back_to_first_page(self): api.cinder.VERSIONS._active = None page_size = settings.API_RESULT_PAGE_SIZE volumes = self.cinder_volumes.list() @@ -227,9 +273,10 @@ class CinderApiTests(test.APIMockTestCase): expected_volumes = mock_volumes marker = expected_volumes[0].id - cinderclient = mock_cinderclient.return_value + cinderclient = self.mock_cinderclient.return_value + cinderclient_with_group = self.mock_cinderclient_groups.return_value - volumes_mock = cinderclient.volumes.list + volumes_mock = cinderclient_with_group.volumes.list volumes_mock.return_value = mock_volumes transfers_mock = cinderclient.transfers.list