Merge "Add API filtering to paged tables"

This commit is contained in:
Jenkins
2014-08-12 04:05:23 +00:00
committed by Gerrit Code Review
11 changed files with 268 additions and 27 deletions

View File

@@ -421,7 +421,20 @@ class FilterAction(BaseAction):
.. attribute: filter_type .. attribute: filter_type
A string representing the type of this filter. Default: ``"query"``. A string representing the type of this filter. If this is set to
``"server"`` then ``filter_choices`` must also be provided.
Default: ``"query"``.
.. attribute: filter_choices
Required for server type filters. A tuple of tuples representing the
filter options. Tuple composition should evaluate to (string, string,
boolean), representing the filter parameter, display value, and whether
or not it should be applied to the API request as an API query
attribute. API type filters do not need to be accounted for in the
filter method since the API will do the filtering. However, server
type filters in general will need to be performed in the filter method.
By default this attribute is not provided.
.. attribute: needs_preloading .. attribute: needs_preloading
@@ -443,10 +456,16 @@ class FilterAction(BaseAction):
self.name = kwargs.get('name', self.name) self.name = kwargs.get('name', self.name)
self.verbose_name = kwargs.get('verbose_name', _("Filter")) self.verbose_name = kwargs.get('verbose_name', _("Filter"))
self.filter_type = kwargs.get('filter_type', "query") self.filter_type = kwargs.get('filter_type', "query")
self.filter_choices = kwargs.get('filter_choices')
self.needs_preloading = kwargs.get('needs_preloading', False) self.needs_preloading = kwargs.get('needs_preloading', False)
self.param_name = kwargs.get('param_name', 'q') self.param_name = kwargs.get('param_name', 'q')
self.icon = "search" self.icon = "search"
if self.filter_type == 'server' and self.filter_choices is None:
raise NotImplementedError('A FilterAction object with the '
'filter_type attribute set to "server" must also have a '
'filter_choices attribute.')
def get_param_name(self): def get_param_name(self):
"""Returns the full query parameter name for this action. """Returns the full query parameter name for this action.
@@ -486,6 +505,17 @@ class FilterAction(BaseAction):
raise NotImplementedError("The filter method has not been " raise NotImplementedError("The filter method has not been "
"implemented by %s." % self.__class__) "implemented by %s." % self.__class__)
def is_api_filter(self, filter_field):
"""Determine if the given filter field should be used as an
API filter.
"""
if self.filter_type == 'server':
for choice in self.filter_choices:
if (choice[0] == filter_field and len(choice) > 2 and
choice[2] is True):
return True
return False
class FixedFilterAction(FilterAction): class FixedFilterAction(FilterAction):
"""A filter action with fixed buttons.""" """A filter action with fixed buttons."""

View File

@@ -1137,22 +1137,40 @@ class DataTable(object):
and action.needs_preloading) and action.needs_preloading)
valid_method = (request_method == action.method) valid_method = (request_method == action.method)
if valid_method or needs_preloading: if valid_method or needs_preloading:
filter_field = self.get_filter_field()
if self._meta.mixed_data_type: if self._meta.mixed_data_type:
self._filtered_data = action.data_type_filter(self, self._filtered_data = action.data_type_filter(self,
self.data, self.data,
filter_string) filter_string)
else: elif not action.is_api_filter(filter_field):
self._filtered_data = action.filter(self, self._filtered_data = action.filter(self,
self.data, self.data,
filter_string) filter_string)
return self._filtered_data return self._filtered_data
def get_filter_string(self): def get_filter_string(self):
"""Get the filter string value. For 'server' type filters this is
saved in the session so that it gets persisted across table loads.
For other filter types this is obtained from the POST dict.
"""
filter_action = self._meta._filter_action filter_action = self._meta._filter_action
param_name = filter_action.get_param_name() param_name = filter_action.get_param_name()
filter_string = self.request.POST.get(param_name, '') filter_string = ''
if filter_action.filter_type == 'server':
filter_string = self.request.session.get(param_name, '')
else:
filter_string = self.request.POST.get(param_name, '')
return filter_string return filter_string
def get_filter_field(self):
"""Get the filter field value used for 'server' type filters. This
is the value from the filter action's list of filter choices.
"""
filter_action = self._meta._filter_action
param_name = '%s_field' % filter_action.get_param_name()
filter_field = self.request.session.get(param_name, '')
return filter_field
def _populate_data_cache(self): def _populate_data_cache(self):
self._data_cache = {} self._data_cache = {}
# Set up hash tables to store data points for each column # Set up hash tables to store data points for each column

