From 6ba1a4d395785c087333d3d21d3a5b2846397ca0 Mon Sep 17 00:00:00 2001 From: Ke Wu Date: Sun, 15 Jul 2012 22:54:50 -0700 Subject: [PATCH] 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 --- horizon/api/swift.py | 2 - horizon/dashboards/nova/containers/tables.py | 85 ++++++++++-------- horizon/dashboards/nova/containers/tests.py | 2 +- horizon/dashboards/nova/containers/views.py | 8 +- horizon/tables/__init__.py | 3 +- horizon/tables/actions.py | 74 ++++++++++++++-- horizon/tables/base.py | 92 +++++++++++++++++--- horizon/tables/views.py | 51 +++++++++++ 8 files changed, 256 insertions(+), 61 deletions(-) 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