From 801c2321bfd1a0036148e5f4d8b3a8c0674fe00d Mon Sep 17 00:00:00 2001 From: Gabriel Hurley Date: Sun, 12 Aug 2012 21:27:21 -0700 Subject: [PATCH] Switch to use python-swiftclient instead of cloudfiles. This patch also resolves some thread-safety problems with when the browser and associated tables are constructed and where the request and data caches are stored on the table. Also includes stylistic and UX enhancments to the swift ResourceBrowser subclass. Implements blueprint swiftclient. Change-Id: I578277ff158b293ee50860528b069dc20e2136a9 --- horizon/api/swift.py | 214 ++++++++++++------ horizon/browsers/base.py | 44 ++-- horizon/browsers/views.py | 33 ++- .../dashboards/nova/containers/browsers.py | 1 + horizon/dashboards/nova/containers/forms.py | 17 +- horizon/dashboards/nova/containers/tables.py | 50 ++-- horizon/dashboards/nova/containers/tests.py | 39 +--- horizon/dashboards/nova/containers/views.py | 51 ++--- horizon/dashboards/nova/volumes/tables.py | 6 +- .../dashboards/syspanel/projects/tables.py | 2 +- horizon/tables/actions.py | 5 +- horizon/tables/base.py | 71 +++--- horizon/tables/views.py | 4 +- .../templates/horizon/common/_data_table.html | 2 + .../horizon/common/_resource_browser.html | 6 +- horizon/test.py | 10 +- horizon/tests/api_tests/swift_tests.py | 164 ++++---------- horizon/tests/test_data/exceptions.py | 4 + horizon/tests/test_data/swift_data.py | 39 +--- openstack_dashboard/exceptions.py | 12 +- .../static/bootstrap/less/variables.less | 22 +- .../static/dashboard/less/horizon.less | 186 +++++++-------- run_tests.sh | 2 +- tools/pip-requires | 2 +- 24 files changed, 460 insertions(+), 526 deletions(-) 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 %} @@ -50,6 +51,7 @@ + {% endif %} {% endwith %} {% if needs_form_wrapper %}{% endif %} diff --git a/horizon/templates/horizon/common/_resource_browser.html b/horizon/templates/horizon/common/_resource_browser.html index 6242f3eec..5caa4b880 100644 --- a/horizon/templates/horizon/common/_resource_browser.html +++ b/horizon/templates/horizon/common/_resource_browser.html @@ -1,9 +1,13 @@ {% load i18n %} -
+
{{ browser.content_table.render }}
+
+ {% blocktrans count nav_items=browser.navigation_table.data|length %}Displaying {{ nav_items }} item{% plural %}Displaying {{ nav_items }} items{% endblocktrans %} + {% blocktrans count content_items=browser.content_table.data|length %}Displaying {{ content_items }} item{% plural %}Displaying {{ content_items }} items{% endblocktrans %} +
diff --git a/horizon/test.py b/horizon/test.py index 1b294eb92..f2977b1f8 100644 --- a/horizon/test.py +++ b/horizon/test.py @@ -21,7 +21,6 @@ from functools import wraps import os -import cloudfiles as swift_client from django import http from django import test as django_test @@ -36,6 +35,8 @@ import glanceclient from keystoneclient.v2_0 import client as keystone_client from novaclient.v1_1 import client as nova_client from quantumclient.v2_0 import client as quantum_client +from swiftclient import client as swift_client + from selenium.webdriver.firefox.webdriver import WebDriver import httplib2 @@ -335,7 +336,12 @@ class APITestCase(TestCase): self.mox.StubOutWithMock(swift_client, 'Connection') self.swiftclient = self.mox.CreateMock(swift_client.Connection) while expected_calls: - swift_client.Connection(auth=mox.IgnoreArg())\ + swift_client.Connection(None, + mox.IgnoreArg(), + None, + preauthtoken=mox.IgnoreArg(), + preauthurl=mox.IgnoreArg(), + auth_version="2.0") \ .AndReturn(self.swiftclient) expected_calls -= 1 return self.swiftclient diff --git a/horizon/tests/api_tests/swift_tests.py b/horizon/tests/api_tests/swift_tests.py index 97b64e33e..6fa48f556 100644 --- a/horizon/tests/api_tests/swift_tests.py +++ b/horizon/tests/api_tests/swift_tests.py @@ -20,7 +20,7 @@ from __future__ import absolute_import -import cloudfiles +from mox import IsA from horizon import api from horizon import exceptions @@ -30,30 +30,32 @@ from horizon import test class SwiftApiTests(test.APITestCase): def test_swift_get_containers(self): containers = self.containers.list() + cont_data = [c._apidict for c in containers] swift_api = self.stub_swiftclient() - swift_api.get_all_containers(limit=1001, - marker=None).AndReturn(containers) + swift_api.get_account(limit=1001, + marker=None, + full_listing=True).AndReturn([{}, cont_data]) self.mox.ReplayAll() (conts, more) = api.swift_get_containers(self.request) self.assertEqual(len(conts), len(containers)) self.assertFalse(more) - def test_swift_create_container(self): + def test_swift_create_duplicate_container(self): container = self.containers.first() swift_api = self.stub_swiftclient(expected_calls=2) # Check for existence, then create - exc = cloudfiles.errors.NoSuchContainer() - swift_api.get_container(container.name).AndRaise(exc) - swift_api.create_container(container.name).AndReturn(container) + exc = self.exceptions.swift + swift_api.head_container(container.name).AndRaise(exc) + swift_api.put_container(container.name).AndReturn(container) self.mox.ReplayAll() # Verification handled by mox, no assertions needed. api.swift_create_container(self.request, container.name) - def test_swift_create_duplicate_container(self): + def test_swift_create_container(self): container = self.containers.first() swift_api = self.stub_swiftclient() - swift_api.get_container(container.name).AndReturn(container) + swift_api.head_container(container.name).AndReturn(container) self.mox.ReplayAll() # Verification handled by mox, no assertions needed. with self.assertRaises(exceptions.AlreadyExists): @@ -64,145 +66,55 @@ class SwiftApiTests(test.APITestCase): objects = self.objects.list() swift_api = self.stub_swiftclient() - swift_api.get_container(container.name).AndReturn(container) - self.mox.StubOutWithMock(container, 'get_objects') - container.get_objects(limit=1001, - marker=None, - prefix=None, - delimiter='/', - path=None).AndReturn(objects) + swift_api.get_container(container.name, + limit=1001, + marker=None, + prefix=None, + delimiter='/', + full_listing=True).AndReturn([{}, objects]) self.mox.ReplayAll() (objs, more) = api.swift_get_objects(self.request, container.name) self.assertEqual(len(objs), len(objects)) self.assertFalse(more) - def test_swift_filter_objects(self): - container = self.containers.first() - objects = self.objects.list() - first_obj = self.objects.first() - expected_objs = [obj.name.encode('utf8') for obj in - self.objects.filter(name=first_obj.name)] - - swift_api = self.stub_swiftclient() - swift_api.get_container(container.name).AndReturn(container) - self.mox.StubOutWithMock(container, 'get_objects') - container.get_objects(limit=10000, - marker=None, - prefix=None, - delimiter='/', - path=None).AndReturn(objects) - self.mox.ReplayAll() - - result_objs = api.swift_filter_objects(self.request, - first_obj.name, - container.name) - self.assertQuerysetEqual(result_objs, expected_objs, - lambda obj: obj.name.encode('utf8')) - def test_swift_upload_object(self): container = self.containers.first() obj = self.objects.first() - OBJECT_DATA = 'someData' + fake_name = 'fake_object.jpg' + + class FakeFile(object): + def __init__(self): + self.name = fake_name + self.data = obj.data + self.size = len(obj.data) + + headers = {'X-Object-Meta-Orig-Filename': fake_name} swift_api = self.stub_swiftclient() - swift_api.get_container(container.name).AndReturn(container) - self.mox.StubOutWithMock(container, 'create_object') - container.create_object(obj.name).AndReturn(obj) - self.mox.StubOutWithMock(obj, 'send') - obj.send(OBJECT_DATA).AndReturn(obj) + swift_api.put_object(container.name, + obj.name, + IsA(FakeFile), + headers=headers) self.mox.ReplayAll() - ret_val = api.swift_upload_object(self.request, - container.name, - obj.name, - OBJECT_DATA) - self.assertEqual(ret_val, obj) - - def test_swift_delete_object(self): - container = self.containers.first() - obj = self.objects.first() - - swift_api = self.stub_swiftclient() - swift_api.get_container(container.name).AndReturn(container) - self.mox.StubOutWithMock(container, 'delete_object') - container.delete_object(obj.name).AndReturn(obj) - self.mox.ReplayAll() - - ret_val = api.swift_delete_object(self.request, - container.name, - obj.name) - - self.assertIsNone(ret_val) - - def test_swift_get_object_data(self): - container = self.containers.first() - obj = self.objects.first() - OBJECT_DATA = 'objectData' - - swift_api = self.stub_swiftclient() - swift_api.get_container(container.name).AndReturn(container) - self.mox.StubOutWithMock(container, 'get_object') - container.get_object(obj.name).AndReturn(obj) - self.mox.StubOutWithMock(obj, 'stream') - obj.stream().AndReturn(OBJECT_DATA) - self.mox.ReplayAll() - - ret_val = api.swift_get_object_data(self.request, - container.name, - obj.name) - self.assertEqual(ret_val, OBJECT_DATA) + api.swift_upload_object(self.request, + container.name, + obj.name, + FakeFile()) def test_swift_object_exists(self): container = self.containers.first() obj = self.objects.first() swift_api = self.stub_swiftclient(expected_calls=2) - self.mox.StubOutWithMock(container, 'get_object') - swift_api.get_container(container.name).AndReturn(container) - container.get_object(obj.name).AndReturn(obj) - swift_api.get_container(container.name).AndReturn(container) - exc = cloudfiles.errors.NoSuchObject() - container.get_object(obj.name).AndRaise(exc) + swift_api.head_object(container.name, obj.name).AndReturn(container) + + exc = self.exceptions.swift + swift_api.head_object(container.name, obj.name).AndRaise(exc) self.mox.ReplayAll() args = self.request, container.name, obj.name self.assertTrue(api.swift_object_exists(*args)) # Again, for a "non-existent" object self.assertFalse(api.swift_object_exists(*args)) - - def test_swift_copy_object(self): - container = self.containers.get(name=u"container_one\u6346") - container_2 = self.containers.get(name=u"container_two\u6346") - obj = self.objects.first() - - swift_api = self.stub_swiftclient() - self.mox.StubOutWithMock(api.swift, 'swift_object_exists') - self.mox.StubOutWithMock(container, 'get_object') - self.mox.StubOutWithMock(obj, 'copy_to') - # Using the non-unicode names here, see below. - swift_api.get_container("no_unicode").AndReturn(container) - api.swift.swift_object_exists(self.request, - "also no unicode", - "obj_with_no_unicode").AndReturn(False) - container.get_object("obj_with_no_unicode").AndReturn(obj) - obj.copy_to("also no unicode", "obj_with_no_unicode") - self.mox.ReplayAll() - - # Unicode fails... we'll get to a successful test in a minute - with self.assertRaises(exceptions.HorizonException): - api.swift_copy_object(self.request, - container.name, - obj.name, - container_2.name, - obj.name) - - # Verification handled by mox. No assertions needed. - container.name = "no_unicode" - container_2.name = "also no unicode" - obj.name = "obj_with_no_unicode" - api.swift_copy_object(self.request, - container.name, - obj.name, - container_2.name, - obj.name) diff --git a/horizon/tests/test_data/exceptions.py b/horizon/tests/test_data/exceptions.py index ab55941bc..17bdfd848 100644 --- a/horizon/tests/test_data/exceptions.py +++ b/horizon/tests/test_data/exceptions.py @@ -16,6 +16,7 @@ import glanceclient.exc as glance_exceptions from keystoneclient import exceptions as keystone_exceptions from novaclient import exceptions as nova_exceptions from quantumclient.common import exceptions as quantum_exceptions +from swiftclient import client as swift_exceptions from .utils import TestDataContainer @@ -57,3 +58,6 @@ def data(TEST): quantum_exception = quantum_exceptions.QuantumClientException TEST.exceptions.quantum = create_stubbed_exception(quantum_exception) + + swift_exception = swift_exceptions.ClientException + TEST.exceptions.swift = create_stubbed_exception(swift_exception) diff --git a/horizon/tests/test_data/swift_data.py b/horizon/tests/test_data/swift_data.py index 394f0a560..aaa5575c3 100644 --- a/horizon/tests/test_data/swift_data.py +++ b/horizon/tests/test_data/swift_data.py @@ -12,13 +12,7 @@ # License for the specific language governing permissions and limitations # under the License. -import new - -from django import http - -from cloudfiles import container, storage_object - -from horizon.api import base +from horizon.api import swift from .utils import TestDataContainer @@ -26,20 +20,8 @@ def data(TEST): TEST.containers = TestDataContainer() TEST.objects = TestDataContainer() - request = http.HttpRequest() - request.user = TEST.user - - class FakeConnection(object): - def __init__(self): - self.cdn_enabled = False - self.uri = base.url_for(request, "object-store") - self.token = TEST.token - self.user_agent = "python-cloudfiles" - - conn = FakeConnection() - - container_1 = container.Container(conn, name=u"container_one\u6346") - container_2 = container.Container(conn, name=u"container_two\u6346") + container_1 = swift.Container(dict(name=u"container_one\u6346")) + container_2 = swift.Container(dict(name=u"container_two\u6346")) TEST.containers.add(container_1, container_2) object_dict = {"name": u"test_object\u6346", @@ -48,15 +30,10 @@ def data(TEST): "last_modified": None, "hash": u"object_hash"} obj_dicts = [object_dict] + obj_data = "Fake Data" + for obj_dict in obj_dicts: - swift_object = storage_object.Object(container_1, - object_record=obj_dict) + swift_object = swift.StorageObject(obj_dict, + container_1.name, + data=obj_data) TEST.objects.add(swift_object) - - # Override the list method to return the type of list cloudfiles does. - def get_object_result_list(self): - return storage_object.ObjectResults(container_1, - objects=obj_dicts) - - list_method = new.instancemethod(get_object_result_list, TEST.objects) - TEST.objects.list = list_method diff --git a/openstack_dashboard/exceptions.py b/openstack_dashboard/exceptions.py index 518c330d5..c025aeb34 100644 --- a/openstack_dashboard/exceptions.py +++ b/openstack_dashboard/exceptions.py @@ -18,11 +18,11 @@ # License for the specific language governing permissions and limitations # under the License. -from cloudfiles import errors as swiftclient from glanceclient.common import exceptions as glanceclient from keystoneclient import exceptions as keystoneclient from novaclient import exceptions as novaclient from quantumclient.common import exceptions as quantumclient +from swiftclient import client as swiftclient UNAUTHORIZED = (keystoneclient.Unauthorized, @@ -31,17 +31,13 @@ UNAUTHORIZED = (keystoneclient.Unauthorized, novaclient.Forbidden, glanceclient.Unauthorized, quantumclient.Unauthorized, - quantumclient.Forbidden, - swiftclient.AuthenticationFailed, - swiftclient.AuthenticationError) + quantumclient.Forbidden) NOT_FOUND = (keystoneclient.NotFound, novaclient.NotFound, glanceclient.NotFound, quantumclient.NetworkNotFoundClient, - quantumclient.PortNotFoundClient, - swiftclient.NoSuchContainer, - swiftclient.NoSuchObject) + quantumclient.PortNotFoundClient) # NOTE(gabriel): This is very broad, and may need to be dialed in. RECOVERABLE = (keystoneclient.ClientException, @@ -57,4 +53,4 @@ RECOVERABLE = (keystoneclient.ClientException, quantumclient.PortInUseClient, quantumclient.AlreadyAttachedClient, quantumclient.StateInvalidClient, - swiftclient.Error) + swiftclient.ClientException) diff --git a/openstack_dashboard/static/bootstrap/less/variables.less b/openstack_dashboard/static/bootstrap/less/variables.less index afc463d88..9262ff3b6 100644 --- a/openstack_dashboard/static/bootstrap/less/variables.less +++ b/openstack_dashboard/static/bootstrap/less/variables.less @@ -104,24 +104,4 @@ // Fluid grid @fluidGridColumnWidth: 6.382978723%; -@fluidGridGutterWidth: 2.127659574%; - -//ResourceBrowser -@dataTableBorderWidth: 1px; -@dataTableBorderColor: #DDD; - -@multiSelectionWidth: 25px; -@actionsColumnWidth: 150px; -@actionsColumnPadding: 10px; - -@navigationColWidth: 150px; -@contentColWidth: 240px; - -@smallButtonHeight: 28px; -@tbodyHeight: (@dataTableBorderWidth + @smallButtonHeight + @actionsColumnPadding) * 10; - -@tableCellPadding: 8px; - -@contentTableWidth: @multiSelectionWidth + @contentColWidth * 2 + @actionsColumnWidth + @actionsColumnPadding * 2 + @tableCellPadding * 6 + @dataTableBorderWidth * 3; -@navigationTableWidth: (@navigationColWidth + @actionsColumnPadding + @tableCellPadding) * 2 + @dataTableBorderWidth * 3; -@browserWrapperWidth: @contentTableWidth + @navigationTableWidth; +@fluidGridGutterWidth: 2.127659574%; \ No newline at end of file diff --git a/openstack_dashboard/static/dashboard/less/horizon.less b/openstack_dashboard/static/dashboard/less/horizon.less index aca092a88..a36411758 100644 --- a/openstack_dashboard/static/dashboard/less/horizon.less +++ b/openstack_dashboard/static/dashboard/less/horizon.less @@ -528,7 +528,6 @@ table form { .table_actions { float: right; min-width: 400px; - margin-bottom: 10px; } .table_actions .table_search { @@ -1390,97 +1389,106 @@ label.log-length { float: left; } -/* ResourceBrowser style -*/ +//ResourceBrowser +@dataTableBorderWidth: 1px; +@dataTableBorderColor: #DDD; + +@actionsColumnPadding: 10px; + +@smallButtonHeight: 28px; +@tdHeight: @smallButtonHeight; + +@tableCellPadding: 8px; + +@contentTableWidth: 70%; +@navigationTableWidth: 30%; +@browserWrapperWidth: 100%; + +/* ResourceBrowser style */ #browser_wrapper { - width: @browserWrapperWidth; - > div{ - position: relative; - padding: 55px 0 32px 0; - float: left; - background-color: @grayLighter; - } - div.table_wrapper { - height: @tbodyHeight; - border-left: @dataTableBorderWidth solid @dataTableBorderColor; - border-right: @dataTableBorderWidth solid @dataTableBorderColor; - overflow-y: scroll; - overflow-x: hidden; - } - div.navigation_wrapper { + width: @browserWrapperWidth; + background-color: @grayLighter; + border: @dataTableBorderWidth solid @dataTableBorderColor; + .border-radius(4px); + .tfoot { + clear: both; + padding: 8px; + border-top: 1px solid @dataTableBorderColor; + background-color: #F1F1F1; + font-size: 11px; + line-height: 14px; + span { + display: inline-block; + &.navigation_table_count { width: @navigationTableWidth; - div.table_wrapper, - thead th.table_header { - width: @navigationTableWidth - 2px; - } - td { - background-color: whiteSmoke; - } - td.normal_column{ - width: @navigationColWidth; - min-width: @navigationColWidth; - > a { - width: @navigationColWidth; - min-width: @navigationColWidth; - } - } - tfoot td { - width: @navigationTableWidth - 2 * @dataTableBorderWidth - 2 * @tableCellPadding; - } + } } - div.content_wrapper { - width: @contentTableWidth; - div.table_wrapper, - thead th.table_header { - width: @contentTableWidth - 2px; - } - td.normal_column { - width: @contentColWidth; - min-width: @contentColWidth; - > a { - width: @contentColWidth; - min-width: @contentColWidth; - } - } - tfoot td { - width: @contentTableWidth - 2 * @dataTableBorderWidth - 2 * @tableCellPadding; - } + } + form, table{ + margin-bottom: 0; + } + .navigation_wrapper, .content_wrapper{ + position: relative; + float: left; + } + div.navigation_wrapper { + width: @navigationTableWidth; + div.table_wrapper, + thead th.table_header { + border-right: 0 none; + border-top-right-radius: 0; } - table { - thead { - position: absolute; - top: 0; - left: 0; - tr th { - border: @dataTableBorderWidth solid @dataTableBorderColor; - border-bottom: none; - background-color: @grayLighter; - } - } - td.multi_select_column, - th.multi_select_column{ - width: @multiSelectionWidth; - } - td.actions_column, - th.actions_column{ - padding :@actionsColumnPadding; - width: @actionsColumnWidth; - } - tbody { - tr td:first-child{ - border-left: none; - } - tr td:last-child { - border-right: none; - } - tr:last-child td { - border-bottom: @dataTableBorderWidth solid @dataTableBorderColor; - } - } - tfoot td{ - position: absolute; - left: 0; - bottom: 0; - } + td.normal_column{ + &:first-child { + border-left: 0 none; + } } + tfoot td { + border-right: 0 none; + border-bottom-right-radius: 0; + } + } + div.content_wrapper { + width: @contentTableWidth; + div.table_wrapper, + thead th.table_header { + border-left: 0 none; + border-top-left-radius: 0; + } + td{ + &:last-child { + border-right: 0 none; + } + } + tfoot td { + border-left: 0 none; + border-bottom-left-radius: 0; + } + } + table { + thead { + tr th { + border-bottom: none; + background-color: @grayLighter; + } + } + tbody { + tr:last-child td { + border-bottom: 1px solid #ddd; + border-radius: 0; + } + tr.empty td { + height: @tdHeight; + padding: @actionsColumnPadding; + } + } + &.table-striped { + tbody { + tr:nth-child(even) td, + tr:nth-child(even) th { + background-color: @white; + } + } + } + } } diff --git a/run_tests.sh b/run_tests.sh index cdb5743a5..3dbbbd366 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -6,7 +6,7 @@ set -o errexit # Increment me any time the environment should be rebuilt. # This includes dependncy changes, directory renames, etc. # Simple integer secuence: 1, 2, 3... -environment_version=26 +environment_version=27 #--------------------------------------------------------# function usage { diff --git a/tools/pip-requires b/tools/pip-requires index 8c6a82fd3..4246a56b5 100644 --- a/tools/pip-requires +++ b/tools/pip-requires @@ -2,11 +2,11 @@ Django>=1.4 django_compressor django_openstack_auth -python-cloudfiles python-glanceclient<2 python-keystoneclient python-novaclient python-quantumclient +python-swiftclient>1.1,<1.2 pytz # Horizon Utility Requirements