Generic volume group support
This commit adds cinder generic group support. Consistency group support are not shown if the generic group support is available. blueprint cinder-generic-volume-groups Change-Id: I038eeaf2508926f18b6053db0082a8aa3f3e20c6
This commit is contained in:
parent
ee95bf15d9
commit
32d463a298
@ -136,6 +136,10 @@ class APIResourceWrapper(object):
|
|||||||
obj[key] = getattr(self, key, None)
|
obj[key] = getattr(self, key, None)
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name_or_id(self):
|
||||||
|
return self.name or '(%s)' % self.id[:13]
|
||||||
|
|
||||||
|
|
||||||
class APIDictWrapper(object):
|
class APIDictWrapper(object):
|
||||||
"""Simple wrapper for api dictionaries
|
"""Simple wrapper for api dictionaries
|
||||||
|
@ -87,7 +87,7 @@ class Volume(BaseCinderAPIResourceWrapper):
|
|||||||
_attrs = ['id', 'name', 'description', 'size', 'status', 'created_at',
|
_attrs = ['id', 'name', 'description', 'size', 'status', 'created_at',
|
||||||
'volume_type', 'availability_zone', 'imageRef', 'bootable',
|
'volume_type', 'availability_zone', 'imageRef', 'bootable',
|
||||||
'snapshot_id', 'source_volid', 'attachments', 'tenant_name',
|
'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',
|
'os-vol-tenant-attr:tenant_id', 'metadata',
|
||||||
'volume_image_metadata', 'encrypted', 'transfer',
|
'volume_image_metadata', 'encrypted', 'transfer',
|
||||||
'multiattach']
|
'multiattach']
|
||||||
@ -172,6 +172,21 @@ class VolumePool(base.APIResourceWrapper):
|
|||||||
'storage_protocol', 'extra_specs']
|
'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):
|
def get_auth_params_from_request(request):
|
||||||
auth_url = base.url_for(request, 'identity')
|
auth_url = base.url_for(request, 'identity')
|
||||||
cinder_urls = []
|
cinder_urls = []
|
||||||
@ -248,6 +263,13 @@ def get_microversion(request, features):
|
|||||||
'cinder', features, api_versions.APIVersion, min_ver, max_ver))
|
'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():
|
def version_get():
|
||||||
api_version = VERSIONS.get_active_version()
|
api_version = VERSIONS.get_active_version()
|
||||||
return api_version['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
|
has_prev_data = False
|
||||||
volumes = []
|
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:
|
if c_client is None:
|
||||||
return volumes, has_more_data, has_prev_data
|
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):
|
def volume_type_remove_project_access(request, volume_type, project_id):
|
||||||
return cinderclient(request).volume_type_access.remove_project_access(
|
return cinderclient(request).volume_type_access.remove_project_access(
|
||||||
volume_type, project_id)
|
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)
|
||||||
|
@ -37,6 +37,7 @@ MICROVERSION_FEATURES = {
|
|||||||
"auto_allocated_network": ["2.37", "2.42"],
|
"auto_allocated_network": ["2.37", "2.42"],
|
||||||
},
|
},
|
||||||
"cinder": {
|
"cinder": {
|
||||||
|
"groups": ["3.27", "3.43", "3.48"],
|
||||||
"consistency_groups": ["2.0", "3.10"],
|
"consistency_groups": ["2.0", "3.10"],
|
||||||
"message_list": ["3.5", "3.29"]
|
"message_list": ["3.5", "3.29"]
|
||||||
}
|
}
|
||||||
|
@ -12,10 +12,17 @@
|
|||||||
# License for the specific language governing permissions and limitations
|
# License for the specific language governing permissions and limitations
|
||||||
# under the License.
|
# under the License.
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
import horizon
|
import horizon
|
||||||
|
|
||||||
|
from openstack_dashboard import api
|
||||||
|
from openstack_dashboard import policy
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class CGSnapshots(horizon.Panel):
|
class CGSnapshots(horizon.Panel):
|
||||||
name = _("Consistency Group Snapshots")
|
name = _("Consistency Group Snapshots")
|
||||||
@ -25,3 +32,20 @@ class CGSnapshots(horizon.Panel):
|
|||||||
'openstack.services.volumev3'),
|
'openstack.services.volumev3'),
|
||||||
)
|
)
|
||||||
policy_rules = (("volume", "consistencygroup:get_all_cgsnapshots"),)
|
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
|
||||||
|
@ -12,10 +12,17 @@
|
|||||||
# License for the specific language governing permissions and limitations
|
# License for the specific language governing permissions and limitations
|
||||||
# under the License.
|
# under the License.
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
import horizon
|
import horizon
|
||||||
|
|
||||||
|
from openstack_dashboard import api
|
||||||
|
from openstack_dashboard import policy
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class CGroups(horizon.Panel):
|
class CGroups(horizon.Panel):
|
||||||
name = _("Consistency Groups")
|
name = _("Consistency Groups")
|
||||||
@ -25,3 +32,20 @@ class CGroups(horizon.Panel):
|
|||||||
'openstack.services.volumev3'),
|
'openstack.services.volumev3'),
|
||||||
)
|
)
|
||||||
policy_rules = (("volume", "consistencygroup:get_all"),)
|
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
|
||||||
|
75
openstack_dashboard/dashboards/project/vg_snapshots/forms.py
Normal file
75
openstack_dashboard/dashboards/project/vg_snapshots/forms.py
Normal file
@ -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)
|
49
openstack_dashboard/dashboards/project/vg_snapshots/panel.py
Normal file
49
openstack_dashboard/dashboards/project/vg_snapshots/panel.py
Normal file
@ -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
|
141
openstack_dashboard/dashboards/project/vg_snapshots/tables.py
Normal file
141
openstack_dashboard/dashboards/project/vg_snapshots/tables.py
Normal file
@ -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')
|
||||||
|
]
|
34
openstack_dashboard/dashboards/project/vg_snapshots/tabs.py
Normal file
34
openstack_dashboard/dashboards/project/vg_snapshots/tabs.py
Normal file
@ -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,)
|
@ -0,0 +1,9 @@
|
|||||||
|
{% extends "horizon/common/_modal_form.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block modal-body-right %}
|
||||||
|
<div class="quota-dynamic">
|
||||||
|
<p>{% blocktrans %}Create a Group that will contain newly created volumes cloned from each of the snapshots in the source Group Snapshot.{% endblocktrans %}</p>
|
||||||
|
{% include "project/volumes/_volume_limits.html" with usages=usages %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
@ -0,0 +1,50 @@
|
|||||||
|
{% load i18n sizeformat parse_date %}
|
||||||
|
|
||||||
|
<div class="detail">
|
||||||
|
<dl class="dl-horizontal">
|
||||||
|
<dt>{% trans "Name" %}</dt>
|
||||||
|
<dd data-display="{{ vg_snapshot.name|default:vg_snapshot.id }}">{{ vg_snapshot.name }}</dd>
|
||||||
|
<dt>{% trans "ID" %}</dt>
|
||||||
|
<dd>{{ vg_snapshot.id }}</dd>
|
||||||
|
{% if vg_snapshot.description %}
|
||||||
|
<dt>{% trans "Description" %}</dt>
|
||||||
|
<dd>{{ vg_snapshot.description }}</dd>
|
||||||
|
{% endif %}
|
||||||
|
<dt>{% trans "Status" %}</dt>
|
||||||
|
<dd>{{ vg_snapshot.status|capfirst }}</dd>
|
||||||
|
<dt>{% trans "Group" %}</dt>
|
||||||
|
<dd>
|
||||||
|
<a href="{% url 'horizon:project:volume_groups:detail' vg_snapshot.group_id %}">
|
||||||
|
{% if vg_snapshot.vg_name %}
|
||||||
|
{{ vg_snapshot.vg_name }}
|
||||||
|
{% else %}
|
||||||
|
{{ vg_snapshot.group_id }}
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
</dd>
|
||||||
|
<dt>{% trans "Group Type" %}</dt>
|
||||||
|
<dd>{{ vg_snapshot.group_type_id }}</dd>
|
||||||
|
<dt>{% trans "Created" %}</dt>
|
||||||
|
<dd>{{ vg_snapshot.created_at|parse_isotime }}</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<h4>{% trans "Snapshot Volume Types" %}</h4>
|
||||||
|
<hr class="header_rule">
|
||||||
|
<dl class="dl-horizontal">
|
||||||
|
{% for vol_type_names in vg_snapshot.volume_type_names %}
|
||||||
|
<dd>{{ vol_type_names }}</dd>
|
||||||
|
{% endfor %}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<h4>{% trans "Snapshot Volumes" %}</h4>
|
||||||
|
<hr class="header_rule">
|
||||||
|
<dl class="dl-horizontal">
|
||||||
|
{% for vol_names in vg_snapshot.volume_names %}
|
||||||
|
<dd>{{ vol_names }}</dd>
|
||||||
|
{% empty %}
|
||||||
|
<dd>
|
||||||
|
<em>{% trans "No assigned volumes" %}</em>
|
||||||
|
</dd>
|
||||||
|
{% endfor %}
|
||||||
|
</dl>
|
||||||
|
</div>
|
@ -0,0 +1,7 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% block title %}{{ page_title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
{% include 'project/vg_snapshots/_create.html' %}
|
||||||
|
{% endblock %}
|
198
openstack_dashboard/dashboards/project/vg_snapshots/tests.py
Normal file
198
openstack_dashboard/dashboards/project/vg_snapshots/tests.py
Normal file
@ -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)
|
25
openstack_dashboard/dashboards/project/vg_snapshots/urls.py
Normal file
25
openstack_dashboard/dashboards/project/vg_snapshots/urls.py
Normal file
@ -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<vg_snapshot_id>[^/]+)/detail/$',
|
||||||
|
views.DetailView.as_view(),
|
||||||
|
name='detail'),
|
||||||
|
url(r'^(?P<vg_snapshot_id>[^/]+)/create_group/$',
|
||||||
|
views.CreateGroupView.as_view(),
|
||||||
|
name='create_group'),
|
||||||
|
]
|
158
openstack_dashboard/dashboards/project/vg_snapshots/views.py
Normal file
158
openstack_dashboard/dashboards/project/vg_snapshots/views.py
Normal file
@ -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"]}
|
198
openstack_dashboard/dashboards/project/volume_groups/forms.py
Normal file
198
openstack_dashboard/dashboards/project/volume_groups/forms.py
Normal file
@ -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)
|
@ -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
|
190
openstack_dashboard/dashboards/project/volume_groups/tables.py
Normal file
190
openstack_dashboard/dashboards/project/volume_groups/tables.py
Normal file
@ -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']
|
34
openstack_dashboard/dashboards/project/volume_groups/tabs.py
Normal file
34
openstack_dashboard/dashboards/project/volume_groups/tabs.py
Normal file
@ -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,)
|
@ -0,0 +1,9 @@
|
|||||||
|
{% extends "horizon/common/_modal_form.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block modal-body-right %}
|
||||||
|
<div class="quota-dynamic">
|
||||||
|
<p>{% blocktrans %}Clone each of the volumes in the source Group, and then add them to a newly created Group.{% endblocktrans %}</p>
|
||||||
|
{% include "project/volumes/_volume_limits.html" with usages=usages snapshot_quota=False %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
@ -0,0 +1,10 @@
|
|||||||
|
{% extends "horizon/common/_modal_form.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block modal-body-right %}
|
||||||
|
<div class="quota-dynamic">
|
||||||
|
<p>{% blocktrans %}Create a snapshot for each volume contained in the Group.{% endblocktrans %}</p>
|
||||||
|
<p>{% blocktrans %}Snapshots can only be created for Groups that contain volumes.{% endblocktrans %}</p>
|
||||||
|
{% include "project/volume_groups/_snapshot_limits.html" with usages=usages snapshot_quota=True %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
@ -0,0 +1,9 @@
|
|||||||
|
{% extends "horizon/common/_modal_form.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% block title %}{{ page_title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block modal-body-right %}
|
||||||
|
<p>{% trans "Volume groups can not be deleted if they contain volumes." %}</p>
|
||||||
|
<p>{% trans "Check the "Delete Volumes" box to also delete any volumes associated with this group." %}</p>
|
||||||
|
<p>{% trans "Note that a volume can not be deleted if it is "attached" or has any dependent snapshots." %}</p>
|
||||||
|
{% endblock %}
|
@ -0,0 +1,42 @@
|
|||||||
|
{% load i18n sizeformat parse_date %}
|
||||||
|
|
||||||
|
<div class="detail">
|
||||||
|
<dl class="dl-horizontal">
|
||||||
|
<dt>{% trans "Name" %}</dt>
|
||||||
|
<dd>{{ group.name|default:_("-") }}</dd>
|
||||||
|
<dt>{% trans "ID" %}</dt>
|
||||||
|
<dd>{{ group.id }}</dd>
|
||||||
|
<dt>{% trans "Description" %}</dt>
|
||||||
|
<dd>{{ group.description|default:_("-") }}</dd>
|
||||||
|
<dt>{% trans "Status" %}</dt>
|
||||||
|
<dd>{{ group.status|capfirst }}</dd>
|
||||||
|
<dt>{% trans "Availability Zone" %}</dt>
|
||||||
|
<dd>{{ group.availability_zone }}</dd>
|
||||||
|
<dt>{% trans "Group Type" %}</dt>
|
||||||
|
<dd>{{ group.group_type }}</dd>
|
||||||
|
<dt>{% trans "Created" %}</dt>
|
||||||
|
<dd>{{ group.created_at|parse_isotime }}</dd>
|
||||||
|
<dt>{% trans "Replication Status" %}</dt>
|
||||||
|
<dd>{{ group.replication_status }}</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<h4>{% trans "Volume Types" %}</h4>
|
||||||
|
<hr class="header_rule">
|
||||||
|
<dl class="dl-horizontal">
|
||||||
|
{% for vol_type_name in group.volume_type_names %}
|
||||||
|
<dd>{{ vol_type_name }}</dd>
|
||||||
|
{% endfor %}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<h4>{% trans "Volumes" %}</h4>
|
||||||
|
<hr class="header_rule">
|
||||||
|
<dl class="dl-horizontal">
|
||||||
|
{% for vol in group.volume_names %}
|
||||||
|
<dd><a href="{% url 'horizon:project:volumes:detail' vol.id %}">{{ vol.name }}</a></dd>
|
||||||
|
{% empty %}
|
||||||
|
<dd>
|
||||||
|
<em>{% trans "No assigned volumes" %}</em>
|
||||||
|
</dd>
|
||||||
|
{% endfor %}
|
||||||
|
</dl>
|
||||||
|
</div>
|
@ -0,0 +1,7 @@
|
|||||||
|
{% extends "horizon/common/_modal_form.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% block title %}{{ page_title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block modal-body %}
|
||||||
|
<p>{% trans "This action will unassign all volumes that are currently contained in this group." %}</p>
|
||||||
|
{% endblock %}
|
@ -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 %}
|
@ -0,0 +1,7 @@
|
|||||||
|
{% extends "horizon/common/_modal_form.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% block title %}{{ page_title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block modal-body-right %}
|
||||||
|
<p>{% trans "Modify the name and description of a volume group." %}</p>
|
||||||
|
{% endblock %}
|
@ -0,0 +1,7 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% block title %}{{ page_title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
{% include 'project/volume_groups/_clone_cgroup.html' %}
|
||||||
|
{% endblock %}
|
@ -0,0 +1,7 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% block title %}{{ page_title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
{% include 'horizon/common/_workflow.html' %}
|
||||||
|
{% endblock %}
|
@ -0,0 +1,7 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% block title %}{{ page_title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
{% include 'project/volume_groups/_create_snapshot.html' %}
|
||||||
|
{% endblock %}
|
@ -0,0 +1,7 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% block title %}{{ page_title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
{% include 'project/volumes/volume_groups/_delete.html' %}
|
||||||
|
{% endblock %}
|
@ -0,0 +1,7 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% block title %}{{ page_title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
{% include 'project/volume_groups/_remove_vols.html' %}
|
||||||
|
{% endblock %}
|
@ -0,0 +1,7 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% block title %}{{ page_title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
{% include 'project/volume_groups/_update.html' %}
|
||||||
|
{% endblock %}
|
378
openstack_dashboard/dashboards/project/volume_groups/tests.py
Normal file
378
openstack_dashboard/dashboards/project/volume_groups/tests.py
Normal file
@ -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'])
|
44
openstack_dashboard/dashboards/project/volume_groups/urls.py
Normal file
44
openstack_dashboard/dashboards/project/volume_groups/urls.py
Normal file
@ -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<group_id>[^/]+)$',
|
||||||
|
views.DetailView.as_view(),
|
||||||
|
name='detail'),
|
||||||
|
url(r'^create/$',
|
||||||
|
views.CreateView.as_view(),
|
||||||
|
name='create'),
|
||||||
|
url(r'^(?P<group_id>[^/]+)/update/$',
|
||||||
|
views.UpdateView.as_view(),
|
||||||
|
name='update'),
|
||||||
|
url(r'^(?P<group_id>[^/]+)/remove_volumese/$',
|
||||||
|
views.RemoveVolumesView.as_view(),
|
||||||
|
name='remove_volumes'),
|
||||||
|
url(r'^(?P<group_id>[^/]+)/delete/$',
|
||||||
|
views.DeleteView.as_view(),
|
||||||
|
name='delete'),
|
||||||
|
url(r'^(?P<group_id>[^/]+)/manage/$',
|
||||||
|
views.ManageView.as_view(),
|
||||||
|
name='manage'),
|
||||||
|
url(r'^(?P<group_id>[^/]+)/create_snapshot/$',
|
||||||
|
views.CreateSnapshotView.as_view(),
|
||||||
|
name='create_snapshot'),
|
||||||
|
url(r'^(?P<group_id>[^/]+)/clone_group/$',
|
||||||
|
views.CloneGroupView.as_view(),
|
||||||
|
name='clone_group'),
|
||||||
|
]
|
312
openstack_dashboard/dashboards/project/volume_groups/views.py
Normal file
312
openstack_dashboard/dashboards/project/volume_groups/views.py
Normal file
@ -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)
|
@ -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
|
@ -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'
|
@ -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'
|
@ -280,11 +280,26 @@ TEST_GLOBAL_MOCKS_ON_PANELS = {
|
|||||||
'.aggregates.panel.Aggregates.can_access'),
|
'.aggregates.panel.Aggregates.can_access'),
|
||||||
'return_value': True,
|
'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': {
|
'domains': {
|
||||||
'method': ('openstack_dashboard.dashboards.identity'
|
'method': ('openstack_dashboard.dashboards.identity'
|
||||||
'.domains.panel.Domains.can_access'),
|
'.domains.panel.Domains.can_access'),
|
||||||
'return_value': True,
|
'return_value': True,
|
||||||
},
|
},
|
||||||
|
'qos': {
|
||||||
|
'method': ('openstack_dashboard.dashboards.project'
|
||||||
|
'.network_qos.panel.NetworkQoS.can_access'),
|
||||||
|
'return_value': True,
|
||||||
|
},
|
||||||
'server_groups': {
|
'server_groups': {
|
||||||
'method': ('openstack_dashboard.dashboards.project'
|
'method': ('openstack_dashboard.dashboards.project'
|
||||||
'.server_groups.panel.ServerGroups.can_access'),
|
'.server_groups.panel.ServerGroups.can_access'),
|
||||||
@ -300,9 +315,14 @@ TEST_GLOBAL_MOCKS_ON_PANELS = {
|
|||||||
'.trunks.panel.Trunks.can_access'),
|
'.trunks.panel.Trunks.can_access'),
|
||||||
'return_value': True,
|
'return_value': True,
|
||||||
},
|
},
|
||||||
'qos': {
|
'volume_groups': {
|
||||||
'method': ('openstack_dashboard.dashboards.project'
|
'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,
|
'return_value': True,
|
||||||
},
|
},
|
||||||
'application_credentials': {
|
'application_credentials': {
|
||||||
|
@ -27,11 +27,14 @@ from cinderclient.v2 import volume_transfers
|
|||||||
from cinderclient.v2 import volume_type_access
|
from cinderclient.v2 import volume_type_access
|
||||||
from cinderclient.v2 import volume_types
|
from cinderclient.v2 import volume_types
|
||||||
from cinderclient.v2 import volumes
|
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 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.test.test_data import utils
|
||||||
|
from openstack_dashboard.usage import quotas as usage_quotas
|
||||||
|
|
||||||
|
|
||||||
def data(TEST):
|
def data(TEST):
|
||||||
@ -55,6 +58,10 @@ def data(TEST):
|
|||||||
TEST.cinder_consistencygroups = utils.TestDataContainer()
|
TEST.cinder_consistencygroups = utils.TestDataContainer()
|
||||||
TEST.cinder_cgroup_volumes = utils.TestDataContainer()
|
TEST.cinder_cgroup_volumes = utils.TestDataContainer()
|
||||||
TEST.cinder_cg_snapshots = 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
|
# Services
|
||||||
service_1 = services.Service(services.ServiceManager(None), {
|
service_1 = services.Service(services.ServiceManager(None), {
|
||||||
@ -150,18 +157,20 @@ def data(TEST):
|
|||||||
|
|
||||||
TEST.cinder_bootable_volumes.add(api.cinder.Volume(non_bootable_volume))
|
TEST.cinder_bootable_volumes.add(api.cinder.Volume(non_bootable_volume))
|
||||||
|
|
||||||
vol_type1 = volume_types.VolumeType(volume_types.VolumeTypeManager(None),
|
vol_type1 = volume_types.VolumeType(
|
||||||
|
volume_types.VolumeTypeManager(None),
|
||||||
{'id': u'1',
|
{'id': u'1',
|
||||||
'name': u'vol_type_1',
|
'name': u'vol_type_1',
|
||||||
'description': 'type 1 description',
|
'description': 'type 1 description',
|
||||||
'extra_specs': {'foo': 'bar',
|
'extra_specs': {'foo': 'bar',
|
||||||
'volume_backend_name':
|
'volume_backend_name': 'backend_1'}})
|
||||||
'backend_1'}})
|
vol_type2 = volume_types.VolumeType(
|
||||||
vol_type2 = volume_types.VolumeType(volume_types.VolumeTypeManager(None),
|
volume_types.VolumeTypeManager(None),
|
||||||
{'id': u'2',
|
{'id': u'2',
|
||||||
'name': u'vol_type_2',
|
'name': u'vol_type_2',
|
||||||
'description': 'type 2 description'})
|
'description': 'type 2 description'})
|
||||||
vol_type3 = volume_types.VolumeType(volume_types.VolumeTypeManager(None),
|
vol_type3 = volume_types.VolumeType(
|
||||||
|
volume_types.VolumeTypeManager(None),
|
||||||
{'id': u'3',
|
{'id': u'3',
|
||||||
'name': u'vol_type_3',
|
'name': u'vol_type_3',
|
||||||
'is_public': False,
|
'is_public': False,
|
||||||
@ -488,3 +497,72 @@ def data(TEST):
|
|||||||
'description': 'cg_ss 1 description',
|
'description': 'cg_ss 1 description',
|
||||||
'consistencygroup_id': u'1'})
|
'consistencygroup_id': u'1'})
|
||||||
TEST.cinder_cg_snapshots.add(cg_snapshot_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)
|
||||||
|
@ -24,16 +24,27 @@ from openstack_dashboard.test import helpers as test
|
|||||||
|
|
||||||
class CinderApiTests(test.APIMockTestCase):
|
class CinderApiTests(test.APIMockTestCase):
|
||||||
|
|
||||||
@mock.patch.object(api.cinder, 'cinderclient')
|
def _stub_cinderclient_with_generic_group(self):
|
||||||
def test_volume_list(self, mock_cinderclient):
|
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}
|
search_opts = {'all_tenants': 1}
|
||||||
detailed = True
|
detailed = True
|
||||||
|
|
||||||
volumes = self.cinder_volumes.list()
|
volumes = self.cinder_volumes.list()
|
||||||
volume_transfers = self.cinder_volume_transfers.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
|
volumes_mock.return_value = volumes
|
||||||
|
|
||||||
transfers_mock = cinderclient.transfers.list
|
transfers_mock = cinderclient.transfers.list
|
||||||
@ -47,15 +58,21 @@ class CinderApiTests(test.APIMockTestCase):
|
|||||||
search_opts=search_opts)
|
search_opts=search_opts)
|
||||||
self.assertEqual(len(volumes), len(api_volumes))
|
self.assertEqual(len(volumes), len(api_volumes))
|
||||||
|
|
||||||
@mock.patch.object(api.cinder, 'cinderclient')
|
@test.create_mocks({
|
||||||
def test_volume_list_paged(self, mock_cinderclient):
|
api.cinder: [
|
||||||
|
'cinderclient',
|
||||||
|
('_cinderclient_with_generic_groups', 'cinderclient_groups'),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
def test_volume_list_paged(self):
|
||||||
search_opts = {'all_tenants': 1}
|
search_opts = {'all_tenants': 1}
|
||||||
detailed = True
|
detailed = True
|
||||||
volumes = self.cinder_volumes.list()
|
volumes = self.cinder_volumes.list()
|
||||||
volume_transfers = self.cinder_volume_transfers.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
|
volumes_mock.return_value = volumes
|
||||||
|
|
||||||
transfers_mock = cinderclient.transfers.list
|
transfers_mock = cinderclient.transfers.list
|
||||||
@ -73,8 +90,13 @@ class CinderApiTests(test.APIMockTestCase):
|
|||||||
|
|
||||||
@override_settings(API_RESULT_PAGE_SIZE=2)
|
@override_settings(API_RESULT_PAGE_SIZE=2)
|
||||||
@override_settings(OPENSTACK_API_VERSIONS={'volume': 2})
|
@override_settings(OPENSTACK_API_VERSIONS={'volume': 2})
|
||||||
@mock.patch.object(api.cinder, 'cinderclient')
|
@test.create_mocks({
|
||||||
def test_volume_list_paginate_first_page(self, mock_cinderclient):
|
api.cinder: [
|
||||||
|
'cinderclient',
|
||||||
|
('_cinderclient_with_generic_groups', 'cinderclient_groups'),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
def test_volume_list_paginate_first_page(self):
|
||||||
api.cinder.VERSIONS._active = None
|
api.cinder.VERSIONS._active = None
|
||||||
page_size = settings.API_RESULT_PAGE_SIZE
|
page_size = settings.API_RESULT_PAGE_SIZE
|
||||||
volumes = self.cinder_volumes.list()
|
volumes = self.cinder_volumes.list()
|
||||||
@ -84,9 +106,10 @@ class CinderApiTests(test.APIMockTestCase):
|
|||||||
mock_volumes = volumes[:page_size + 1]
|
mock_volumes = volumes[:page_size + 1]
|
||||||
expected_volumes = mock_volumes[:-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
|
volumes_mock.return_value = mock_volumes
|
||||||
|
|
||||||
transfers_mock = cinderclient.transfers.list
|
transfers_mock = cinderclient.transfers.list
|
||||||
@ -107,8 +130,13 @@ class CinderApiTests(test.APIMockTestCase):
|
|||||||
|
|
||||||
@override_settings(API_RESULT_PAGE_SIZE=2)
|
@override_settings(API_RESULT_PAGE_SIZE=2)
|
||||||
@override_settings(OPENSTACK_API_VERSIONS={'volume': 2})
|
@override_settings(OPENSTACK_API_VERSIONS={'volume': 2})
|
||||||
@mock.patch.object(api.cinder, 'cinderclient')
|
@test.create_mocks({
|
||||||
def test_volume_list_paginate_second_page(self, mock_cinderclient):
|
api.cinder: [
|
||||||
|
'cinderclient',
|
||||||
|
('_cinderclient_with_generic_groups', 'cinderclient_groups'),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
def test_volume_list_paginate_second_page(self):
|
||||||
api.cinder.VERSIONS._active = None
|
api.cinder.VERSIONS._active = None
|
||||||
page_size = settings.API_RESULT_PAGE_SIZE
|
page_size = settings.API_RESULT_PAGE_SIZE
|
||||||
volumes = self.cinder_volumes.list()
|
volumes = self.cinder_volumes.list()
|
||||||
@ -119,9 +147,10 @@ class CinderApiTests(test.APIMockTestCase):
|
|||||||
expected_volumes = mock_volumes[:-1]
|
expected_volumes = mock_volumes[:-1]
|
||||||
marker = expected_volumes[0].id
|
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
|
volumes_mock.return_value = mock_volumes
|
||||||
|
|
||||||
transfers_mock = cinderclient.transfers.list
|
transfers_mock = cinderclient.transfers.list
|
||||||
@ -143,8 +172,13 @@ class CinderApiTests(test.APIMockTestCase):
|
|||||||
|
|
||||||
@override_settings(API_RESULT_PAGE_SIZE=2)
|
@override_settings(API_RESULT_PAGE_SIZE=2)
|
||||||
@override_settings(OPENSTACK_API_VERSIONS={'volume': 2})
|
@override_settings(OPENSTACK_API_VERSIONS={'volume': 2})
|
||||||
@mock.patch.object(api.cinder, 'cinderclient')
|
@test.create_mocks({
|
||||||
def test_volume_list_paginate_last_page(self, mock_cinderclient):
|
api.cinder: [
|
||||||
|
'cinderclient',
|
||||||
|
('_cinderclient_with_generic_groups', 'cinderclient_groups'),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
def test_volume_list_paginate_last_page(self):
|
||||||
api.cinder.VERSIONS._active = None
|
api.cinder.VERSIONS._active = None
|
||||||
page_size = settings.API_RESULT_PAGE_SIZE
|
page_size = settings.API_RESULT_PAGE_SIZE
|
||||||
volumes = self.cinder_volumes.list()
|
volumes = self.cinder_volumes.list()
|
||||||
@ -155,9 +189,10 @@ class CinderApiTests(test.APIMockTestCase):
|
|||||||
expected_volumes = mock_volumes
|
expected_volumes = mock_volumes
|
||||||
marker = expected_volumes[0].id
|
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
|
volumes_mock.return_value = mock_volumes
|
||||||
|
|
||||||
transfers_mock = cinderclient.transfers.list
|
transfers_mock = cinderclient.transfers.list
|
||||||
@ -179,8 +214,13 @@ class CinderApiTests(test.APIMockTestCase):
|
|||||||
|
|
||||||
@override_settings(API_RESULT_PAGE_SIZE=2)
|
@override_settings(API_RESULT_PAGE_SIZE=2)
|
||||||
@override_settings(OPENSTACK_API_VERSIONS={'volume': 2})
|
@override_settings(OPENSTACK_API_VERSIONS={'volume': 2})
|
||||||
@mock.patch.object(api.cinder, 'cinderclient')
|
@test.create_mocks({
|
||||||
def test_volume_list_paginate_back_from_some_page(self, mock_cinderclient):
|
api.cinder: [
|
||||||
|
'cinderclient',
|
||||||
|
('_cinderclient_with_generic_groups', 'cinderclient_groups'),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
def test_volume_list_paginate_back_from_some_page(self):
|
||||||
api.cinder.VERSIONS._active = None
|
api.cinder.VERSIONS._active = None
|
||||||
page_size = settings.API_RESULT_PAGE_SIZE
|
page_size = settings.API_RESULT_PAGE_SIZE
|
||||||
volumes = self.cinder_volumes.list()
|
volumes = self.cinder_volumes.list()
|
||||||
@ -191,9 +231,10 @@ class CinderApiTests(test.APIMockTestCase):
|
|||||||
expected_volumes = mock_volumes[:-1]
|
expected_volumes = mock_volumes[:-1]
|
||||||
marker = expected_volumes[0].id
|
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
|
volumes_mock.return_value = mock_volumes
|
||||||
|
|
||||||
transfers_mock = cinderclient.transfers.list
|
transfers_mock = cinderclient.transfers.list
|
||||||
@ -215,8 +256,13 @@ class CinderApiTests(test.APIMockTestCase):
|
|||||||
|
|
||||||
@override_settings(API_RESULT_PAGE_SIZE=2)
|
@override_settings(API_RESULT_PAGE_SIZE=2)
|
||||||
@override_settings(OPENSTACK_API_VERSIONS={'volume': 2})
|
@override_settings(OPENSTACK_API_VERSIONS={'volume': 2})
|
||||||
@mock.patch.object(api.cinder, 'cinderclient')
|
@test.create_mocks({
|
||||||
def test_volume_list_paginate_back_to_first_page(self, mock_cinderclient):
|
api.cinder: [
|
||||||
|
'cinderclient',
|
||||||
|
('_cinderclient_with_generic_groups', 'cinderclient_groups'),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
def test_volume_list_paginate_back_to_first_page(self):
|
||||||
api.cinder.VERSIONS._active = None
|
api.cinder.VERSIONS._active = None
|
||||||
page_size = settings.API_RESULT_PAGE_SIZE
|
page_size = settings.API_RESULT_PAGE_SIZE
|
||||||
volumes = self.cinder_volumes.list()
|
volumes = self.cinder_volumes.list()
|
||||||
@ -227,9 +273,10 @@ class CinderApiTests(test.APIMockTestCase):
|
|||||||
expected_volumes = mock_volumes
|
expected_volumes = mock_volumes
|
||||||
marker = expected_volumes[0].id
|
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
|
volumes_mock.return_value = mock_volumes
|
||||||
|
|
||||||
transfers_mock = cinderclient.transfers.list
|
transfers_mock = cinderclient.transfers.list
|
||||||
|
Loading…
Reference in New Issue
Block a user