Merge "Switch to use python-swiftclient instead of cloudfiles."
This commit is contained in:
commit
647e8a5d27
@ -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 {
|
||||
@ -1393,97 +1392,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…
x
Reference in New Issue
Block a user