View File

@@ -14,6 +14,7 @@
from collections import defaultdict from collections import defaultdict
from django import shortcuts
from django.views import generic from django.views import generic
from horizon.templatetags.horizon import has_permissions # noqa from horizon.templatetags.horizon import has_permissions # noqa
@@ -180,6 +181,7 @@ class DataTableView(MultiTableView):
def _get_data_dict(self): def _get_data_dict(self):
if not self._data: if not self._data:
self.update_server_filter_action()
self._data = {self.table_class._meta.name: self.get_data()} self._data = {self.table_class._meta.name: self.get_data()}
return self._data return self._data
@@ -212,6 +214,63 @@ class DataTableView(MultiTableView):
context[self.context_object_name] = self.table context[self.context_object_name] = self.table
return context return context
def post(self, request, *args, **kwargs):
# If the server side table filter changed then go back to the first
# page of data. Otherwise GET and POST handling are the same.
if self.handle_server_filter(request):
return shortcuts.redirect(self.get_table().get_absolute_url())
return self.get(request, *args, **kwargs)
def get_server_filter_info(self, request):
filter_action = self.get_table()._meta._filter_action
if filter_action is None or filter_action.filter_type != 'server':
return None
param_name = filter_action.get_param_name()
filter_string = request.POST.get(param_name)
filter_string_session = request.session.get(param_name)
changed = (filter_string is not None and
filter_string != filter_string_session)
if filter_string is None and filter_string_session is not None:
filter_string = filter_string_session
filter_field_param = param_name + '_field'
filter_field = request.POST.get(filter_field_param)
filter_field_session = request.session.get(filter_field_param)
if filter_field is None and filter_field_session is not None:
filter_field = filter_field_session
filter_info = {
'action': filter_action,
'value_param': param_name,
'value': filter_string,
'field_param': filter_field_param,
'field': filter_field,
'changed': changed
}
return filter_info
def handle_server_filter(self, request):
"""Update the table server filter information in the session and
determine if the filter has been changed.
"""
filter_info = self.get_server_filter_info(request)
if filter_info is None:
return False
request.session[filter_info['value_param']] = filter_info['value']
if filter_info['field_param']:
request.session[filter_info['field_param']] = filter_info['field']
return filter_info['changed']
def update_server_filter_action(self):
"""Update the table server side filter action based on the current
filter. The filter info may be stored in the session and this will
restore it.
"""
filter_info = self.get_server_filter_info(self.request)
if filter_info is not None:
action = filter_info['action']
setattr(action, 'filter_string', filter_info['value'])
if filter_info['field_param']:
setattr(action, 'filter_field', filter_info['field'])
class MixedDataTableView(DataTableView): class MixedDataTableView(DataTableView):
"""A class-based generic view to handle DataTable with mixed data """A class-based generic view to handle DataTable with mixed data

View File

