diff --git a/horizon/api/swift.py b/horizon/api/swift.py
index baa052fcf..00847e1c4 100644
--- a/horizon/api/swift.py
+++ b/horizon/api/swift.py
@@ -118,8 +118,6 @@ def swift_filter_objects(request, filter_string, container_name, prefix=None,
filter_string_list = filter_string.lower().strip().split(' ')
def matches_filter(obj):
- if obj.content_type == "application/directory":
- return False
for q in filter_string_list:
return wildcard_search(obj.name.lower(), q)
diff --git a/horizon/dashboards/nova/containers/tables.py b/horizon/dashboards/nova/containers/tables.py
index f450e5165..72ae557f7 100644
--- a/horizon/dashboards/nova/containers/tables.py
+++ b/horizon/dashboards/nova/containers/tables.py
@@ -107,8 +107,10 @@ class ContainersTable(tables.DataTable):
class DeleteObject(tables.DeleteAction):
+ name = "delete_object"
data_type_singular = _("Object")
data_type_plural = _("Objects")
+ allowed_data_types = ("objects",)
def delete(self, request, obj_id):
obj = self.table.get_object_by_id(obj_id)
@@ -116,11 +118,26 @@ class DeleteObject(tables.DeleteAction):
api.swift_delete_object(request, container_name, obj_id)
+class DeleteSubfolder(DeleteObject):
+ name = "delete_subfolder"
+ data_type_singular = _("Folder")
+ data_type_plural = _("Folders")
+ allowed_data_types = ("subfolders",)
+
+
+class DeleteMultipleObjects(DeleteObject):
+ name = "delete_multiple_objects"
+ data_type_singular = _("Object/Folder")
+ data_type_plural = _("Objects/Folders")
+ allowed_data_types = ("subfolders", "objects",)
+
+
class CopyObject(tables.LinkAction):
name = "copy"
verbose_name = _("Copy")
url = "horizon:nova:containers:object_copy"
classes = ("ajax-modal", "btn-copy")
+ allowed_data_types = ("objects",)
def get_link_url(self, obj):
return reverse(self.url, args=(http.urlquote(obj.container.name),
@@ -132,6 +149,7 @@ class DownloadObject(tables.LinkAction):
verbose_name = _("Download")
url = "horizon:nova:containers:object_download"
classes = ("btn-download",)
+ allowed_data_types = ("objects",)
def get_link_url(self, obj):
return reverse(self.url, args=(http.urlquote(obj.container.name),
@@ -139,39 +157,34 @@ class DownloadObject(tables.LinkAction):
class ObjectFilterAction(tables.FilterAction):
- def filter(self, table, objects, filter_string):
+ def _filtered_data(self, table, filter_string):
request = table._meta.request
container = self.table.kwargs['container_name']
subfolder = self.table.kwargs['subfolder_path']
path = subfolder + '/' if subfolder else ''
- return api.swift_filter_objects(request,
- filter_string,
- container,
- path=path)
+ self.filtered_data = api.swift_filter_objects(request,
+ filter_string,
+ container,
+ path=path)
+ return self.filtered_data
+
+ def filter_subfolders_data(self, table, objects, filter_string):
+ data = self._filtered_data(table, filter_string)
+ return [datum for datum in data if
+ datum.content_type == "application/directory"]
+
+ def filter_objects_data(self, table, objects, filter_string):
+ data = self._filtered_data(table, filter_string)
+ return [datum for datum in data if
+ datum.content_type != "application/directory"]
def sanitize_name(name):
return name.split("/")[-1]
-class ObjectsTable(tables.DataTable):
- name = tables.Column("name",
- verbose_name=_("Object Name"),
- filters=(sanitize_name,))
- size = tables.Column("size",
- verbose_name=_('Size'),
- filters=(filesizeformat,),
- summation="sum",
- attrs={'data-type': 'size'})
-
- def get_object_id(self, obj):
- return obj.name
-
- class Meta:
- name = "objects"
- verbose_name = _("Objects")
- table_actions = (ObjectFilterAction, UploadObject, DeleteObject)
- row_actions = (DownloadObject, CopyObject, DeleteObject)
+def get_size(obj):
+ return filesizeformat(obj.size)
def get_link_subfolder(subfolder):
@@ -192,22 +205,22 @@ class CreateSubfolder(CreateContainer):
return reverse(self.url, args=(http.urlquote(parent + "/"),))
-class DeleteSubfolder(DeleteObject):
- data_type_singular = _("Folder")
- data_type_plural = _("Folders")
-
-
-class ContainerSubfoldersTable(tables.DataTable):
+class ObjectsTable(tables.DataTable):
name = tables.Column("name",
- link=get_link_subfolder,
- verbose_name=_("Subfolder Name"),
- filters=(sanitize_name,))
+ link=get_link_subfolder,
+ allowed_data_types=("subfolders",),
+ verbose_name=_("Object Name"),
+ filters=(sanitize_name,))
+ size = tables.Column(get_size, verbose_name=_('Size'))
def get_object_id(self, obj):
return obj.name
class Meta:
- name = "subfolders"
- verbose_name = _("Subfolders")
- table_actions = (CreateSubfolder, DeleteSubfolder)
- row_actions = (DeleteSubfolder,)
+ name = "objects"
+ verbose_name = _("Subfolders and Objects")
+ table_actions = (ObjectFilterAction, CreateSubfolder,
+ UploadObject, DeleteMultipleObjects)
+ row_actions = (DownloadObject, CopyObject, DeleteObject,
+ DeleteSubfolder)
+ data_types = ("subfolders", "objects")
diff --git a/horizon/dashboards/nova/containers/tests.py b/horizon/dashboards/nova/containers/tests.py
index 61d65fd6d..cdafc22f9 100644
--- a/horizon/dashboards/nova/containers/tests.py
+++ b/horizon/dashboards/nova/containers/tests.py
@@ -179,7 +179,7 @@ class ObjectViewTests(test.TestCase):
obj.name)
self.mox.ReplayAll()
- action_string = "objects__delete__%s" % obj.name
+ action_string = "objects__delete_object__%s" % obj.name
form_data = {"action": action_string}
req = self.factory.post(index_url, form_data)
kwargs = {"container_name": container.name}
diff --git a/horizon/dashboards/nova/containers/views.py b/horizon/dashboards/nova/containers/views.py
index a37fec286..0f2f46141 100644
--- a/horizon/dashboards/nova/containers/views.py
+++ b/horizon/dashboards/nova/containers/views.py
@@ -33,8 +33,7 @@ from horizon import exceptions
from horizon import forms
from horizon import tables
from .forms import CreateContainer, UploadObject, CopyObject
-from .tables import ContainersTable, ObjectsTable,\
- ContainerSubfoldersTable
+from .tables import ContainersTable, ObjectsTable
LOG = logging.getLogger(__name__)
@@ -81,8 +80,8 @@ class CreateView(forms.ModalFormView):
return initial
-class ObjectIndexView(tables.MultiTableView):
- table_classes = (ObjectsTable, ContainerSubfoldersTable)
+class ObjectIndexView(tables.MixedDataTableView):
+ table_class = ObjectsTable
template_name = 'nova/containers/detail.html'
def has_more_data(self, table):
@@ -110,6 +109,7 @@ class ObjectIndexView(tables.MultiTableView):
marker=marker,
path=prefix)
except:
+ self._more = None
objects = []
msg = _('Unable to retrieve object list.')
exceptions.handle(self.request, msg)
diff --git a/horizon/tables/__init__.py b/horizon/tables/__init__.py
index ef8060430..92acef6cc 100644
--- a/horizon/tables/__init__.py
+++ b/horizon/tables/__init__.py
@@ -18,4 +18,5 @@
from .actions import (Action, BatchAction, DeleteAction,
LinkAction, FilterAction)
from .base import DataTable, Column, Row
-from .views import DataTableView, MultiTableView, MultiTableMixin
+from .views import DataTableView, MultiTableView, MultiTableMixin, \
+ MixedDataTableView
diff --git a/horizon/tables/actions.py b/horizon/tables/actions.py
index 0ac858175..f3a309dd2 100644
--- a/horizon/tables/actions.py
+++ b/horizon/tables/actions.py
@@ -46,6 +46,21 @@ class BaseAction(html.HTMLElement):
super(BaseAction, self).__init__()
self.datum = datum
+ def data_type_matched(self, datum):
+ """ Method to see if the action is allowed for a certain type of data.
+ Only affects mixed data type tables.
+ """
+ if datum:
+ action_data_types = getattr(self, "allowed_data_types", [])
+ # If the data types of this action is empty, we assume it accepts
+ # all kinds of data and this method will return True.
+ if action_data_types:
+ datum_type = getattr(datum, self.table._meta.data_type_name,
+ None)
+ if datum_type and (datum_type not in action_data_types):
+ return False
+ return True
+
def allowed(self, request, datum):
""" Determine whether this action is allowed for the current request.
@@ -130,6 +145,15 @@ class Action(BaseAction):
to bypass any API calls and processing which would otherwise be
required to load the table.
+ .. attribute:: allowed_data_types
+
+ A list that contains the allowed data types of the action. If the
+ datum's type is in this list, the action will be shown on the row
+ for the datum.
+
+ Default to be an empty list (``[]``). When set to empty, the action
+ will accept any kind of data.
+
At least one of the following methods must be defined:
.. method:: single(self, data_table, request, object_id)
@@ -154,7 +178,7 @@ class Action(BaseAction):
def __init__(self, verbose_name=None, verbose_name_plural=None,
single_func=None, multiple_func=None, handle_func=None,
handles_multiple=False, attrs=None, requires_input=True,
- datum=None):
+ allowed_data_types=[], datum=None):
super(Action, self).__init__(datum=datum)
# Priority: constructor, class-defined, fallback
self.verbose_name = verbose_name or getattr(self, 'verbose_name',
@@ -168,6 +192,9 @@ class Action(BaseAction):
self.requires_input = getattr(self,
"requires_input",
requires_input)
+ self.allowed_data_types = getattr(self, "allowed_data_types",
+ allowed_data_types)
+
if attrs:
self.attrs.update(attrs)
@@ -229,19 +256,31 @@ class LinkAction(BaseAction):
A string or a callable which resolves to a url to be used as the link
target. You must either define the ``url`` attribute or a override
the ``get_link_url`` method on the class.
+
+ .. attribute:: allowed_data_types
+
+ A list that contains the allowed data types of the action. If the
+ datum's type is in this list, the action will be shown on the row
+ for the datum.
+
+ Defaults to be an empty list (``[]``). When set to empty, the action
+ will accept any kind of data.
"""
method = "GET"
bound_url = None
- def __init__(self, verbose_name=None, url=None, attrs=None):
+ def __init__(self, verbose_name=None, allowed_data_types=[],
+ url=None, attrs=None):
super(LinkAction, self).__init__()
self.verbose_name = verbose_name or getattr(self,
- "verbose_name",
- self.name.title())
+ "verbose_name",
+ self.name.title())
self.url = getattr(self, "url", url)
if not self.verbose_name:
raise NotImplementedError('A LinkAction object must have a '
'verbose_name attribute.')
+ self.allowed_data_types = getattr(self, "allowed_data_types",
+ allowed_data_types)
if attrs:
self.attrs.update(attrs)
@@ -316,14 +355,37 @@ class FilterAction(BaseAction):
classes += ("btn-search",)
return classes
+ def assign_type_string(self, table, data, type_string):
+ for datum in data:
+ 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:
+ func_name = "filter_%s_data" % data_type
+ filter_func = getattr(self, func_name, None)
+ if not filter_func and not callable(filter_func):
+ # The check of filter function implementation should happen
+ # in the __init__. However, the current workflow of DataTable
+ # and actions won't allow it. Need to be fixed in the future.
+ cls_name = self.__class__.__name__
+ raise NotImplementedError("You must define a %s method "
+ "for %s data type in %s." %
+ (func_name, data_type, cls_name))
+ _data = filter_func(table, data, filter_string)
+ self.assign_type_string(table, _data, data_type)
+ filtered_data.extend(_data)
+ return filtered_data
+
def filter(self, table, data, filter_string):
""" Provides the actual filtering logic.
This method must be overridden by subclasses and return
the filtered data.
"""
- raise NotImplementedError("The filter method has not been implemented "
- "by %s." % self.__class__)
+ raise NotImplementedError("The filter method has not been "
+ "implemented by %s." % self.__class__)
class BatchAction(Action):
diff --git a/horizon/tables/base.py b/horizon/tables/base.py
index bec13ec4a..1a1ddea84 100644
--- a/horizon/tables/base.py
+++ b/horizon/tables/base.py
@@ -78,6 +78,14 @@ class Column(html.HTMLElement):
A string or callable which returns a URL which will be wrapped around
this column's text as a link.
+ .. attribute:: allowed_data_types
+
+ A list of data types for which the link should be created.
+ Default is an empty list (``[]``).
+
+ When the list is empty and the ``link`` attribute is not None, all the
+ rows under this column will be links.
+
.. attribute:: status
Boolean designating whether or not this column represents a status
@@ -179,10 +187,10 @@ class Column(html.HTMLElement):
)
def __init__(self, transform, verbose_name=None, sortable=True,
- link=None, hidden=False, attrs=None, status=False,
- status_choices=None, display_choices=None, empty_value=None,
- filters=None, classes=None, summation=None, auto=None,
- truncate=None):
+ link=None, allowed_data_types=[], hidden=False, attrs=None,
+ status=False, status_choices=None, display_choices=None,
+ empty_value=None, filters=None, classes=None, summation=None,
+ auto=None, truncate=None):
self.classes = list(classes or getattr(self, "classes", []))
super(Column, self).__init__()
self.attrs.update(attrs or {})
@@ -204,6 +212,7 @@ class Column(html.HTMLElement):
self.sortable = sortable
self.verbose_name = verbose_name
self.link = link
+ self.allowed_data_types = allowed_data_types
self.hidden = hidden
self.status = status
self.empty_value = empty_value or '-'
@@ -298,11 +307,21 @@ class Column(html.HTMLElement):
def get_link_url(self, datum):
""" Returns the final value for the column's ``link`` property.
+ If ``allowed_data_types`` of this column is not empty and the datum
+ has an assigned type, check if the datum's type is in the
+ ``allowed_data_types`` list. If not, the datum won't be displayed
+ as a link.
+
If ``link`` is a callable, it will be passed the current data object
and should return a URL. Otherwise ``get_link_url`` will attempt to
call ``reverse`` on ``link`` with the object's id as a parameter.
Failing that, it will simply return the value of ``link``.
"""
+ if self.allowed_data_types:
+ data_type_name = self.table._meta.data_type_name
+ data_type = getattr(datum, data_type_name, None)
+ if data_type and (data_type not in self.allowed_data_types):
+ return None
obj_id = self.table.get_object_id(datum)
if callable(self.link):
return self.link(datum)
@@ -529,12 +548,19 @@ class Cell(html.HTMLElement):
data = None
exc_info = sys.exc_info()
raise template.TemplateSyntaxError, exc_info[1], exc_info[2]
+ if self.url:
+ # Escape the data inside while allowing our HTML to render
+ data = mark_safe('%s' % (self.url, escape(data)))
+ return data
+
+ @property
+ def url(self):
if self.column.link:
url = self.column.get_link_url(self.datum)
if url:
- # Escape the data inside while allowing our HTML to render
- data = mark_safe('%s' % (url, escape(data)))
- return data
+ return url
+ else:
+ return None
@property
def status(self):
@@ -565,6 +591,9 @@ class Cell(html.HTMLElement):
def get_default_classes(self):
""" Returns a flattened string of the cell's CSS classes. """
+ if not self.url:
+ self.column.classes = [cls for cls in self.column.classes
+ if cls != "anchor"]
column_class_string = self.column.get_final_attrs().get('class', "")
classes = set(column_class_string.split(" "))
if self.column.status:
@@ -656,6 +685,20 @@ class DataTableOptions(object):
The class which should be used for handling the columns of this table.
Optional. Default: :class:`~horizon.tables.Column`.
+
+ .. attribute:: mixed_data_type
+
+ A toggle to indicate if the table accepts two or more types of data.
+ Optional. Default: :``False``
+
+ .. attribute:: data_types
+ A list of data types that this table would accept. Default to be an
+ empty list, but if the attibute ``mixed_data_type`` is set to ``True``,
+ then this list must have at least one element.
+
+ .. attribute:: data_type_name
+ The name of an attribute to assign to data passed to the table when it
+ accepts mix data. Default: ``"_table_data_type"``
"""
def __init__(self, options):
self.name = getattr(options, 'name', self.__class__.__name__)
@@ -700,6 +743,25 @@ class DataTableOptions(object):
# Set runtime table defaults; not configurable.
self.has_more_data = False
+ # Set mixed data type table attr
+ self.mixed_data_type = getattr(options, 'mixed_data_type', False)
+ self.data_types = getattr(options, 'data_types', [])
+
+ # If the data_types has more than 2 elements, set mixed_data_type
+ # to True automatically.
+ if len(self.data_types) > 1:
+ self.mixed_data_type = True
+
+ # However, if the mixed_data_type is set to True manually and the
+ # the data_types is empty, raise an errror.
+ if self.mixed_data_type and len(self.data_types) <= 1:
+ raise ValueError("If mixed_data_type is set to True in class %s, "
+ "data_types should has more than one types" %
+ self.name)
+
+ self.data_type_name = getattr(options, 'data_type_name',
+ "_table_data_type")
+
class DataTableMetaclass(type):
""" Metaclass to add options to DataTable class and collect columns. """
@@ -842,9 +904,14 @@ class DataTable(object):
filter_string = self.get_filter_string()
request_method = self._meta.request.method
if filter_string and request_method == action.method:
- self._filtered_data = action.filter(self,
- self.data,
- filter_string)
+ if self._meta.mixed_data_type:
+ self._filtered_data = action.data_type_filter(self,
+ self.data,
+ filter_string)
+ else:
+ self._filtered_data = action.filter(self,
+ self.data,
+ filter_string)
return self._filtered_data
def get_filter_string(self):
@@ -862,7 +929,10 @@ class DataTable(object):
def _filter_action(self, action, request, datum=None):
try:
# Catch user errors in permission functions here
- return action._allowed(request, datum)
+ row_matched = True
+ if self._meta.mixed_data_type:
+ row_matched = action.data_type_matched(datum)
+ return action._allowed(request, datum) and row_matched
except Exception:
LOG.exception("Error while checking action permissions.")
return None
diff --git a/horizon/tables/views.py b/horizon/tables/views.py
index 612afcac2..f50527ab6 100644
--- a/horizon/tables/views.py
+++ b/horizon/tables/views.py
@@ -154,3 +154,54 @@ class DataTableView(MultiTableView):
context = super(DataTableView, self).get_context_data(**kwargs)
context[self.context_object_name] = self.table
return context
+
+
+class MixedDataTableView(DataTableView):
+ """ A class-based generic view to handle DataTable with mixed data
+ types.
+
+ Basic usage is the same as DataTableView.
+
+ Three steps are required to use this view:
+ #. Set the ``table_class`` attribute with desired
+ :class:`~horizon.tables.DataTable` class. In the class the
+ ``data_types`` list should have at least two elements.
+
+ #. Define a ``get_{{ data_type }}_data`` method for each data type
+ which returns a set of data for the table.
+
+ #. Specify a template for the ``template_name`` attribute.
+ """
+ table_class = None
+ context_object_name = 'table'
+
+ def _get_data_dict(self):
+ if not self._data:
+ table = self.table_class
+ self._data = {table._meta.name: []}
+ for data_type in table._meta.data_types:
+ func_name = "get_%s_data" % data_type
+ data_func = getattr(self, func_name, None)
+ if data_func is None:
+ cls_name = self.__class__.__name__
+ raise NotImplementedError("You must define a %s method "
+ "for %s data type in %s." %
+ (func_name, data_type, cls_name))
+ data = data_func()
+ self.assign_type_string(data, data_type)
+ self._data[table._meta.name].extend(data)
+ return self._data
+
+ def assign_type_string(self, data, type_string):
+ for datum in data:
+ setattr(datum, self.table_class._meta.data_type_name,
+ type_string)
+
+ def get_table(self):
+ self.table = super(MixedDataTableView, self).get_table()
+ if not self.table._meta.mixed_data_type:
+ raise AttributeError('You must have at least two elements in '
+ 'the data_types attibute '
+ 'in table %s to use MixedDataTableView.'
+ % self.table._meta.name)
+ return self.table