Add Swift pseudo-folder support to Horizon.

Implements blueprint swift-folders.

Change-Id: If29ad3cc1fcfb9b7bdb66d915a667f3363d38da0
This commit is contained in:
Ke Wu 2012-06-01 12:06:50 -07:00
parent f6802a9058
commit 59e862e422
12 changed files with 240 additions and 67 deletions

1
.gitignore vendored
View File

@ -19,3 +19,4 @@ build
dist dist
AUTHORS AUTHORS
ChangeLog ChangeLog
tags

View File

@ -87,12 +87,15 @@ def swift_delete_container(request, name):
swift_api(request).delete_container(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) limit = getattr(settings, 'API_RESULT_LIMIT', 1000)
container = swift_api(request).get_container(container_name) container = swift_api(request).get_container(container_name)
objects = container.get_objects(prefix=prefix, objects = container.get_objects(prefix=prefix,
marker=marker, marker=marker,
limit=limit + 1) limit=limit + 1,
delimiter="/",
path=path)
if(len(objects) > limit): if(len(objects) > limit):
return (objects[0:-1], True) return (objects[0:-1], True)
else: 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) 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): def swift_upload_object(request, container_name, object_name, object_file):
container = swift_api(request).get_container(container_name) container = swift_api(request).get_container(container_name)
obj = container.create_object(object_name) obj = container.create_object(object_name)

View File

