Makes data table accept mixed data types

This is the first step of making the resource browser, related to
blueprint swift-ui-improvements.

Adds three attrs to DataTableOptions:
.. attribute:: mixed_data_type

.. attribute:: data_types

.. attribute:: data_type_name

Adds attr to Column class:
.. attribute:: allowed_data_types

Adds attr to Action and LinkAction class:
.. attribute:: allowed_data_types

Changes implentation of FilterAction.
Adds MixedDataTableView.

Change-Id: I45fce5e889e92abc592f0a5abfef1abb4ce527be
This commit is contained in:
Ke Wu 2012-07-15 22:54:50 -07:00
parent 576262a142
commit 6ba1a4d395
8 changed files with 256 additions and 61 deletions

View File

@ -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)

View File

@ -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")

View File

@ -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}

View File

@ -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)

View File

@ -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

View File

@ -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):

View File

@ -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('<a href="%s">%s</a>' % (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('<a href="%s">%s</a>' % (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

View File

@ -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