diff --git a/horizon/api/swift.py b/horizon/api/swift.py index 381851620..8d57e7fb1 100644 --- a/horizon/api/swift.py +++ b/horizon/api/swift.py @@ -20,109 +20,171 @@ import logging -import cloudfiles +import swiftclient + from django.conf import settings from django.utils.translation import ugettext as _ from horizon import exceptions -from horizon.api.base import url_for +from horizon.api.base import url_for, APIDictWrapper LOG = logging.getLogger(__name__) FOLDER_DELIMITER = "/" -class SwiftAuthentication(object): - """ Auth container in the format CloudFiles expects. """ - def __init__(self, storage_url, auth_token): - self.storage_url = storage_url - self.auth_token = auth_token +class Container(APIDictWrapper): + pass - def authenticate(self): - return (self.storage_url, '', self.auth_token) + +class StorageObject(APIDictWrapper): + def __init__(self, apidict, container_name, orig_name=None, data=None): + super(StorageObject, self).__init__(apidict) + self.container_name = container_name + self.orig_name = orig_name + self.data = data + + +class PseudoFolder(APIDictWrapper): + """ + Wrapper to smooth out discrepencies between swift "subdir" items + and swift pseudo-folder objects. + """ + + def __init__(self, apidict, container_name): + super(PseudoFolder, self).__init__(apidict) + self.container_name = container_name + + def _has_content_type(self): + content_type = self._apidict.get("content_type", None) + return content_type == "application/directory" + + @property + def name(self): + if self._has_content_type(): + return self._apidict['name'] + return self.subdir.rstrip(FOLDER_DELIMITER) + + @property + def bytes(self): + if self._has_content_type(): + return self._apidict['bytes'] + return None + + @property + def content_type(self): + return "application/directory" + + +def _objectify(items, container_name): + """ Splits a listing of objects into their appropriate wrapper classes. """ + objects = {} + subdir_markers = [] + + # Deal with objects and object pseudo-folders first, save subdirs for later + for item in items: + if item.get("content_type", None) == "application/directory": + objects[item['name']] = PseudoFolder(item, container_name) + elif item.get("subdir", None) is not None: + subdir_markers.append(PseudoFolder(item, container_name)) + else: + objects[item['name']] = StorageObject(item, container_name) + # Revisit subdirs to see if we have any non-duplicates + for item in subdir_markers: + if item.name not in objects.keys(): + objects[item.name] = item + return objects.values() def swift_api(request): endpoint = url_for(request, 'object-store') LOG.debug('Swift connection created using token "%s" and url "%s"' % (request.user.token.id, endpoint)) - auth = SwiftAuthentication(endpoint, request.user.token.id) - return cloudfiles.get_connection(auth=auth) + return swiftclient.client.Connection(None, + request.user.username, + None, + preauthtoken=request.user.token.id, + preauthurl=endpoint, + auth_version="2.0") def swift_container_exists(request, container_name): try: - swift_api(request).get_container(container_name) + swift_api(request).head_container(container_name) return True - except cloudfiles.errors.NoSuchContainer: + except swiftclient.client.ClientException: return False def swift_object_exists(request, container_name, object_name): - container = swift_api(request).get_container(container_name) - try: - container.get_object(object_name) + swift_api(request).head_object(container_name, object_name) return True - except cloudfiles.errors.NoSuchObject: + except swiftclient.client.ClientException: return False def swift_get_containers(request, marker=None): limit = getattr(settings, 'API_RESULT_LIMIT', 1000) - containers = swift_api(request).get_all_containers(limit=limit + 1, - marker=marker) - if(len(containers) > limit): - return (containers[0:-1], True) + headers, containers = swift_api(request).get_account(limit=limit + 1, + marker=marker, + full_listing=True) + container_objs = [Container(c) for c in containers] + if(len(container_objs) > limit): + return (container_objs[0:-1], True) else: - return (containers, False) + return (container_objs, False) def swift_create_container(request, name): if swift_container_exists(request, name): raise exceptions.AlreadyExists(name, 'container') - return swift_api(request).create_container(name) + swift_api(request).put_container(name) + return Container({'name': name}) def swift_delete_container(request, name): swift_api(request).delete_container(name) + return True -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, - delimiter=FOLDER_DELIMITER, - path=path) - if(len(objects) > limit): - return (objects[0:-1], True) +def swift_get_objects(request, container_name, prefix=None, marker=None, + limit=None): + limit = limit or getattr(settings, 'API_RESULT_LIMIT', 1000) + kwargs = dict(prefix=prefix, + marker=marker, + limit=limit + 1, + delimiter=FOLDER_DELIMITER, + full_listing=True) + headers, objects = swift_api(request).get_container(container_name, + **kwargs) + object_objs = _objectify(objects, container_name) + + if(len(object_objs) > limit): + return (object_objs[0:-1], True) else: - return (objects, False) + return (object_objs, False) def swift_filter_objects(request, filter_string, container_name, prefix=None, - path=None, marker=None): - #FIXME(kewu): Cloudfiles currently has no filtering API, thus the marker - #parameter here won't actually help the pagination. For now I am just - #getting the largest number of objects from a container and filtering based - #on those objects. - limit = 10000 - container = swift_api(request).get_container(container_name) - objects = container.get_objects(prefix=prefix, - marker=marker, - limit=limit, - delimiter=FOLDER_DELIMITER, - path=path) + marker=None): + # FIXME(kewu): Swift currently has no real filtering API, thus the marker + # parameter here won't actually help the pagination. For now I am just + # getting the largest number of objects from a container and filtering + # based on those objects. + limit = 9999 + objects = swift_get_objects(request, + container_name, + prefix=prefix, + marker=marker, + limit=limit) filter_string_list = filter_string.lower().strip().split(' ') def matches_filter(obj): for q in filter_string_list: return wildcard_search(obj.name.lower(), q) - return filter(matches_filter, objects) + return filter(matches_filter, objects[0]) def wildcard_search(string, q): @@ -142,7 +204,7 @@ def wildcard_search(string, q): def swift_copy_object(request, orig_container_name, orig_object_name, new_container_name, new_object_name): try: - # FIXME(gabriel): Cloudfiles currently fails at unicode in the + # FIXME(gabriel): The swift currently fails at unicode in the # copy_to method, so to provide a better experience we check for # unicode here and pre-empt with an error message rather than # letting the call fail. @@ -153,42 +215,50 @@ def swift_copy_object(request, orig_container_name, orig_object_name, except UnicodeEncodeError: raise exceptions.HorizonException(_("Unicode is not currently " "supported for object copy.")) - container = swift_api(request).get_container(orig_container_name) if swift_object_exists(request, new_container_name, new_object_name): raise exceptions.AlreadyExists(new_object_name, 'object') - orig_obj = container.get_object(orig_object_name) - return orig_obj.copy_to(new_container_name, new_object_name) + headers = {"X-Copy-From": FOLDER_DELIMITER.join([orig_container_name, + orig_object_name])} + return swift_api(request).put_object(new_container_name, + new_object_name, + None, + headers=headers) 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 + headers = {'content-type': 'application/directory', + 'content-length': 0} + etag = swift_api(request).put_object(container_name, + folder_name, + None, + headers=headers) + obj_info = {'subdir': folder_name, 'etag': etag} + return PseudoFolder(obj_info, container_name) 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) - obj.send(object_file) - return obj + headers = {} + headers['X-Object-Meta-Orig-Filename'] = object_file.name + etag = swift_api(request).put_object(container_name, + object_name, + object_file, + headers=headers) + obj_info = {'name': object_name, 'bytes': object_file.size, 'etag': etag} + return StorageObject(obj_info, container_name) def swift_delete_object(request, container_name, object_name): - container = swift_api(request).get_container(container_name) - container.delete_object(object_name) + swift_api(request).delete_object(container_name, object_name) + return True def swift_get_object(request, container_name, object_name): - container = swift_api(request).get_container(container_name) - return container.get_object(object_name) - - -def swift_get_object_data(request, container_name, object_name): - container = swift_api(request).get_container(container_name) - return container.get_object(object_name).stream() + headers, data = swift_api(request).get_object(container_name, object_name) + orig_name = headers.get("x-object-meta-orig-filename") + obj_info = {'name': object_name, 'bytes': len(data)} + return StorageObject(obj_info, + container_name, + orig_name=orig_name, + data=data) diff --git a/horizon/browsers/base.py b/horizon/browsers/base.py index 635cdc3c4..e4a897920 100644 --- a/horizon/browsers/base.py +++ b/horizon/browsers/base.py @@ -15,6 +15,7 @@ # under the License. from django import template +from django.utils.translation import ugettext_lazy as _ from horizon.tables import DataTable from horizon.utils import html @@ -32,6 +33,7 @@ class ResourceBrowser(html.HTMLElement): A more verbose name for the browser meant for display purposes. .. attribute:: navigation_table_class + This table displays data on the left side of the browser. Set the ``navigation_table_class`` attribute with the desired :class:`~horizon.tables.DataTable` class. @@ -39,6 +41,7 @@ class ResourceBrowser(html.HTMLElement): ``"navigation"``. .. attribute:: content_table_class + This table displays data on the right side of the browser. Set the ``content_table_class`` attribute with the desired :class:`~horizon.tables.DataTable` class. @@ -59,44 +62,35 @@ class ResourceBrowser(html.HTMLElement): verbose_name = None navigation_table_class = None content_table_class = None + navigable_item_name = _("Navigation Item") template = "horizon/common/_resource_browser.html" context_var_name = "browser" - def __init__(self, request, tables=None, attrs=None, - **kwargs): + def __init__(self, request, tables_dict=None, attrs=None, **kwargs): super(ResourceBrowser, self).__init__() - self.name = getattr(self, "name", self.__class__.__name__) - self.verbose_name = getattr(self, "verbose_name", self.name.title()) + self.name = self.name or self.__class__.__name__ + self.verbose_name = self.verbose_name or self.name.title() self.request = request self.attrs.update(attrs or {}) - - self.navigation_table_class = getattr(self, "navigation_table_class", - None) + self.check_table_class(self.content_table_class, "content_table_class") self.check_table_class(self.navigation_table_class, "navigation_table_class") - - self.content_table_class = getattr(self, "content_table_class", - None) - self.check_table_class(self.content_table_class, - "content_table_class") - - self.set_tables(tables) + if tables_dict: + self.set_tables(tables_dict) def check_table_class(self, cls, attr_name): - if not cls or not issubclass(cls, (DataTable, )): - raise ValueError("You must specify a DataTable class for " - "the %s attribute on %s " + if not cls or not issubclass(cls, DataTable): + raise ValueError("You must specify a DataTable subclass for " + "the %s attribute on %s." % (attr_name, self.__class__.__name__)) def set_tables(self, tables): - if tables: - self.navigation_table = tables.get(self.navigation_table_class - ._meta.name, None) - self.content_table = tables.get(self.content_table_class - ._meta.name, None) - else: - raise ValueError("There are no tables passed to class %s." % - self.__class__.__name__) + """ + Sets the table instances on the browser from a dictionary mapping table + names to table instances (as constructed by MultiTableView). + """ + self.navigation_table = tables[self.navigation_table_class._meta.name] + self.content_table = tables[self.content_table_class._meta.name] def render(self): browser_template = template.loader.get_template(self.template) diff --git a/horizon/browsers/views.py b/horizon/browsers/views.py index 64e548a4b..933795e10 100644 --- a/horizon/browsers/views.py +++ b/horizon/browsers/views.py @@ -16,37 +16,32 @@ from collections import defaultdict +from django.utils.translation import ugettext_lazy as _ + from horizon.tables import MultiTableView class ResourceBrowserView(MultiTableView): browser_class = None - data_method_pattern = "get_%s_data" def __init__(self, *args, **kwargs): - self.browser_class = getattr(self, "browser_class", None) if not self.browser_class: - raise ValueError("You must specify a ResourceBrowser class " - " for the browser_class attribute on %s " + raise ValueError("You must specify a ResourceBrowser subclass " + "for the browser_class attribute on %s." % self.__class__.__name__) - - self.navigation_table = self.browser_class.navigation_table_class - self.content_table = self.browser_class.content_table_class - - # Check and set up the method the view would use to collect data - self._data_methods = defaultdict(list) - self.table_classes = (self.navigation_table, self.content_table) - self.get_data_methods(self.table_classes, self._data_methods) - - self._tables = {} - self._data = {} + self.table_classes = (self.browser_class.navigation_table_class, + self.browser_class.content_table_class) + super(ResourceBrowserView, self).__init__(*args, **kwargs) + self.navigation_selection = False def get_browser(self): if not hasattr(self, "browser"): - tables = self.get_tables() - self.browser = self.browser_class(self.request, - tables, - **self.kwargs) + self.browser = self.browser_class(self.request, **self.kwargs) + self.browser.set_tables(self.get_tables()) + if not self.navigation_selection: + ct = self.browser.content_table + item = self.browser.navigable_item_name.lower() + ct._no_data_message = _("Select a %s to browse.") % item return self.browser def get_context_data(self, **kwargs): diff --git a/horizon/dashboards/nova/containers/browsers.py b/horizon/dashboards/nova/containers/browsers.py index 0b986a8c0..68aab4c99 100644 --- a/horizon/dashboards/nova/containers/browsers.py +++ b/horizon/dashboards/nova/containers/browsers.py @@ -30,3 +30,4 @@ class ContainerBrowser(browsers.ResourceBrowser): verbose_name = _("Swift") navigation_table_class = ContainersTable content_table_class = ObjectsTable + navigable_item_name = _("Container") diff --git a/horizon/dashboards/nova/containers/forms.py b/horizon/dashboards/nova/containers/forms.py index bde2f32c2..82a493625 100644 --- a/horizon/dashboards/nova/containers/forms.py +++ b/horizon/dashboards/nova/containers/forms.py @@ -29,6 +29,8 @@ from horizon import exceptions from horizon import forms from horizon import messages +from .tables import wrap_delimiter + LOG = logging.getLogger(__name__) @@ -90,8 +92,6 @@ class UploadObject(forms.SelfHandlingForm): data['container_name'], object_path, object_file) - obj.metadata['orig-filename'] = object_file.name - obj.sync_metadata() messages.success(request, _("Object was successfully uploaded.")) return obj except: @@ -114,7 +114,7 @@ class CopyObject(forms.SelfHandlingForm): self.fields['new_container_name'].choices = containers def handle(self, request, data): - object_index = "horizon:nova:containers:index" + index = "horizon:nova:containers:index" orig_container = data['orig_container_name'] orig_object = data['orig_object_name'] new_container = data['new_container_name'] @@ -124,14 +124,15 @@ class CopyObject(forms.SelfHandlingForm): # Iteratively make sure all the directory markers exist. if data['path']: path_component = "" - for bit in data['path'].split("/"): + for bit in [i for i in data['path'].split("/") if i]: path_component += bit try: api.swift.swift_create_subfolder(request, new_container, path_component) except: - redirect = reverse(object_index, args=(orig_container,)) + redirect = reverse(index, + args=(wrap_delimiter(orig_container),)) exceptions.handle(request, _("Unable to copy object."), redirect=redirect) @@ -154,10 +155,10 @@ class CopyObject(forms.SelfHandlingForm): return True except exceptions.HorizonException, exc: messages.error(request, exc) - raise exceptions.Http302(reverse(object_index, - args=[orig_container])) + raise exceptions.Http302(reverse(index, + args=[wrap_delimiter(orig_container)])) except: - redirect = reverse(object_index, args=(orig_container,)) + redirect = reverse(index, args=[wrap_delimiter(orig_container)]) exceptions.handle(request, _("Unable to copy object."), redirect=redirect) diff --git a/horizon/dashboards/nova/containers/tables.py b/horizon/dashboards/nova/containers/tables.py index 65b2db351..6799a0eb9 100644 --- a/horizon/dashboards/nova/containers/tables.py +++ b/horizon/dashboards/nova/containers/tables.py @@ -16,24 +16,23 @@ import logging -from cloudfiles.errors import ContainerNotEmpty from django.core.urlresolvers import reverse from django.template.defaultfilters import filesizeformat from django.utils import http from django.utils.translation import ugettext_lazy as _ from horizon import api -from horizon import messages from horizon import tables from horizon.api import FOLDER_DELIMITER -from horizon.tables import DataTable LOG = logging.getLogger(__name__) def wrap_delimiter(name): - return name + FOLDER_DELIMITER + if not name.endswith(FOLDER_DELIMITER): + return name + FOLDER_DELIMITER + return name class DeleteContainer(tables.DeleteAction): @@ -42,12 +41,7 @@ class DeleteContainer(tables.DeleteAction): completion_url = "horizon:nova:containers:index" def delete(self, request, obj_id): - try: - api.swift_delete_container(request, obj_id) - except ContainerNotEmpty: - messages.error(request, - _('Containers must be empty before deletion.')) - raise + api.swift_delete_container(request, obj_id) def get_success_url(self, request=None): """ @@ -112,7 +106,7 @@ class UploadObject(tables.LinkAction): def get_size_used(container): - return filesizeformat(container.size_used) + return filesizeformat(container.bytes) def get_container_link(container): @@ -121,7 +115,8 @@ def get_container_link(container): class ContainersTable(tables.DataTable): - name = tables.Column("name", link=get_container_link, + name = tables.Column("name", + link=get_container_link, verbose_name=_("Container Name")) def get_object_id(self, container): @@ -131,8 +126,9 @@ class ContainersTable(tables.DataTable): name = "containers" verbose_name = _("Containers") table_actions = (CreateContainer,) - row_actions = (ListObjects, UploadObject, DeleteContainer) + row_actions = (DeleteContainer,) browser_table = "navigation" + footer = False class DeleteObject(tables.DeleteAction): @@ -143,7 +139,7 @@ class DeleteObject(tables.DeleteAction): def delete(self, request, obj_id): obj = self.table.get_object_by_id(obj_id) - container_name = obj.container.name + container_name = obj.container_name api.swift_delete_object(request, container_name, obj_id) @@ -169,7 +165,8 @@ class CopyObject(tables.LinkAction): allowed_data_types = ("objects",) def get_link_url(self, obj): - return reverse(self.url, args=(http.urlquote(obj.container.name), + container_name = self.table.kwargs['container_name'] + return reverse(self.url, args=(http.urlquote(container_name), http.urlquote(obj.name))) @@ -181,20 +178,21 @@ class DownloadObject(tables.LinkAction): allowed_data_types = ("objects",) def get_link_url(self, obj): - return reverse(self.url, args=(http.urlquote(obj.container.name), + container_name = self.table.kwargs['container_name'] + return reverse(self.url, args=(http.urlquote(container_name), http.urlquote(obj.name))) class ObjectFilterAction(tables.FilterAction): def _filtered_data(self, table, filter_string): - request = table._meta.request + request = table.request container = self.table.kwargs['container_name'] subfolder = self.table.kwargs['subfolder_path'] - path = subfolder + FOLDER_DELIMITER if subfolder else '' + prefix = wrap_delimiter(subfolder) if subfolder else '' self.filtered_data = api.swift_filter_objects(request, filter_string, container, - path=path) + prefix=prefix) return self.filtered_data def filter_subfolders_data(self, table, objects, filter_string): @@ -218,11 +216,12 @@ def sanitize_name(name): def get_size(obj): - return filesizeformat(obj.size) + if obj.bytes: + return filesizeformat(obj.bytes) def get_link_subfolder(subfolder): - container_name = subfolder.container.name + container_name = subfolder.container_name return reverse("horizon:nova:containers:index", args=(http.urlquote(wrap_delimiter(container_name)), http.urlquote(wrap_delimiter(subfolder.name)))) @@ -248,10 +247,10 @@ class CreateSubfolder(CreateContainer): class ObjectsTable(tables.DataTable): name = tables.Column("name", - link=get_link_subfolder, - allowed_data_types=("subfolders",), - verbose_name=_("Object Name"), - filters=(sanitize_name,)) + link=get_link_subfolder, + allowed_data_types=("subfolders",), + verbose_name=_("Object Name"), + filters=(sanitize_name,)) size = tables.Column(get_size, verbose_name=_('Size')) @@ -267,3 +266,4 @@ class ObjectsTable(tables.DataTable): DeleteSubfolder) data_types = ("subfolders", "objects") browser_table = "content" + footer = False diff --git a/horizon/dashboards/nova/containers/tests.py b/horizon/dashboards/nova/containers/tests.py index a39ac2209..e3f67af4d 100644 --- a/horizon/dashboards/nova/containers/tests.py +++ b/horizon/dashboards/nova/containers/tests.py @@ -20,7 +20,6 @@ import tempfile -from cloudfiles.errors import ContainerNotEmpty from django import http from django.core.files.uploadedfile import InMemoryUploadedFile from django.core.urlresolvers import reverse @@ -35,8 +34,8 @@ from . import forms CONTAINER_INDEX_URL = reverse('horizon:nova:containers:index') -class ContainerViewTests(test.TestCase): - def test_index(self): +class SwiftTests(test.TestCase): + def test_index_no_container_selected(self): containers = self.containers.list() self.mox.StubOutWithMock(api, 'swift_get_containers') api.swift_get_containers(IsA(http.HttpRequest), marker=None) \ @@ -66,7 +65,7 @@ class ContainerViewTests(test.TestCase): def test_delete_container_nonempty(self): container = self.containers.first() self.mox.StubOutWithMock(api, 'swift_delete_container') - exc = ContainerNotEmpty('containerNotEmpty') + exc = self.exceptions.swift exc.silence_logging = True api.swift_delete_container(IsA(http.HttpRequest), container.name).AndRaise(exc) @@ -97,9 +96,7 @@ class ContainerViewTests(test.TestCase): args=[wrap_delimiter(self.containers.first().name)]) self.assertRedirectsNoFollow(res, url) - -class IndexViewTests(test.TestCase): - def test_index(self): + def test_index_container_selected(self): self.mox.StubOutWithMock(api, 'swift_get_containers') self.mox.StubOutWithMock(api, 'swift_get_objects') containers = (self.containers.list(), False) @@ -109,7 +106,7 @@ class IndexViewTests(test.TestCase): api.swift_get_objects(IsA(http.HttpRequest), self.containers.first().name, marker=None, - path=None).AndReturn(ret) + prefix=None).AndReturn(ret) self.mox.ReplayAll() res = self.client.get(reverse('horizon:nova:containers:index', @@ -123,11 +120,6 @@ class IndexViewTests(test.TestCase): expected, lambda obj: obj.name.encode('utf8')) - def test_upload_index(self): - res = self.client.get(reverse('horizon:nova:containers:object_upload', - args=[self.containers.first().name])) - self.assertTemplateUsed(res, 'nova/containers/upload.html') - def test_upload(self): container = self.containers.first() obj = self.objects.first() @@ -143,11 +135,14 @@ class IndexViewTests(test.TestCase): container.name, obj.name, IsA(InMemoryUploadedFile)).AndReturn(obj) - self.mox.StubOutWithMock(obj, 'sync_metadata') - obj.sync_metadata() self.mox.ReplayAll() + upload_url = reverse('horizon:nova:containers:object_upload', args=[container.name]) + + res = self.client.get(upload_url) + self.assertTemplateUsed(res, 'nova/containers/upload.html') + res = self.client.get(upload_url) self.assertContains(res, 'enctype="multipart/form-data"') @@ -167,13 +162,6 @@ class IndexViewTests(test.TestCase): self.assertNoMessages() self.assertContains(res, "Slash is not an allowed character.") - # Test invalid container name - #formData['container_name'] = "contains/a/slash" - #formData['name'] = "no_slash" - #res = self.client.post(upload_url, formData) - #self.assertNoMessages() - #self.assertContains(res, "Slash is not an allowed character.") - def test_delete(self): container = self.containers.first() obj = self.objects.first() @@ -196,22 +184,17 @@ class IndexViewTests(test.TestCase): def test_download(self): container = self.containers.first() obj = self.objects.first() - OBJECT_DATA = 'objectData' - self.mox.StubOutWithMock(api, 'swift_get_object_data') self.mox.StubOutWithMock(api.swift, 'swift_get_object') api.swift.swift_get_object(IsA(http.HttpRequest), container.name, obj.name).AndReturn(obj) - api.swift_get_object_data(IsA(http.HttpRequest), - container.name, - obj.name).AndReturn(OBJECT_DATA) self.mox.ReplayAll() download_url = reverse('horizon:nova:containers:object_download', args=[container.name, obj.name]) res = self.client.get(download_url) - self.assertEqual(res.content, OBJECT_DATA) + self.assertEqual(res.content, obj.data) self.assertTrue(res.has_header('Content-Disposition')) def test_copy_index(self): diff --git a/horizon/dashboards/nova/containers/views.py b/horizon/dashboards/nova/containers/views.py index e2d5d9a8d..d262cec31 100644 --- a/horizon/dashboards/nova/containers/views.py +++ b/horizon/dashboards/nova/containers/views.py @@ -57,23 +57,24 @@ class ContainerView(browsers.ResourceBrowserView): def objects(self): """ Returns a list of objects given the subfolder's path. - The path is from the kwargs of the request + 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'] + subfolder = self.kwargs['subfolder_path'] prefix = None if container_name: - if subfolders: - prefix = subfolders.rstrip(FOLDER_DELIMITER) + self.navigation_selection = True + if subfolder: + prefix = subfolder try: objects, self._more = api.swift_get_objects(self.request, container_name, marker=marker, - path=prefix) + prefix=prefix) except: self._more = None objects = [] @@ -82,21 +83,19 @@ class ContainerView(browsers.ResourceBrowserView): self._objects = objects return self._objects - def get_objects_data(self): - """ Returns the objects within the in the current folder. + def is_subdir(self, item): + return getattr(item, "content_type", None) == "application/directory" - 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"] + def get_objects_data(self): + """ Returns a list of objects within the current folder. """ + filtered_objects = [item for item in self.objects + if not self.is_subdir(item)] 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"] + """ Returns a list of subfolders within the current folder. """ + filtered_objects = [item for item in self.objects + if self.is_subdir(item)] return filtered_objects def get_context_data(self, **kwargs): @@ -158,28 +157,24 @@ class UploadView(forms.ModalFormView): 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_path.rsplit(FOLDER_DELIMITER)[-1] - if not os.path.splitext(obj.name)[1]: - name, ext = os.path.splitext(obj.metadata.get('orig-filename', '')) - filename = "%s%s" % (filename, ext) try: - object_data = api.swift_get_object_data(request, - container_name, - object_path) + obj = api.swift.swift_get_object(request, container_name, object_path) except: redirect = reverse("horizon:nova:containers:index") exceptions.handle(request, _("Unable to retrieve object."), redirect=redirect) + # Add the original file extension back on if it wasn't preserved in the + # name given to the object. + filename = object_path.rsplit(FOLDER_DELIMITER)[-1] + if not os.path.splitext(obj.name)[1] and obj.orig_name: + name, ext = os.path.splitext(obj.orig_name) + filename = "%s%s" % (filename, ext) response = http.HttpResponse() 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: - response.write(data) + response.write(obj.data) return response diff --git a/horizon/dashboards/nova/volumes/tables.py b/horizon/dashboards/nova/volumes/tables.py index 59239d02b..996a13ecd 100644 --- a/horizon/dashboards/nova/volumes/tables.py +++ b/horizon/dashboards/nova/volumes/tables.py @@ -110,7 +110,7 @@ class AttachmentColumn(tables.Column): for a volume instance. """ def get_raw_data(self, volume): - request = self.table._meta.request + request = self.table.request link = _('Attached to %(instance)s on %(dev)s') attachments = [] # Filter out "empty" attachments which the client returns... @@ -188,7 +188,7 @@ class AttachedInstanceColumn(tables.Column): for a volume instance. """ def get_raw_data(self, attachment): - request = self.table._meta.request + request = self.table.request return safestring.mark_safe(get_attachment_name(request, attachment)) @@ -201,7 +201,7 @@ class AttachmentsTable(tables.DataTable): return obj['id'] def get_object_display(self, attachment): - instance_name = get_attachment_name(self._meta.request, attachment) + instance_name = get_attachment_name(self.request, attachment) vals = {"dev": attachment['device'], "instance_name": strip_tags(instance_name)} return _("%(dev)s on instance %(instance_name)s") % vals diff --git a/horizon/dashboards/syspanel/projects/tables.py b/horizon/dashboards/syspanel/projects/tables.py index ddc2ea8ce..a5f5ba431 100644 --- a/horizon/dashboards/syspanel/projects/tables.py +++ b/horizon/dashboards/syspanel/projects/tables.py @@ -114,7 +114,7 @@ class RemoveUserAction(tables.BatchAction): class ProjectUserRolesColumn(tables.Column): def get_raw_data(self, user): - request = self.table._meta.request + request = self.table.request try: roles = api.keystone.roles_for_user(request, user.id, diff --git a/horizon/tables/actions.py b/horizon/tables/actions.py index 57b01beef..2a643f385 100644 --- a/horizon/tables/actions.py +++ b/horizon/tables/actions.py @@ -357,12 +357,11 @@ class FilterAction(BaseAction): def assign_type_string(self, table, data, type_string): for datum in data: - setattr(datum, table._meta.data_type_name, - type_string) + setattr(datum, table._meta.data_type_name, type_string) def data_type_filter(self, table, data, filter_string): filtered_data = [] - for data_type in table._meta.data_types: + for data_type in table.data_types: func_name = "filter_%s_data" % data_type filter_func = getattr(self, func_name, None) if not filter_func and not callable(filter_func): diff --git a/horizon/tables/base.py b/horizon/tables/base.py index 49917465b..ce9404194 100644 --- a/horizon/tables/base.py +++ b/horizon/tables/base.py @@ -701,6 +701,11 @@ class DataTableOptions(object): The name of an attribute to assign to data passed to the table when it accepts mix data. Default: ``"_table_data_type"`` + + .. attribute:: footer + + Boolean to control whether or not to show the table's footer. + Default: ``True``. """ def __init__(self, options): self.name = getattr(options, 'name', self.__class__.__name__) @@ -715,6 +720,10 @@ class DataTableOptions(object): self.column_class = getattr(options, 'column_class', Column) self.pagination_param = getattr(options, 'pagination_param', 'marker') self.browser_table = getattr(options, 'browser_table', None) + self.footer = getattr(options, 'footer', True) + self.no_data_message = getattr(options, + "no_data_message", + _("No items to display.")) # Set self.filter if we have any FilterActions filter_actions = [action for action in self.table_actions if @@ -762,7 +771,8 @@ class DataTableOptions(object): "data_types should has more than one types" % self.name) - self.data_type_name = getattr(options, 'data_type_name', + self.data_type_name = getattr(options, + 'data_type_name', "_table_data_type") @@ -776,12 +786,12 @@ class DataTableMetaclass(type): # Gather columns; this prevents the column from being an attribute # on the DataTable class and avoids naming conflicts. columns = [] - for name, obj in attrs.items(): + for attr_name, obj in attrs.items(): if issubclass(type(obj), (opts.column_class, Column)): - column_instance = attrs.pop(name) - column_instance.name = name + column_instance = attrs.pop(attr_name) + column_instance.name = attr_name column_instance.classes.append('normal_column') - columns.append((name, column_instance)) + columns.append((attr_name, column_instance)) columns.sort(key=lambda x: x[1].creation_counter) # Iterate in reverse to preserve final order @@ -866,10 +876,11 @@ class DataTable(object): __metaclass__ = DataTableMetaclass def __init__(self, request, data=None, needs_form_wrapper=None, **kwargs): - self._meta.request = request - self._meta.data = data + self.request = request + self.data = data self.kwargs = kwargs self._needs_form_wrapper = needs_form_wrapper + self._no_data_message = self._meta.no_data_message # Create a new set columns = [] @@ -891,19 +902,15 @@ class DataTable(object): return unicode(self._meta.verbose_name) def __repr__(self): - return '<%s: %s>' % (self.__class__.__name__, self.name) + return '<%s: %s>' % (self.__class__.__name__, self._meta.name) @property def name(self): return self._meta.name @property - def data(self): - return self._meta.data - - @data.setter - def data(self, data): - self._meta.data = data + def footer(self): + return self._meta.footer @property def multi_select(self): @@ -916,7 +923,7 @@ class DataTable(object): if self._meta.filter and self._meta._filter_action: action = self._meta._filter_action filter_string = self.get_filter_string() - request_method = self._meta.request.method + request_method = self.request.method if filter_string and request_method == action.method: if self._meta.mixed_data_type: self._filtered_data = action.data_type_filter(self, @@ -931,7 +938,7 @@ class DataTable(object): def get_filter_string(self): filter_action = self._meta._filter_action param_name = filter_action.get_param_name() - filter_string = self._meta.request.POST.get(param_name, '') + filter_string = self.request.POST.get(param_name, '') return filter_string def _populate_data_cache(self): @@ -960,7 +967,7 @@ class DataTable(object): """ Renders the table using the template from the table options. """ table_template = template.loader.get_template(self._meta.template) extra_context = {self._meta.context_var_name: self} - context = template.RequestContext(self._meta.request, extra_context) + context = template.RequestContext(self.request, extra_context) return table_template.render(context) def get_absolute_url(self): @@ -974,11 +981,11 @@ class DataTable(object): ``request.get_full_path()`` with any query string stripped off, e.g. the path at which the table was requested. """ - return self._meta.request.get_full_path().partition('?')[0] + return self.request.get_full_path().partition('?')[0] def get_empty_message(self): """ Returns the message to be displayed when there is no data. """ - return _("No items to display.") + return self._no_data_message def get_object_by_id(self, lookup): """ @@ -1026,7 +1033,7 @@ class DataTable(object): bound_actions = [self.base_actions[action.name] for action in self._meta.table_actions] return [action for action in bound_actions if - self._filter_action(action, self._meta.request)] + self._filter_action(action, self.request)] def get_row_actions(self, datum): """ Returns a list of the action instances for a specific row. """ @@ -1038,11 +1045,11 @@ class DataTable(object): bound_action.datum = datum # Remove disallowed actions. if not self._filter_action(bound_action, - self._meta.request, + self.request, datum): continue # Hook for modifying actions based on data. No-op by default. - bound_action.update(self._meta.request, datum) + bound_action.update(self.request, datum) # Pre-create the URL for this link with appropriate parameters if issubclass(bound_action.__class__, LinkAction): bound_action.bound_url = bound_action.get_link_url(datum) @@ -1056,9 +1063,9 @@ class DataTable(object): bound_actions = self.get_table_actions() extra_context = {"table_actions": bound_actions} if self._meta.filter and \ - self._filter_action(self._meta._filter_action, self._meta.request): + self._filter_action(self._meta._filter_action, self.request): extra_context["filter"] = self._meta._filter_action - context = template.RequestContext(self._meta.request, extra_context) + context = template.RequestContext(self.request, extra_context) return table_actions_template.render(context) def render_row_actions(self, datum): @@ -1070,7 +1077,7 @@ class DataTable(object): bound_actions = self.get_row_actions(datum) extra_context = {"row_actions": bound_actions, "row_id": self.get_object_id(datum)} - context = template.RequestContext(self._meta.request, extra_context) + context = template.RequestContext(self.request, extra_context) return row_actions_template.render(context) @staticmethod @@ -1100,9 +1107,9 @@ class DataTable(object): if unsuccessful. """ # See if we have a list of ids - obj_ids = obj_ids or self._meta.request.POST.getlist('object_ids') + obj_ids = obj_ids or self.request.POST.getlist('object_ids') action = self.base_actions.get(action_name, None) - if not action or action.method != self._meta.request.method: + if not action or action.method != self.request.method: # We either didn't get an action or we're being hacked. Goodbye. return None @@ -1114,17 +1121,17 @@ class DataTable(object): obj_ids = [self.sanitize_id(i) for i in obj_ids] # Single handling is easy if not action.handles_multiple: - response = action.single(self, self._meta.request, obj_id) + response = action.single(self, self.request, obj_id) # Otherwise figure out what to pass along else: # Preference given to a specific id, since that implies # the user selected an action for just one row. if obj_id: obj_ids = [obj_id] - response = action.multiple(self, self._meta.request, obj_ids) + response = action.multiple(self, self.request, obj_ids) return response elif action and action.requires_input and not (obj_id or obj_ids): - messages.info(self._meta.request, + messages.info(self.request, _("Please select a row before taking that action.")) return None @@ -1146,7 +1153,7 @@ class DataTable(object): Determine whether the request should be handled by a preemptive action on this table or by an AJAX row update before loading any data. """ - request = self._meta.request + request = self.request table_name, action_name, obj_id = self.check_handler(request) if table_name == self.name: @@ -1181,7 +1188,7 @@ class DataTable(object): Determine whether the request should be handled by any action on this table after data has been loaded. """ - request = self._meta.request + request = self.request table_name, action_name, obj_id = self.check_handler(request) if table_name == self.name and action_name: return self.take_action(action_name, obj_id) diff --git a/horizon/tables/views.py b/horizon/tables/views.py index 835e51089..df345c39a 100644 --- a/horizon/tables/views.py +++ b/horizon/tables/views.py @@ -224,7 +224,7 @@ class MixedDataTableView(DataTableView): if not self._data: table = self.table_class self._data = {table._meta.name: []} - for data_type in table._meta.data_types: + for data_type in table.data_types: func_name = "get_%s_data" % data_type data_func = getattr(self, func_name, None) if data_func is None: @@ -239,7 +239,7 @@ class MixedDataTableView(DataTableView): def assign_type_string(self, data, type_string): for datum in data: - setattr(datum, self.table_class._meta.data_type_name, + setattr(datum, self.table_class.data_type_name, type_string) def get_table(self): diff --git a/horizon/templates/horizon/common/_data_table.html b/horizon/templates/horizon/common/_data_table.html index 34c4dd970..ccbdbc4b3 100644 --- a/horizon/templates/horizon/common/_data_table.html +++ b/horizon/templates/horizon/common/_data_table.html @@ -28,6 +28,7 @@ {% endfor %} + {% if table.footer %}
{% if table.needs_summary_row %}