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