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:
parent
576262a142
commit
6ba1a4d395
@ -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)
|
||||
|
||||
|
@ -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")
|
||||
|
@ -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}
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user