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(' ') filter_string_list = filter_string.lower().strip().split(' ')
def matches_filter(obj): def matches_filter(obj):
if obj.content_type == "application/directory":
return False
for q in filter_string_list: for q in filter_string_list:
return wildcard_search(obj.name.lower(), q) return wildcard_search(obj.name.lower(), q)

View File

@ -107,8 +107,10 @@ class ContainersTable(tables.DataTable):
class DeleteObject(tables.DeleteAction): class DeleteObject(tables.DeleteAction):
name = "delete_object"
data_type_singular = _("Object") data_type_singular = _("Object")
data_type_plural = _("Objects") data_type_plural = _("Objects")
allowed_data_types = ("objects",)
def delete(self, request, obj_id): def delete(self, request, obj_id):
obj = self.table.get_object_by_id(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) 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): class CopyObject(tables.LinkAction):
name = "copy" name = "copy"
verbose_name = _("Copy") verbose_name = _("Copy")
url = "horizon:nova:containers:object_copy" url = "horizon:nova:containers:object_copy"
classes = ("ajax-modal", "btn-copy") classes = ("ajax-modal", "btn-copy")
allowed_data_types = ("objects",)
def get_link_url(self, obj): def get_link_url(self, obj):
return reverse(self.url, args=(http.urlquote(obj.container.name), return reverse(self.url, args=(http.urlquote(obj.container.name),
@ -132,6 +149,7 @@ class DownloadObject(tables.LinkAction):
verbose_name = _("Download") verbose_name = _("Download")
url = "horizon:nova:containers:object_download" url = "horizon:nova:containers:object_download"
classes = ("btn-download",) classes = ("btn-download",)
allowed_data_types = ("objects",)
def get_link_url(self, obj): def get_link_url(self, obj):
return reverse(self.url, args=(http.urlquote(obj.container.name), return reverse(self.url, args=(http.urlquote(obj.container.name),
@ -139,39 +157,34 @@ class DownloadObject(tables.LinkAction):
class ObjectFilterAction(tables.FilterAction): class ObjectFilterAction(tables.FilterAction):
def filter(self, table, objects, filter_string): def _filtered_data(self, table, filter_string):
request = table._meta.request request = table._meta.request
container = self.table.kwargs['container_name'] container = self.table.kwargs['container_name']
subfolder = self.table.kwargs['subfolder_path'] subfolder = self.table.kwargs['subfolder_path']
path = subfolder + '/' if subfolder else '' path = subfolder + '/' if subfolder else ''
return api.swift_filter_objects(request, self.filtered_data = api.swift_filter_objects(request,
filter_string, filter_string,
container, container,
path=path) 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): def sanitize_name(name):
return name.split("/")[-1] return name.split("/")[-1]
class ObjectsTable(tables.DataTable): def get_size(obj):
name = tables.Column("name", return filesizeformat(obj.size)
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_link_subfolder(subfolder): def get_link_subfolder(subfolder):
@ -192,22 +205,22 @@ class CreateSubfolder(CreateContainer):
return reverse(self.url, args=(http.urlquote(parent + "/"),)) return reverse(self.url, args=(http.urlquote(parent + "/"),))
class DeleteSubfolder(DeleteObject): class ObjectsTable(tables.DataTable):
data_type_singular = _("Folder")
data_type_plural = _("Folders")
class ContainerSubfoldersTable(tables.DataTable):
name = tables.Column("name", name = tables.Column("name",
link=get_link_subfolder, link=get_link_subfolder,
verbose_name=_("Subfolder Name"), allowed_data_types=("subfolders",),
filters=(sanitize_name,)) verbose_name=_("Object Name"),
filters=(sanitize_name,))
size = tables.Column(get_size, verbose_name=_('Size'))
def get_object_id(self, obj): def get_object_id(self, obj):
return obj.name return obj.name
class Meta: class Meta:
name = "subfolders" name = "objects"
verbose_name = _("Subfolders") verbose_name = _("Subfolders and Objects")
table_actions = (CreateSubfolder, DeleteSubfolder) table_actions = (ObjectFilterAction, CreateSubfolder,
row_actions = (DeleteSubfolder,) UploadObject, DeleteMultipleObjects)
row_actions = (DownloadObject, CopyObject, DeleteObject,
DeleteSubfolder)
data_types = ("subfolders", "objects")

View File

@ -179,7 +179,7 @@ class ObjectViewTests(test.TestCase):
obj.name) obj.name)
self.mox.ReplayAll() self.mox.ReplayAll()
action_string = "objects__delete__%s" % obj.name action_string = "objects__delete_object__%s" % obj.name
form_data = {"action": action_string} form_data = {"action": action_string}
req = self.factory.post(index_url, form_data) req = self.factory.post(index_url, form_data)
kwargs = {"container_name": container.name} kwargs = {"container_name": container.name}

View File

@ -33,8 +33,7 @@ from horizon import exceptions
from horizon import forms from horizon import forms
from horizon import tables from horizon import tables
from .forms import CreateContainer, UploadObject, CopyObject from .forms import CreateContainer, UploadObject, CopyObject
from .tables import ContainersTable, ObjectsTable,\ from .tables import ContainersTable, ObjectsTable
ContainerSubfoldersTable
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
@ -81,8 +80,8 @@ class CreateView(forms.ModalFormView):
return initial return initial
class ObjectIndexView(tables.MultiTableView): class ObjectIndexView(tables.MixedDataTableView):
table_classes = (ObjectsTable, ContainerSubfoldersTable) table_class = ObjectsTable
template_name = 'nova/containers/detail.html' template_name = 'nova/containers/detail.html'
def has_more_data(self, table): def has_more_data(self, table):
@ -110,6 +109,7 @@ class ObjectIndexView(tables.MultiTableView):
marker=marker, marker=marker,
path=prefix) path=prefix)
except: except:
self._more = None
objects = [] objects = []
msg = _('Unable to retrieve object list.') msg = _('Unable to retrieve object list.')
exceptions.handle(self.request, msg) exceptions.handle(self.request, msg)

View File

@ -18,4 +18,5 @@
from .actions import (Action, BatchAction, DeleteAction, from .actions import (Action, BatchAction, DeleteAction,
LinkAction, FilterAction) LinkAction, FilterAction)
from .base import DataTable, Column, Row 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__() super(BaseAction, self).__init__()
self.datum = datum 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): def allowed(self, request, datum):
""" Determine whether this action is allowed for the current request. """ 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 to bypass any API calls and processing which would otherwise be
required to load the table. 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: At least one of the following methods must be defined:
.. method:: single(self, data_table, request, object_id) .. 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, def __init__(self, verbose_name=None, verbose_name_plural=None,
single_func=None, multiple_func=None, handle_func=None, single_func=None, multiple_func=None, handle_func=None,
handles_multiple=False, attrs=None, requires_input=True, handles_multiple=False, attrs=None, requires_input=True,
datum=None): allowed_data_types=[], datum=None):
super(Action, self).__init__(datum=datum) super(Action, self).__init__(datum=datum)
# Priority: constructor, class-defined, fallback # Priority: constructor, class-defined, fallback
self.verbose_name = verbose_name or getattr(self, 'verbose_name', self.verbose_name = verbose_name or getattr(self, 'verbose_name',
@ -168,6 +192,9 @@ class Action(BaseAction):
self.requires_input = getattr(self, self.requires_input = getattr(self,
"requires_input", "requires_input",
requires_input) requires_input)
self.allowed_data_types = getattr(self, "allowed_data_types",
allowed_data_types)
if attrs: if attrs:
self.attrs.update(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 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 target. You must either define the ``url`` attribute or a override
the ``get_link_url`` method on the class. 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" method = "GET"
bound_url = None 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__() super(LinkAction, self).__init__()
self.verbose_name = verbose_name or getattr(self, self.verbose_name = verbose_name or getattr(self,
"verbose_name", "verbose_name",
self.name.title()) self.name.title())
self.url = getattr(self, "url", url) self.url = getattr(self, "url", url)
if not self.verbose_name: if not self.verbose_name:
raise NotImplementedError('A LinkAction object must have a ' raise NotImplementedError('A LinkAction object must have a '
'verbose_name attribute.') 'verbose_name attribute.')
self.allowed_data_types = getattr(self, "allowed_data_types",
allowed_data_types)
if attrs: if attrs:
self.attrs.update(attrs) self.attrs.update(attrs)
@ -316,14 +355,37 @@ class FilterAction(BaseAction):
classes += ("btn-search",) classes += ("btn-search",)
return classes 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): def filter(self, table, data, filter_string):
""" Provides the actual filtering logic. """ Provides the actual filtering logic.
This method must be overridden by subclasses and return This method must be overridden by subclasses and return
the filtered data. the filtered data.
""" """
raise NotImplementedError("The filter method has not been implemented " raise NotImplementedError("The filter method has not been "
"by %s." % self.__class__) "implemented by %s." % self.__class__)
class BatchAction(Action): 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 A string or callable which returns a URL which will be wrapped around
this column's text as a link. 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 .. attribute:: status
Boolean designating whether or not this column represents a 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, def __init__(self, transform, verbose_name=None, sortable=True,
link=None, hidden=False, attrs=None, status=False, link=None, allowed_data_types=[], hidden=False, attrs=None,
status_choices=None, display_choices=None, empty_value=None, status=False, status_choices=None, display_choices=None,
filters=None, classes=None, summation=None, auto=None, empty_value=None, filters=None, classes=None, summation=None,
truncate=None): auto=None, truncate=None):
self.classes = list(classes or getattr(self, "classes", [])) self.classes = list(classes or getattr(self, "classes", []))
super(Column, self).__init__() super(Column, self).__init__()
self.attrs.update(attrs or {}) self.attrs.update(attrs or {})
@ -204,6 +212,7 @@ class Column(html.HTMLElement):
self.sortable = sortable self.sortable = sortable
self.verbose_name = verbose_name self.verbose_name = verbose_name
self.link = link self.link = link
self.allowed_data_types = allowed_data_types
self.hidden = hidden self.hidden = hidden
self.status = status self.status = status
self.empty_value = empty_value or '-' self.empty_value = empty_value or '-'
@ -298,11 +307,21 @@ class Column(html.HTMLElement):
def get_link_url(self, datum): def get_link_url(self, datum):
""" Returns the final value for the column's ``link`` property. """ 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 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 and should return a URL. Otherwise ``get_link_url`` will attempt to
call ``reverse`` on ``link`` with the object's id as a parameter. call ``reverse`` on ``link`` with the object's id as a parameter.
Failing that, it will simply return the value of ``link``. 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) obj_id = self.table.get_object_id(datum)
if callable(self.link): if callable(self.link):
return self.link(datum) return self.link(datum)
@ -529,12 +548,19 @@ class Cell(html.HTMLElement):
data = None data = None
exc_info = sys.exc_info() exc_info = sys.exc_info()
raise template.TemplateSyntaxError, exc_info[1], exc_info[2] 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: if self.column.link:
url = self.column.get_link_url(self.datum) url = self.column.get_link_url(self.datum)
if url: if url:
# Escape the data inside while allowing our HTML to render return url
data = mark_safe('<a href="%s">%s</a>' % (url, escape(data))) else:
return data return None
@property @property
def status(self): def status(self):
@ -565,6 +591,9 @@ class Cell(html.HTMLElement):
def get_default_classes(self): def get_default_classes(self):
""" Returns a flattened string of the cell's CSS classes. """ """ 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', "") column_class_string = self.column.get_final_attrs().get('class', "")
classes = set(column_class_string.split(" ")) classes = set(column_class_string.split(" "))
if self.column.status: if self.column.status:
@ -656,6 +685,20 @@ class DataTableOptions(object):
The class which should be used for handling the columns of this table. The class which should be used for handling the columns of this table.
Optional. Default: :class:`~horizon.tables.Column`. 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): def __init__(self, options):
self.name = getattr(options, 'name', self.__class__.__name__) self.name = getattr(options, 'name', self.__class__.__name__)
@ -700,6 +743,25 @@ class DataTableOptions(object):
# Set runtime table defaults; not configurable. # Set runtime table defaults; not configurable.
self.has_more_data = False 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): class DataTableMetaclass(type):
""" Metaclass to add options to DataTable class and collect columns. """ """ Metaclass to add options to DataTable class and collect columns. """
@ -842,9 +904,14 @@ class DataTable(object):
filter_string = self.get_filter_string() filter_string = self.get_filter_string()
request_method = self._meta.request.method request_method = self._meta.request.method
if filter_string and request_method == action.method: if filter_string and request_method == action.method:
self._filtered_data = action.filter(self, if self._meta.mixed_data_type:
self.data, self._filtered_data = action.data_type_filter(self,
filter_string) self.data,
filter_string)
else:
self._filtered_data = action.filter(self,
self.data,
filter_string)
return self._filtered_data return self._filtered_data
def get_filter_string(self): def get_filter_string(self):
@ -862,7 +929,10 @@ class DataTable(object):
def _filter_action(self, action, request, datum=None): def _filter_action(self, action, request, datum=None):
try: try:
# Catch user errors in permission functions here # 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: except Exception:
LOG.exception("Error while checking action permissions.") LOG.exception("Error while checking action permissions.")
return None return None

View File

@ -154,3 +154,54 @@ class DataTableView(MultiTableView):
context = super(DataTableView, self).get_context_data(**kwargs) context = super(DataTableView, self).get_context_data(**kwargs)
context[self.context_object_name] = self.table context[self.context_object_name] = self.table
return context 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