@ -41,21 +41,47 @@ no_slash_validator = validators.RegexValidator(r'^(?u)[^/]+$',
class CreateContainer(forms.SelfHandlingForm): 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"), label=_("Container Name"),
validators=[no_slash_validator]) validators=[no_slash_validator])
def handle(self, request, data): def handle(self, request, data):
try: try:
api.swift_create_container(request, data['name']) if not data['parent']:
messages.success(request, _("Container created successfully.")) # 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: except:
exceptions.handle(request, _('Unable to create container.')) exceptions.handle(request, _('Unable to create container.'))
return shortcuts.redirect("horizon:nova:containers:index") return shortcuts.redirect("horizon:nova:containers:index")
class UploadObject(forms.SelfHandlingForm): 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"), label=_("Object Name"),
validators=[no_slash_validator]) validators=[no_slash_validator])
object_file = forms.FileField(label=_("File")) object_file = forms.FileField(label=_("File"))
@ -63,10 +89,14 @@ class UploadObject(forms.SelfHandlingForm):
def handle(self, request, data): def handle(self, request, data):
object_file = self.files['object_file'] object_file = self.files['object_file']
if data['path']:
object_path = "/".join([data['path'].rstrip("/"), data['name']])
else:
object_path = data['name']
try: try:
obj = api.swift_upload_object(request, obj = api.swift_upload_object(request,
data['container_name'], data['container_name'],
data['name'], object_path,
object_file) object_file)
obj.metadata['orig-filename'] = object_file.name obj.metadata['orig-filename'] = object_file.name
obj.sync_metadata() obj.sync_metadata()
@ -74,13 +104,14 @@ class UploadObject(forms.SelfHandlingForm):
except: except:
exceptions.handle(request, _("Unable to upload object.")) exceptions.handle(request, _("Unable to upload object."))
return shortcuts.redirect("horizon:nova:containers:object_index", return shortcuts.redirect("horizon:nova:containers:object_index",
data['container_name']) data['container_name'], data['path'])
class CopyObject(forms.SelfHandlingForm): class CopyObject(forms.SelfHandlingForm):
new_container_name = forms.ChoiceField(label=_("Destination container"), new_container_name = forms.ChoiceField(label=_("Destination container"),
validators=[no_slash_validator]) 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"), label=_("Destination object name"),
validators=[no_slash_validator]) validators=[no_slash_validator])
orig_container_name = forms.CharField(widget=forms.HiddenInput()) orig_container_name = forms.CharField(widget=forms.HiddenInput())
@ -97,15 +128,38 @@ class CopyObject(forms.SelfHandlingForm):
orig_object = data['orig_object_name'] orig_object = data['orig_object_name']
new_container = data['new_container_name'] new_container = data['new_container_name']
new_object = data['new_object_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: try:
api.swift_copy_object(request, api.swift_copy_object(request,
orig_container, orig_container,
orig_object, orig_object,
new_container, new_container,
new_object) new_path)
vals = {"container": new_container, "obj": new_object} dest = "%s/%s" % (new_container, data['path'])
messages.success(request, _('Object "%(obj)s" copied to container ' vals = {"dest": dest.rstrip("/"),
'"%(container)s".') % vals) "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: except exceptions.HorizonException, exc:
messages.error(request, exc) messages.error(request, exc)
return shortcuts.redirect(object_index, orig_container) return shortcuts.redirect(object_index, orig_container)
@ -114,4 +168,4 @@ class CopyObject(forms.SelfHandlingForm):
exceptions.handle(request, exceptions.handle(request,
_("Unable to copy object."), _("Unable to copy object."),
redirect=redirect) redirect=redirect)
return shortcuts.redirect(object_index, new_container) return shortcuts.redirect(object_index, new_container, data['path'])

View File

@ -52,7 +52,7 @@ class CreateContainer(tables.LinkAction):
class ListObjects(tables.LinkAction): class ListObjects(tables.LinkAction):
name = "list_objects" name = "list_objects"
verbose_name = _("List Objects") verbose_name = _("View Container")
url = "horizon:nova:containers:object_index" url = "horizon:nova:containers:object_index"
classes = ("btn-list",) classes = ("btn-list",)
@ -71,7 +71,10 @@ class UploadObject(tables.LinkAction):
else: else:
# This is a table action and we already have the container name # This is a table action and we already have the container name
container_name = self.table.kwargs['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): def update(self, request, obj):
# This will only be called for the row, so we can remove the button # 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",) classes = ("btn-download",)
def get_link_url(self, obj): def get_link_url(self, obj):
#assert False, obj.__dict__['_apiresource'].__dict__
return reverse(self.url, args=(http.urlquote(obj.container.name), return reverse(self.url, args=(http.urlquote(obj.container.name),
http.urlquote(obj.name))) http.urlquote(obj.name)))
@ -147,12 +149,18 @@ class ObjectFilterAction(tables.FilterAction):
return filter(comp, objects) return filter(comp, objects)
def sanitize_name(name):
return name.split("/")[-1]
def get_size(obj): def get_size(obj):
return filesizeformat(obj.size) return filesizeformat(obj.size)
class ObjectsTable(tables.DataTable): 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')) size = tables.Column(get_size, verbose_name=_('Size'))
def get_object_id(self, obj): def get_object_id(self, obj):
@ -163,3 +171,42 @@ class ObjectsTable(tables.DataTable):
verbose_name = _("Objects") verbose_name = _("Objects")
table_actions = (ObjectFilterAction, UploadObject, DeleteObject) table_actions = (ObjectFilterAction, UploadObject, DeleteObject)
row_actions = (DownloadObject, CopyObject, 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,)

View File

@ -102,16 +102,18 @@ class ObjectViewTests(test.TestCase):
ret = (self.objects.list(), False) ret = (self.objects.list(), False)
api.swift_get_objects(IsA(http.HttpRequest), api.swift_get_objects(IsA(http.HttpRequest),
self.containers.first().name, self.containers.first().name,
marker=None).AndReturn(ret) marker=None,
path=None).AndReturn(ret)
self.mox.ReplayAll() self.mox.ReplayAll()
res = self.client.get(reverse('horizon:nova:containers:object_index', res = self.client.get(reverse('horizon:nova:containers:object_index',
args=[self.containers.first().name])) args=[self.containers.first().name]))
self.assertTemplateUsed(res, 'nova/objects/index.html') self.assertTemplateUsed(res, 'nova/objects/index.html')
expected = [obj.name for obj in self.objects.list()] # UTF8 encoding here to ensure there aren't problems with Nose output.
self.assertQuerysetEqual(res.context['table'].data, expected = [obj.name.encode('utf8') for obj in self.objects.list()]
self.assertQuerysetEqual(res.context['objects_table'].data,
expected, expected,
lambda obj: obj.name) lambda obj: obj.name.encode('utf8'))
def test_upload_index(self): def test_upload_index(self):
res = self.client.get(reverse('horizon:nova:containers:object_upload', res = self.client.get(reverse('horizon:nova:containers:object_upload',

View File

@ -23,16 +23,29 @@ from django.conf.urls.defaults import patterns, url
from .views import IndexView, CreateView, UploadView, ObjectIndexView, CopyView from .views import IndexView, CreateView, UploadView, ObjectIndexView, CopyView
OBJECTS = r'^(?P<container_name>[^/]+)/%s$'
# Swift containers and objects. # Swift containers and objects.
urlpatterns = patterns('horizon.dashboards.nova.containers.views', urlpatterns = patterns('horizon.dashboards.nova.containers.views',
url(r'^$', IndexView.as_view(), name='index'), 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(r'^(?P<container_name>(.+/)+)?create$',
url(OBJECTS % r'upload$', UploadView.as_view(), name='object_upload'), CreateView.as_view(),
url(OBJECTS % r'(?P<object_name>[^/]+)/copy$', name='create'),
CopyView.as_view(), name='object_copy'),
url(OBJECTS % r'(?P<object_name>[^/]+)/download$', url(r'^(?P<container_name>[^/]+)/(?P<subfolder_path>(.+/)+)?$',
'object_download', name='object_download')) 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')
)

View File

@ -33,7 +33,8 @@ from horizon import exceptions
from horizon import forms from horizon import forms
from horizon import tables from horizon import tables
from .forms import CreateContainer, UploadObject, CopyObject from .forms import CreateContainer, UploadObject, CopyObject
from .tables import ContainersTable, ObjectsTable from .tables import ContainersTable, ObjectsTable,\
ContainerSubfoldersTable
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
@ -63,31 +64,68 @@ class CreateView(forms.ModalFormView):
form_class = CreateContainer form_class = CreateContainer
template_name = 'nova/containers/create.html' 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' template_name = 'nova/objects/index.html'
def has_more_data(self, table): def has_more_data(self, table):
return self._more return self._more
def get_data(self): @property
objects = [] def objects(self):
self._more = None """ Returns a list of objects given the subfolder's path.
marker = self.request.GET.get('marker', None)
container_name = self.kwargs['container_name'] The path is from the kwargs of the request
try: """
objects, self._more = api.swift_get_objects(self.request, if not hasattr(self, "_objects"):
container_name, objects = []
marker=marker) self._more = None
except: marker = self.request.GET.get('marker', None)
msg = _('Unable to retrieve object list.') container_name = self.kwargs['container_name']
exceptions.handle(self.request, msg) subfolders = self.kwargs['subfolder_path']
return objects 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): def get_context_data(self, **kwargs):
context = super(ObjectIndexView, self).get_context_data(**kwargs) context = super(ObjectIndexView, self).get_context_data(**kwargs)
context['container_name'] = self.kwargs["container_name"] context['container_name'] = self.kwargs["container_name"]
context['subfolder_path'] = self.kwargs["subfolder_path"]
return context return context
@ -96,7 +134,8 @@ class UploadView(forms.ModalFormView):
template_name = 'nova/objects/upload.html' template_name = 'nova/objects/upload.html'
def get_initial(self): 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): def get_context_data(self, **kwargs):
context = super(UploadView, self).get_context_data(**kwargs) context = super(UploadView, self).get_context_data(**kwargs)
@ -104,25 +143,25 @@ class UploadView(forms.ModalFormView):
return context return context
def object_download(request, container_name, object_name): def object_download(request, container_name, object_path):
obj = api.swift.swift_get_object(request, container_name, object_name) 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 # Add the original file extension back on if it wasn't preserved in the
# name given to the object. # name given to the object.
filename = object_name filename = object_path.rsplit("/")[-1]
if not os.path.splitext(obj.name)[1]: if not os.path.splitext(obj.name)[1]:
name, ext = os.path.splitext(obj.metadata.get('orig-filename', '')) name, ext = os.path.splitext(obj.metadata.get('orig-filename', ''))
filename = "%s%s" % (object_name, ext) filename = "%s%s" % (filename, ext)
try: try:
object_data = api.swift_get_object_data(request, object_data = api.swift_get_object_data(request,
container_name, container_name,
object_name) object_path)
except: except:
redirect = reverse("horizon:nova:containers:index") redirect = reverse("horizon:nova:containers:index")
exceptions.handle(request, exceptions.handle(request,
_("Unable to retrieve object."), _("Unable to retrieve object."),
redirect=redirect) redirect=redirect)
response = http.HttpResponse() 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-Disposition'] = 'attachment; filename=%s' % safe_name
response['Content-Type'] = 'application/octet-stream' response['Content-Type'] = 'application/octet-stream'
for data in object_data: for data in object_data:
@ -147,9 +186,12 @@ class CopyView(forms.ModalFormView):
return kwargs return kwargs
def get_initial(self): 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"], return {"new_container_name": self.kwargs["container_name"],
"orig_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"]} "new_object_name": "%s copy" % self.kwargs["object_name"]}
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):

View File

@ -9,7 +9,3 @@
{% block dash_main %} {% block dash_main %}
{% include "nova/containers/_create.html" %} {% include "nova/containers/_create.html" %}
{% endblock %} {% endblock %}

View File

@ -3,9 +3,7 @@
{% block title %}Containers{% endblock %} {% block title %}Containers{% endblock %}
{% block page_header %} {% block page_header %}
{% url horizon:nova:images_and_snapshots:images:index as refresh_link %} {% include "horizon/common/_page_header.html" with title=_("Containers") %}
{# 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" %}
{% endblock page_header %} {% endblock page_header %}
{% block dash_main %} {% block dash_main %}

View File

@ -14,7 +14,7 @@
</div> </div>
<div class="right"> <div class="right">
<h3>{% trans "Description" %}:</h3> <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> </div>
{% endblock %} {% endblock %}

View File

@ -4,10 +4,15 @@
{% block page_header %} {% block page_header %}
<div class='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> </div>
{% endblock page_header %} {% endblock page_header %}
{% block dash_main %} {% block dash_main %}
{{ table.render }} <div id="subfolders">
{{ subfolders_table.render }}
</div>
<div id="objects">
{{ objects_table.render }}
</div>
{% endblock %} {% endblock %}

View File

@ -68,7 +68,9 @@ class SwiftApiTests(test.APITestCase):
self.mox.StubOutWithMock(container, 'get_objects') self.mox.StubOutWithMock(container, 'get_objects')
container.get_objects(limit=1001, container.get_objects(limit=1001,
marker=None, marker=None,
prefix=None).AndReturn(objects) prefix=None,
delimiter='/',
path=None).AndReturn(objects)
self.mox.ReplayAll() self.mox.ReplayAll()
(objs, more) = api.swift_get_objects(self.request, container.name) (objs, more) = api.swift_get_objects(self.request, container.name)