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
This commit is contained in:
parent
ee17b1588b
commit
801c2321bf
@ -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,
|
||||
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,
|
||||
path=path)
|
||||
if(len(objects) > limit):
|
||||
return (objects[0:-1], True)
|
||||
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
|
||||
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 = 10000
|
||||
container = swift_api(request).get_container(container_name)
|
||||
objects = container.get_objects(prefix=prefix,
|
||||
# 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,
|
||||
delimiter=FOLDER_DELIMITER,
|
||||
path=path)
|
||||
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',
|
||||
headers = {'content-type': 'application/directory',
|
||||
'content-length': 0}
|
||||
obj.send('')
|
||||
obj.sync_metadata()
|
||||
return obj
|
||||
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)
|
||||
|
@ -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)
|
||||
|
@ -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):
|
||||
|
@ -30,3 +30,4 @@ class ContainerBrowser(browsers.ResourceBrowser):
|
||||
verbose_name = _("Swift")
|
||||
navigation_table_class = ContainersTable
|
||||
content_table_class = ObjectsTable
|
||||
navigable_item_name = _("Container")
|
||||
|
@ -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)
|
||||
|
@ -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):
|
||||
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
|
||||
|
||||
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))))
|
||||
@ -267,3 +266,4 @@ class ObjectsTable(tables.DataTable):
|
||||
DeleteSubfolder)
|
||||
data_types = ("subfolders", "objects")
|
||||
browser_table = "content"
|
||||
footer = False
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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):
|
||||
|
@ -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)
|
||||
|
@ -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):
|
||||
|
@ -28,6 +28,7 @@
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
{% if table.footer %}
|
||||
<tfoot>
|
||||
{% if table.needs_summary_row %}
|
||||
<tr class="summation">
|
||||
@ -50,6 +51,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
{% endif %}
|
||||
</table>
|
||||
{% endwith %}
|
||||
{% if needs_form_wrapper %}</form>{% endif %}
|
||||
|
@ -1,9 +1,13 @@
|
||||
{% load i18n %}
|
||||
<div id="browser_wrapper">
|
||||
<div id="browser_wrapper" class="pull-left">
|
||||
<div class="navigation_wrapper">
|
||||
{{ browser.navigation_table.render }}
|
||||
</div>
|
||||
<div class="content_wrapper">
|
||||
{{ browser.content_table.render }}
|
||||
</div>
|
||||
<div class="tfoot">
|
||||
<span class="navigation_table_count">{% blocktrans count nav_items=browser.navigation_table.data|length %}Displaying {{ nav_items }} item{% plural %}Displaying {{ nav_items }} items{% endblocktrans %}</span>
|
||||
<span class="content_table_count">{% blocktrans count content_items=browser.content_table.data|length %}Displaying {{ content_items }} item{% plural %}Displaying {{ content_items }} items{% endblocktrans %}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
swift_api.get_container(container.name,
|
||||
limit=1001,
|
||||
marker=None,
|
||||
prefix=None,
|
||||
delimiter='/',
|
||||
path=None).AndReturn(objects)
|
||||
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,
|
||||
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)
|
||||
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)
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -105,23 +105,3 @@
|
||||
// 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;
|
||||
|
@ -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;
|
||||
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 {
|
||||
height: @tbodyHeight;
|
||||
border-left: @dataTableBorderWidth solid @dataTableBorderColor;
|
||||
border-right: @dataTableBorderWidth solid @dataTableBorderColor;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
}
|
||||
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 {
|
||||
width: @navigationTableWidth - 2px;
|
||||
}
|
||||
td {
|
||||
background-color: whiteSmoke;
|
||||
border-right: 0 none;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
td.normal_column{
|
||||
width: @navigationColWidth;
|
||||
min-width: @navigationColWidth;
|
||||
> a {
|
||||
width: @navigationColWidth;
|
||||
min-width: @navigationColWidth;
|
||||
&:first-child {
|
||||
border-left: 0 none;
|
||||
}
|
||||
}
|
||||
tfoot td {
|
||||
width: @navigationTableWidth - 2 * @dataTableBorderWidth - 2 * @tableCellPadding;
|
||||
border-right: 0 none;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
}
|
||||
div.content_wrapper {
|
||||
width: @contentTableWidth;
|
||||
div.table_wrapper,
|
||||
thead th.table_header {
|
||||
width: @contentTableWidth - 2px;
|
||||
border-left: 0 none;
|
||||
border-top-left-radius: 0;
|
||||
}
|
||||
td.normal_column {
|
||||
width: @contentColWidth;
|
||||
min-width: @contentColWidth;
|
||||
> a {
|
||||
width: @contentColWidth;
|
||||
min-width: @contentColWidth;
|
||||
td{
|
||||
&:last-child {
|
||||
border-right: 0 none;
|
||||
}
|
||||
}
|
||||
tfoot td {
|
||||
width: @contentTableWidth - 2 * @dataTableBorderWidth - 2 * @tableCellPadding;
|
||||
border-left: 0 none;
|
||||
border-bottom-left-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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
tfoot td{
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user