@@ -1,4 +1,5 @@
# Copyright 2012 Nebula, Inc. # Copyright 2012 Nebula, Inc.
# Copyright 2014 IBM Corp.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); you may # Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain # not use this file except in compliance with the License. You may obtain
@@ -168,6 +169,20 @@ class MyFilterAction(tables.FilterAction):
return filter(comp, objs) return filter(comp, objs)
class MyServerFilterAction(tables.FilterAction):
filter_type = 'server'
filter_choices = (('name', 'Name', False),
('status', 'Status', True))
needs_preloading = True
def filter(self, table, items, filter_string):
filter_field = table.get_filter_field()
if filter_field == 'name' and filter_string:
return [item for item in items
if filter_string in item.name]
return items
class MyUpdateAction(tables.UpdateAction): class MyUpdateAction(tables.UpdateAction):
def allowed(self, *args): def allowed(self, *args):
return True return True
@@ -221,6 +236,18 @@ class MyTable(tables.DataTable):
row_actions = (MyAction, MyLinkAction, MyBatchAction, MyToggleAction) row_actions = (MyAction, MyLinkAction, MyBatchAction, MyToggleAction)
class MyServerFilterTable(MyTable):
class Meta:
name = "my_table"
verbose_name = "My Table"
status_columns = ["status"]
columns = ('id', 'name', 'value', 'optional', 'status')
row_class = MyRow
column_class = MyColumn
table_actions = (MyServerFilterAction, MyAction, MyBatchAction)
row_actions = (MyAction, MyLinkAction, MyBatchAction, MyToggleAction)
class MyTableSelectable(MyTable): class MyTableSelectable(MyTable):
class Meta: class Meta:
name = "my_table" name = "my_table"
@@ -904,6 +931,32 @@ class DataTableTests(test.TestCase):
self.assertEqual(unicode(row_actions[0].verbose_name), "Delete Me") self.assertEqual(unicode(row_actions[0].verbose_name), "Delete Me")
self.assertEqual(unicode(row_actions[1].verbose_name), "Log In") self.assertEqual(unicode(row_actions[1].verbose_name), "Log In")
def test_server_filtering(self):
filter_value_param = "my_table__filter__q"
filter_field_param = '%s_field' % filter_value_param
# Server Filtering
req = self.factory.post('/my_url/')
req.session[filter_value_param] = '2'
req.session[filter_field_param] = 'name'
self.table = MyServerFilterTable(req, TEST_DATA)
handled = self.table.maybe_handle()
self.assertIsNone(handled)
self.assertQuerysetEqual(self.table.filtered_data,
['<FakeObject: object_2>'])
# Ensure API filtering does not filter on server, e.g. no filter here
req = self.factory.post('/my_url/')
req.session[filter_value_param] = 'up'
req.session[filter_field_param] = 'status'
self.table = MyServerFilterTable(req, TEST_DATA)
handled = self.table.maybe_handle()
self.assertIsNone(handled)
self.assertQuerysetEqual(self.table.filtered_data,
['<FakeObject: object_1>',
'<FakeObject: object_2>',
'<FakeObject: object_3>'])
def test_inline_edit_update_action_get_non_ajax(self): def test_inline_edit_update_action_get_non_ajax(self):
# Non ajax inline edit request should return None. # Non ajax inline edit request should return None.
url = ('/my_url/?action=cell_update' url = ('/my_url/?action=cell_update'
@@ -1183,6 +1236,10 @@ class SingleTableView(table_views.DataTableView):
return TEST_DATA return TEST_DATA
class APIFilterTableView(SingleTableView):
table_class = MyServerFilterTable
class TableWithPermissions(tables.DataTable): class TableWithPermissions(tables.DataTable):
id = tables.Column('id') id = tables.Column('id')
@@ -1248,6 +1305,26 @@ class DataTableViewTests(test.TestCase):
self.assertEqual(context['table_with_permissions_table'].__class__, self.assertEqual(context['table_with_permissions_table'].__class__,
TableWithPermissions) TableWithPermissions)
def test_api_filter_table_view(self):
filter_value_param = "my_table__filter__q"
filter_field_param = '%s_field' % filter_value_param
req = self.factory.post('/my_url/', {filter_value_param: 'up',
filter_field_param: 'status'})
req.user = self.user
view = APIFilterTableView()
view.request = req
view.kwargs = {}
view.handle_server_filter(req)
context = view.get_context_data()
self.assertEqual(context['table'].__class__, MyServerFilterTable)
data = view.get_data()
self.assertQuerysetEqual(data,
['<FakeObject: object_1>',
'<FakeObject: object_2>',
'<FakeObject: object_3>'])
self.assertEqual(req.session.get(filter_value_param), 'up')
self.assertEqual(req.session.get(filter_field_param), 'status')
class FormsetTableTests(test.TestCase): class FormsetTableTests(test.TestCase):

View File

@@ -55,6 +55,15 @@ class UpdateRow(tables.Row):
return image return image
class AdminImageFilterAction(tables.FilterAction):
filter_type = "server"
filter_choices = (('name', _("Image Name ="), True),
('status', _('Status ='), True),
('disk_format', _('Format ='), True),
('size_min', _('Min. Size (MB)'), True),
('size_max', _('Max. Size (MB)'), True))
class AdminImagesTable(project_tables.ImagesTable): class AdminImagesTable(project_tables.ImagesTable):
name = tables.Column("name", name = tables.Column("name",
link="horizon:admin:images:detail", link="horizon:admin:images:detail",
@@ -65,5 +74,6 @@ class AdminImagesTable(project_tables.ImagesTable):
row_class = UpdateRow row_class = UpdateRow
status_columns = ["status"] status_columns = ["status"]
verbose_name = _("Images") verbose_name = _("Images")
table_actions = (AdminCreateImage, AdminDeleteImage) table_actions = (AdminCreateImage, AdminDeleteImage,
AdminImageFilterAction)
row_actions = (AdminEditImage, ViewCustomProperties, AdminDeleteImage) row_actions = (AdminEditImage, ViewCustomProperties, AdminDeleteImage)

View File

@@ -16,6 +16,8 @@
# License for the specific language governing permissions and limitations # License for the specific language governing permissions and limitations
# under the License. # under the License.
import logging
from django.core.urlresolvers import reverse_lazy from django.core.urlresolvers import reverse_lazy
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
@@ -29,6 +31,8 @@ from openstack_dashboard.dashboards.admin.images import forms
from openstack_dashboard.dashboards.admin.images \ from openstack_dashboard.dashboards.admin.images \
import tables as project_tables import tables as project_tables
LOG = logging.getLogger(__name__)
class IndexView(tables.DataTableView): class IndexView(tables.DataTableView):
table_class = project_tables.AdminImagesTable table_class = project_tables.AdminImagesTable
@@ -42,8 +46,7 @@ class IndexView(tables.DataTableView):
def get_data(self): def get_data(self):
images = [] images = []
filters = {'is_public': None} filters = self.get_filters()
prev_marker = self.request.GET.get( prev_marker = self.request.GET.get(
project_tables.AdminImagesTable._meta.prev_pagination_param, None) project_tables.AdminImagesTable._meta.prev_pagination_param, None)
@@ -73,6 +76,28 @@ class IndexView(tables.DataTableView):
exceptions.handle(self.request, msg) exceptions.handle(self.request, msg)
return images return images
def get_filters(self):
filters = {'is_public': None}
filter_field = self.table.get_filter_field()
filter_string = self.table.get_filter_string()
filter_action = self.table._meta._filter_action
if filter_field and filter_string and (
filter_action.is_api_filter(filter_field)):
if filter_field in ['size_min', 'size_max']:
invalid_msg = ('API query is not valid and is ignored: %s=%s'
% (filter_field, filter_string))
try:
filter_string = long(float(filter_string) * (1024 ** 2))
if filter_string >= 0:
filters[filter_field] = filter_string
else:
LOG.warning(invalid_msg)
except ValueError:
LOG.warning(invalid_msg)
else:
filters[filter_field] = filter_string
return filters
class CreateView(views.CreateView): class CreateView(views.CreateView):
template_name = 'admin/images/create.html' template_name = 'admin/images/create.html'

View File

@@ -82,26 +82,28 @@ class AdminUpdateRow(project_tables.UpdateRow):
class AdminInstanceFilterAction(tables.FilterAction): class AdminInstanceFilterAction(tables.FilterAction):
# Change default name of 'filter' to distinguish this one from the
# project instances table filter, since this is used as part of the
# session property used for persisting the filter.
name = "filter_admin_instances"
filter_type = "server" filter_type = "server"
filter_choices = (('project', _("Project")), filter_choices = (('project', _("Project"), False),
('name', _("Name")) ('host', _("Host ="), True),
) ('name', _("Name"), True),
needs_preloading = True ('ip', _("IPv4 Address ="), True),
('ip6', _("IPv6 Address ="), True),
('status', _("Status ="), True),
('image', _("Image ID ="), True),
('flavor', _("Flavor ID ="), True))
def filter(self, table, instances, filter_string): def filter(self, table, instances, filter_string):
"""Server side search. """Server side search.
When filtering is supported in the api, then we will handle in view When filtering is supported in the api, then we will handle in view
""" """
filter_field = table.request.POST.get('instances__filter__q_field') filter_field = table.get_filter_field()
self.filter_field = filter_field
self.filter_string = filter_string
if filter_field == 'project' and filter_string: if filter_field == 'project' and filter_string:
return [inst for inst in instances return [inst for inst in instances
if inst.tenant_name == filter_string] if inst.tenant_name == filter_string]
if filter_field == 'name' and filter_string:
q = filter_string.lower()
return [instance for instance in instances
if q in instance.name.lower()]
return instances return instances

View File

@@ -72,11 +72,11 @@ class AdminIndexView(tables.DataTableView):
instances = [] instances = []
marker = self.request.GET.get( marker = self.request.GET.get(
project_tables.AdminInstancesTable._meta.pagination_param, None) project_tables.AdminInstancesTable._meta.pagination_param, None)
search_opts = self.get_filters({'marker': marker, 'paginate': True})
try: try:
instances, self._more = api.nova.server_list( instances, self._more = api.nova.server_list(
self.request, self.request,
search_opts={'marker': marker, search_opts=search_opts,
'paginate': True},
all_tenants=True) all_tenants=True)
except Exception: except Exception:
self._more = False self._more = False
@@ -126,6 +126,15 @@ class AdminIndexView(tables.DataTableView):
inst.tenant_name = getattr(tenant, "name", None) inst.tenant_name = getattr(tenant, "name", None)
return instances return instances
def get_filters(self, filters):
filter_field = self.table.get_filter_field()
filter_action = self.table._meta._filter_action
if filter_action.is_api_filter(filter_field):
filter_string = self.table.get_filter_string()
if filter_field and filter_string:
filters[filter_field] = filter_string
return filters
class LiveMigrateView(forms.ModalFormView): class LiveMigrateView(forms.ModalFormView):
form_class = project_forms.LiveMigrateForm form_class = project_forms.LiveMigrateForm

View File

@@ -781,12 +781,11 @@ POWER_DISPLAY_CHOICES = (
class InstancesFilterAction(tables.FilterAction): class InstancesFilterAction(tables.FilterAction):
filter_type = "server"
def filter(self, table, instances, filter_string): filter_choices = (('name', _("Instance Name"), True),
"""Naive case-insensitive search.""" ('status', _("Status ="), True),
q = filter_string.lower() ('image', _("Image ID ="), True),
return [instance for instance in instances ('flavor', _("Flavor ID ="), True))
if q in instance.name.lower()]
class InstancesTable(tables.DataTable): class InstancesTable(tables.DataTable):

View File

@@ -57,12 +57,12 @@ class IndexView(tables.DataTableView):
def get_data(self): def get_data(self):
marker = self.request.GET.get( marker = self.request.GET.get(
project_tables.InstancesTable._meta.pagination_param, None) project_tables.InstancesTable._meta.pagination_param, None)
search_opts = self.get_filters({'marker': marker, 'paginate': True})
# Gather our instances # Gather our instances
try: try:
instances, self._more = api.nova.server_list( instances, self._more = api.nova.server_list(
self.request, self.request,
search_opts={'marker': marker, search_opts=search_opts)
'paginate': True})
except Exception: except Exception:
self._more = False self._more = False
instances = [] instances = []
@@ -120,6 +120,15 @@ class IndexView(tables.DataTableView):
exceptions.handle(self.request, msg) exceptions.handle(self.request, msg)
return instances return instances
def get_filters(self, filters):
filter_field = self.table.get_filter_field()
filter_action = self.table._meta._filter_action
if filter_action.is_api_filter(filter_field):
filter_string = self.table.get_filter_string()
if filter_field and filter_string:
filters[filter_field] = filter_string
return filters
class LaunchInstanceView(workflows.WorkflowView): class LaunchInstanceView(workflows.WorkflowView):
workflow_class = project_workflows.LaunchInstance workflow_class = project_workflows.LaunchInstance

View File

@@ -641,6 +641,9 @@ table form {
input[type="text"] { input[type="text"] {
padding-right: 26px; padding-right: 26px;
} }
select {
width: auto;
}
} }
td.no-transition { td.no-transition {