Add Swift pseudo-folder support to Horizon.
Implements blueprint swift-folders. Change-Id: If29ad3cc1fcfb9b7bdb66d915a667f3363d38da0
This commit is contained in:
parent
f6802a9058
commit
59e862e422
1
.gitignore
vendored
1
.gitignore
vendored
@ -19,3 +19,4 @@ build
|
||||
dist
|
||||
AUTHORS
|
||||
ChangeLog
|
||||
tags
|
||||
|
@ -87,12 +87,15 @@ def swift_delete_container(request, name):
|
||||
swift_api(request).delete_container(name)
|
||||
|
||||
|
||||
def swift_get_objects(request, container_name, prefix=None, marker=None):
|
||||
def swift_get_objects(request, container_name, prefix=None, path=None,
|
||||
marker=None):
|
||||
limit = getattr(settings, 'API_RESULT_LIMIT', 1000)
|
||||
container = swift_api(request).get_container(container_name)
|
||||
objects = container.get_objects(prefix=prefix,
|
||||
marker=marker,
|
||||
limit=limit + 1)
|
||||
limit=limit + 1,
|
||||
delimiter="/",
|
||||
path=path)
|
||||
if(len(objects) > limit):
|
||||
return (objects[0:-1], True)
|
||||
else:
|
||||
@ -122,6 +125,16 @@ def swift_copy_object(request, orig_container_name, orig_object_name,
|
||||
return orig_obj.copy_to(new_container_name, new_object_name)
|
||||
|
||||
|
||||
def swift_create_subfolder(request, container_name, folder_name):
|
||||
container = swift_api(request).get_container(container_name)
|
||||
obj = container.create_object(folder_name)
|
||||
obj.headers = {'content-type': 'application/directory',
|
||||
'content-length': 0}
|
||||
obj.send('')
|
||||
obj.sync_metadata()
|
||||
return obj
|
||||
|
||||
|
||||
def swift_upload_object(request, container_name, object_name, object_file):
|
||||
container = swift_api(request).get_container(container_name)
|
||||
obj = container.create_object(object_name)
|
||||
|
@ -41,21 +41,47 @@ no_slash_validator = validators.RegexValidator(r'^(?u)[^/]+$',
|
||||
|
||||
|
||||
class CreateContainer(forms.SelfHandlingForm):
|
||||
name = forms.CharField(max_length="255",
|
||||
parent = forms.CharField(max_length=255,
|
||||
required=False,
|
||||
widget=forms.HiddenInput)
|
||||
name = forms.CharField(max_length=255,
|
||||
label=_("Container Name"),
|
||||
validators=[no_slash_validator])
|
||||
|
||||
def handle(self, request, data):
|
||||
try:
|
||||
api.swift_create_container(request, data['name'])
|
||||
messages.success(request, _("Container created successfully."))
|
||||
if not data['parent']:
|
||||
# Create a container
|
||||
api.swift_create_container(request, data["name"])
|
||||
messages.success(request, _("Container created successfully."))
|
||||
else:
|
||||
# Create a pseudo-folder
|
||||
container, slash, remainder = data['parent'].partition("/")
|
||||
remainder = remainder.rstrip("/")
|
||||
subfolder_name = "/".join([bit for bit
|
||||
in (remainder, data['name'])
|
||||
if bit])
|
||||
api.swift_create_subfolder(request,
|
||||
container,
|
||||
subfolder_name)
|
||||
messages.success(request, _("Folder created successfully."))
|
||||
url = "horizon:nova:containers:object_index"
|
||||
if remainder:
|
||||
remainder = remainder.rstrip("/")
|
||||
remainder += "/"
|
||||
return shortcuts.redirect(url, container, remainder)
|
||||
|
||||
except:
|
||||
exceptions.handle(request, _('Unable to create container.'))
|
||||
|
||||
return shortcuts.redirect("horizon:nova:containers:index")
|
||||
|
||||
|
||||
class UploadObject(forms.SelfHandlingForm):
|
||||
name = forms.CharField(max_length="255",
|
||||
path = forms.CharField(max_length=255,
|
||||
required=False,
|
||||
widget=forms.HiddenInput)
|
||||
name = forms.CharField(max_length=255,
|
||||
label=_("Object Name"),
|
||||
validators=[no_slash_validator])
|
||||
object_file = forms.FileField(label=_("File"))
|
||||
@ -63,10 +89,14 @@ class UploadObject(forms.SelfHandlingForm):
|
||||
|
||||
def handle(self, request, data):
|
||||
object_file = self.files['object_file']
|
||||
if data['path']:
|
||||
object_path = "/".join([data['path'].rstrip("/"), data['name']])
|
||||
else:
|
||||
object_path = data['name']
|
||||
try:
|
||||
obj = api.swift_upload_object(request,
|
||||
data['container_name'],
|
||||
data['name'],
|
||||
object_path,
|
||||
object_file)
|
||||
obj.metadata['orig-filename'] = object_file.name
|
||||
obj.sync_metadata()
|
||||
@ -74,13 +104,14 @@ class UploadObject(forms.SelfHandlingForm):
|
||||
except:
|
||||
exceptions.handle(request, _("Unable to upload object."))
|
||||
return shortcuts.redirect("horizon:nova:containers:object_index",
|
||||
data['container_name'])
|
||||
data['container_name'], data['path'])
|
||||
|
||||
|
||||
class CopyObject(forms.SelfHandlingForm):
|
||||
new_container_name = forms.ChoiceField(label=_("Destination container"),
|
||||
validators=[no_slash_validator])
|
||||
new_object_name = forms.CharField(max_length="255",
|
||||
path = forms.CharField(max_length=255, required=False)
|
||||
new_object_name = forms.CharField(max_length=255,
|
||||
label=_("Destination object name"),
|
||||
validators=[no_slash_validator])
|
||||
orig_container_name = forms.CharField(widget=forms.HiddenInput())
|
||||
@ -97,15 +128,38 @@ class CopyObject(forms.SelfHandlingForm):
|
||||
orig_object = data['orig_object_name']
|
||||
new_container = data['new_container_name']
|
||||
new_object = data['new_object_name']
|
||||
new_path = "%s%s" % (data['path'], new_object)
|
||||
|
||||
# Iteratively make sure all the directory markers exist.
|
||||
if data['path']:
|
||||
path_component = ""
|
||||
for bit in data['path'].split("/"):
|
||||
path_component += bit
|
||||
try:
|
||||
api.swift.swift_create_subfolder(request,
|
||||
new_container,
|
||||
path_component)
|
||||
except:
|
||||
redirect = reverse(object_index, args=(orig_container,))
|
||||
exceptions.handle(request,
|
||||
_("Unable to copy object."),
|
||||
redirect=redirect)
|
||||
path_component += "/"
|
||||
|
||||
# Now copy the object itself.
|
||||
try:
|
||||
api.swift_copy_object(request,
|
||||
orig_container,
|
||||
orig_object,
|
||||
new_container,
|
||||
new_object)
|
||||
vals = {"container": new_container, "obj": new_object}
|
||||
messages.success(request, _('Object "%(obj)s" copied to container '
|
||||
'"%(container)s".') % vals)
|
||||
new_path)
|
||||
dest = "%s/%s" % (new_container, data['path'])
|
||||
vals = {"dest": dest.rstrip("/"),
|
||||
"orig": orig_object.split("/")[-1],
|
||||
"new": new_object}
|
||||
messages.success(request,
|
||||
_('Copied "%(orig)s" to "%(dest)s" as "%(new)s".')
|
||||
% vals)
|
||||
except exceptions.HorizonException, exc:
|
||||
messages.error(request, exc)
|
||||
return shortcuts.redirect(object_index, orig_container)
|
||||
@ -114,4 +168,4 @@ class CopyObject(forms.SelfHandlingForm):
|
||||
exceptions.handle(request,
|
||||
_("Unable to copy object."),
|
||||
redirect=redirect)
|
||||
return shortcuts.redirect(object_index, new_container)
|
||||
return shortcuts.redirect(object_index, new_container, data['path'])
|
||||
|
@ -52,7 +52,7 @@ class CreateContainer(tables.LinkAction):
|
||||
|
||||
class ListObjects(tables.LinkAction):
|
||||
name = "list_objects"
|
||||
verbose_name = _("List Objects")
|
||||
verbose_name = _("View Container")
|
||||
url = "horizon:nova:containers:object_index"
|
||||
classes = ("btn-list",)
|
||||
|
||||
@ -71,7 +71,10 @@ class UploadObject(tables.LinkAction):
|
||||
else:
|
||||
# This is a table action and we already have the container name
|
||||
container_name = self.table.kwargs['container_name']
|
||||
return reverse(self.url, args=(container_name,))
|
||||
subfolders = self.table.kwargs.get('subfolder_path', '')
|
||||
args = (http.urlquote(bit) for bit in
|
||||
(container_name, subfolders) if bit)
|
||||
return reverse(self.url, args=args)
|
||||
|
||||
def update(self, request, obj):
|
||||
# This will only be called for the row, so we can remove the button
|
||||
@ -129,7 +132,6 @@ class DownloadObject(tables.LinkAction):
|
||||
classes = ("btn-download",)
|
||||
|
||||
def get_link_url(self, obj):
|
||||
#assert False, obj.__dict__['_apiresource'].__dict__
|
||||
return reverse(self.url, args=(http.urlquote(obj.container.name),
|
||||
http.urlquote(obj.name)))
|
||||
|
||||
@ -147,12 +149,18 @@ class ObjectFilterAction(tables.FilterAction):
|
||||
return filter(comp, objects)
|
||||
|
||||
|
||||
def sanitize_name(name):
|
||||
return name.split("/")[-1]
|
||||
|
||||
|
||||
def get_size(obj):
|
||||
return filesizeformat(obj.size)
|
||||
|
||||
|
||||
class ObjectsTable(tables.DataTable):
|
||||
name = tables.Column("name", verbose_name=_("Object Name"))
|
||||
name = tables.Column("name",
|
||||
verbose_name=_("Object Name"),
|
||||
filters=(sanitize_name,))
|
||||
size = tables.Column(get_size, verbose_name=_('Size'))
|
||||
|
||||
def get_object_id(self, obj):
|
||||
@ -163,3 +171,42 @@ class ObjectsTable(tables.DataTable):
|
||||
verbose_name = _("Objects")
|
||||
table_actions = (ObjectFilterAction, UploadObject, DeleteObject)
|
||||
row_actions = (DownloadObject, CopyObject, DeleteObject)
|
||||
|
||||
|
||||
def get_link_subfolder(subfolder):
|
||||
return reverse("horizon:nova:containers:object_index",
|
||||
args=(http.urlquote(subfolder.container.name),
|
||||
http.urlquote(subfolder.name + "/")))
|
||||
|
||||
|
||||
class CreateSubfolder(CreateContainer):
|
||||
verbose_name = _("Create Folder")
|
||||
url = "horizon:nova:containers:create"
|
||||
|
||||
def get_link_url(self):
|
||||
container = self.table.kwargs['container_name']
|
||||
subfolders = self.table.kwargs['subfolder_path']
|
||||
parent = "/".join((bit for bit in [container, subfolders] if bit))
|
||||
parent = parent.rstrip("/")
|
||||
return reverse(self.url, args=(http.urlquote(parent + "/"),))
|
||||
|
||||
|
||||
class DeleteSubfolder(DeleteObject):
|
||||
data_type_singular = _("Folder")
|
||||
data_type_plural = _("Folders")
|
||||
|
||||
|
||||
class ContainerSubfoldersTable(tables.DataTable):
|
||||
name = tables.Column("name",
|
||||
link=get_link_subfolder,
|
||||
verbose_name=_("Subfolder Name"),
|
||||
filters=(sanitize_name,))
|
||||
|
||||
def get_object_id(self, obj):
|
||||
return obj.name
|
||||
|
||||
class Meta:
|
||||
name = "subfolders"
|
||||
verbose_name = _("Subfolders")
|
||||
table_actions = (CreateSubfolder, DeleteSubfolder)
|
||||
row_actions = (DeleteSubfolder,)
|
||||
|
@ -102,16 +102,18 @@ class ObjectViewTests(test.TestCase):
|
||||
ret = (self.objects.list(), False)
|
||||
api.swift_get_objects(IsA(http.HttpRequest),
|
||||
self.containers.first().name,
|
||||
marker=None).AndReturn(ret)
|
||||
marker=None,
|
||||
path=None).AndReturn(ret)
|
||||
self.mox.ReplayAll()
|
||||
|
||||
res = self.client.get(reverse('horizon:nova:containers:object_index',
|
||||
args=[self.containers.first().name]))
|
||||
self.assertTemplateUsed(res, 'nova/objects/index.html')
|
||||
expected = [obj.name for obj in self.objects.list()]
|
||||
self.assertQuerysetEqual(res.context['table'].data,
|
||||
# UTF8 encoding here to ensure there aren't problems with Nose output.
|
||||
expected = [obj.name.encode('utf8') for obj in self.objects.list()]
|
||||
self.assertQuerysetEqual(res.context['objects_table'].data,
|
||||
expected,
|
||||
lambda obj: obj.name)
|
||||
lambda obj: obj.name.encode('utf8'))
|
||||
|
||||
def test_upload_index(self):
|
||||
res = self.client.get(reverse('horizon:nova:containers:object_upload',
|
||||
|
@ -23,16 +23,29 @@ from django.conf.urls.defaults import patterns, url
|
||||
from .views import IndexView, CreateView, UploadView, ObjectIndexView, CopyView
|
||||
|
||||
|
||||
OBJECTS = r'^(?P<container_name>[^/]+)/%s$'
|
||||
|
||||
|
||||
# Swift containers and objects.
|
||||
urlpatterns = patterns('horizon.dashboards.nova.containers.views',
|
||||
url(r'^$', IndexView.as_view(), name='index'),
|
||||
url(r'^create/$', CreateView.as_view(), name='create'),
|
||||
url(OBJECTS % r'$', ObjectIndexView.as_view(), name='object_index'),
|
||||
url(OBJECTS % r'upload$', UploadView.as_view(), name='object_upload'),
|
||||
url(OBJECTS % r'(?P<object_name>[^/]+)/copy$',
|
||||
CopyView.as_view(), name='object_copy'),
|
||||
url(OBJECTS % r'(?P<object_name>[^/]+)/download$',
|
||||
'object_download', name='object_download'))
|
||||
|
||||
url(r'^(?P<container_name>(.+/)+)?create$',
|
||||
CreateView.as_view(),
|
||||
name='create'),
|
||||
|
||||
url(r'^(?P<container_name>[^/]+)/(?P<subfolder_path>(.+/)+)?$',
|
||||
ObjectIndexView.as_view(),
|
||||
name='object_index'),
|
||||
|
||||
url(r'^(?P<container_name>[^/]+)/(?P<subfolder_path>(.+/)+)?upload$',
|
||||
UploadView.as_view(),
|
||||
name='object_upload'),
|
||||
|
||||
url(r'^(?P<container_name>[^/]+)/'
|
||||
r'(?P<subfolder_path>(.+/)+)?'
|
||||
r'(?P<object_name>.+)/copy$',
|
||||
CopyView.as_view(),
|
||||
name='object_copy'),
|
||||
|
||||
url(r'^(?P<container_name>[^/]+)/(?P<object_path>.+)/download$',
|
||||
'object_download',
|
||||
name='object_download')
|
||||
)
|
||||
|
@ -33,7 +33,8 @@ from horizon import exceptions
|
||||
from horizon import forms
|
||||
from horizon import tables
|
||||
from .forms import CreateContainer, UploadObject, CopyObject
|
||||
from .tables import ContainersTable, ObjectsTable
|
||||
from .tables import ContainersTable, ObjectsTable,\
|
||||
ContainerSubfoldersTable
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
@ -63,31 +64,68 @@ class CreateView(forms.ModalFormView):
|
||||
form_class = CreateContainer
|
||||
template_name = 'nova/containers/create.html'
|
||||
|
||||
def get_initial(self):
|
||||
initial = super(CreateView, self).get_initial()
|
||||
initial['parent'] = self.kwargs['container_name']
|
||||
return initial
|
||||
|
||||
class ObjectIndexView(tables.DataTableView):
|
||||
table_class = ObjectsTable
|
||||
|
||||
class ObjectIndexView(tables.MultiTableView):
|
||||
table_classes = (ObjectsTable, ContainerSubfoldersTable)
|
||||
template_name = 'nova/objects/index.html'
|
||||
|
||||
def has_more_data(self, table):
|
||||
return self._more
|
||||
|
||||
def get_data(self):
|
||||
objects = []
|
||||
self._more = None
|
||||
marker = self.request.GET.get('marker', None)
|
||||
container_name = self.kwargs['container_name']
|
||||
try:
|
||||
objects, self._more = api.swift_get_objects(self.request,
|
||||
container_name,
|
||||
marker=marker)
|
||||
except:
|
||||
msg = _('Unable to retrieve object list.')
|
||||
exceptions.handle(self.request, msg)
|
||||
return objects
|
||||
@property
|
||||
def objects(self):
|
||||
""" Returns a list of objects given the subfolder's path.
|
||||
|
||||
The path is from the kwargs of the request
|
||||
"""
|
||||
if not hasattr(self, "_objects"):
|
||||
objects = []
|
||||
self._more = None
|
||||
marker = self.request.GET.get('marker', None)
|
||||
container_name = self.kwargs['container_name']
|
||||
subfolders = self.kwargs['subfolder_path']
|
||||
if subfolders:
|
||||
prefix = subfolders.rstrip("/")
|
||||
else:
|
||||
prefix = None
|
||||
try:
|
||||
objects, self._more = api.swift_get_objects(self.request,
|
||||
container_name,
|
||||
marker=marker,
|
||||
path=prefix)
|
||||
except:
|
||||
objects = []
|
||||
msg = _('Unable to retrieve object list.')
|
||||
exceptions.handle(self.request, msg)
|
||||
self._objects = objects
|
||||
return self._objects
|
||||
|
||||
def get_objects_data(self):
|
||||
""" Returns the objects within the in the current folder.
|
||||
|
||||
These objects are those whose names don't contain '/' after
|
||||
striped the path out
|
||||
"""
|
||||
filtered_objects = [item for item in self.objects if
|
||||
item.content_type != "application/directory"]
|
||||
return filtered_objects
|
||||
|
||||
def get_subfolders_data(self):
|
||||
""" Returns a list of subfolders given the current folder path.
|
||||
"""
|
||||
filtered_objects = [item for item in self.objects if
|
||||
item.content_type == "application/directory"]
|
||||
return filtered_objects
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super(ObjectIndexView, self).get_context_data(**kwargs)
|
||||
context['container_name'] = self.kwargs["container_name"]
|
||||
context['subfolder_path'] = self.kwargs["subfolder_path"]
|
||||
return context
|
||||
|
||||
|
||||
@ -96,7 +134,8 @@ class UploadView(forms.ModalFormView):
|
||||
template_name = 'nova/objects/upload.html'
|
||||
|
||||
def get_initial(self):
|
||||
return {"container_name": self.kwargs["container_name"]}
|
||||
return {"container_name": self.kwargs["container_name"],
|
||||
"path": self.kwargs['subfolder_path']}
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super(UploadView, self).get_context_data(**kwargs)
|
||||
@ -104,25 +143,25 @@ class UploadView(forms.ModalFormView):
|
||||
return context
|
||||
|
||||
|
||||
def object_download(request, container_name, object_name):
|
||||
obj = api.swift.swift_get_object(request, container_name, object_name)
|
||||
def object_download(request, container_name, object_path):
|
||||
obj = api.swift.swift_get_object(request, container_name, object_path)
|
||||
# Add the original file extension back on if it wasn't preserved in the
|
||||
# name given to the object.
|
||||
filename = object_name
|
||||
filename = object_path.rsplit("/")[-1]
|
||||
if not os.path.splitext(obj.name)[1]:
|
||||
name, ext = os.path.splitext(obj.metadata.get('orig-filename', ''))
|
||||
filename = "%s%s" % (object_name, ext)
|
||||
filename = "%s%s" % (filename, ext)
|
||||
try:
|
||||
object_data = api.swift_get_object_data(request,
|
||||
container_name,
|
||||
object_name)
|
||||
object_path)
|
||||
except:
|
||||
redirect = reverse("horizon:nova:containers:index")
|
||||
exceptions.handle(request,
|
||||
_("Unable to retrieve object."),
|
||||
redirect=redirect)
|
||||
response = http.HttpResponse()
|
||||
safe_name = filename.encode('utf-8')
|
||||
safe_name = filename.replace(",", "").encode('utf-8')
|
||||
response['Content-Disposition'] = 'attachment; filename=%s' % safe_name
|
||||
response['Content-Type'] = 'application/octet-stream'
|
||||
for data in object_data:
|
||||
@ -147,9 +186,12 @@ class CopyView(forms.ModalFormView):
|
||||
return kwargs
|
||||
|
||||
def get_initial(self):
|
||||
path = self.kwargs["subfolder_path"]
|
||||
orig = "%s%s" % (path or '', self.kwargs["object_name"])
|
||||
return {"new_container_name": self.kwargs["container_name"],
|
||||
"orig_container_name": self.kwargs["container_name"],
|
||||
"orig_object_name": self.kwargs["object_name"],
|
||||
"orig_object_name": orig,
|
||||
"path": path,
|
||||
"new_object_name": "%s copy" % self.kwargs["object_name"]}
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
|
@ -9,7 +9,3 @@
|
||||
{% block dash_main %}
|
||||
{% include "nova/containers/_create.html" %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
|
||||
|
@ -3,9 +3,7 @@
|
||||
{% block title %}Containers{% endblock %}
|
||||
|
||||
{% block page_header %}
|
||||
{% url horizon:nova:images_and_snapshots:images:index as refresh_link %}
|
||||
{# to make searchable false, just remove it from the include statement #}
|
||||
{% include "horizon/common/_page_header.html" with title=_("Containers") refresh_link=refresh_link searchable="true" %}
|
||||
{% include "horizon/common/_page_header.html" with title=_("Containers") %}
|
||||
{% endblock page_header %}
|
||||
|
||||
{% block dash_main %}
|
||||
|
@ -14,7 +14,7 @@
|
||||
</div>
|
||||
<div class="right">
|
||||
<h3>{% trans "Description" %}:</h3>
|
||||
<p>{% trans "You may make a new copy of an existing object to store in this or another container." %}</p>
|
||||
<p>{% trans "Make a new copy of an existing object to store in this or another container. You may also specify a path at which the new copy should live inside of the selected container." %}</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
@ -4,10 +4,15 @@
|
||||
|
||||
{% block page_header %}
|
||||
<div class='page-header'>
|
||||
<h2>Objects <small>Container: {{ container_name }}</small></h2>
|
||||
<h2>{% trans "Container" %}: {{ container_name }}<small>/{{ subfolder_path|default:"" }}</small></h2>
|
||||
</div>
|
||||
{% endblock page_header %}
|
||||
|
||||
{% block dash_main %}
|
||||
{{ table.render }}
|
||||
<div id="subfolders">
|
||||
{{ subfolders_table.render }}
|
||||
</div>
|
||||
<div id="objects">
|
||||
{{ objects_table.render }}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -68,7 +68,9 @@ class SwiftApiTests(test.APITestCase):
|
||||
self.mox.StubOutWithMock(container, 'get_objects')
|
||||
container.get_objects(limit=1001,
|
||||
marker=None,
|
||||
prefix=None).AndReturn(objects)
|
||||
prefix=None,
|
||||
delimiter='/',
|
||||
path=None).AndReturn(objects)
|
||||
self.mox.ReplayAll()
|
||||
|
||||
(objs, more) = api.swift_get_objects(self.request, container.name)
|
||||
|
Loading…
Reference in New Issue
Block a user