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
commit b02422b521
11 changed files with 268 additions and 27 deletions

View File

@ -421,7 +421,20 @@ class FilterAction(BaseAction):
.. 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
@ -443,10 +456,16 @@ class FilterAction(BaseAction):
self.name = kwargs.get('name', self.name)
self.verbose_name = kwargs.get('verbose_name', _("Filter"))
self.filter_type = kwargs.get('filter_type', "query")
self.filter_choices = kwargs.get('filter_choices')
self.needs_preloading = kwargs.get('needs_preloading', False)
self.param_name = kwargs.get('param_name', 'q')
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):
"""Returns the full query parameter name for this action.
@ -486,6 +505,17 @@ class FilterAction(BaseAction):
raise NotImplementedError("The filter method has not been "
"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):
"""A filter action with fixed buttons."""

View File

@ -1137,22 +1137,40 @@ class DataTable(object):
and action.needs_preloading)
valid_method = (request_method == action.method)
if valid_method or needs_preloading:
filter_field = self.get_filter_field()
if self._meta.mixed_data_type:
self._filtered_data = action.data_type_filter(self,
self.data,
filter_string)
else:
elif not action.is_api_filter(filter_field):
self._filtered_data = action.filter(self,
self.data,
filter_string)
return self._filtered_data
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
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
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):
self._data_cache = {}
# Set up hash tables to store data points for each column

View File

@ -14,6 +14,7 @@
from collections import defaultdict
from django import shortcuts
from django.views import generic
from horizon.templatetags.horizon import has_permissions # noqa
@ -180,6 +181,7 @@ class DataTableView(MultiTableView):
def _get_data_dict(self):
if not self._data:
self.update_server_filter_action()
self._data = {self.table_class._meta.name: self.get_data()}
return self._data
@ -212,6 +214,63 @@ class DataTableView(MultiTableView):
context[self.context_object_name] = self.table
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):
"""A class-based generic view to handle DataTable with mixed data

View File

@ -1,4 +1,5 @@
# Copyright 2012 Nebula, Inc.
# Copyright 2014 IBM Corp.
#
# 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
@ -168,6 +169,20 @@ class MyFilterAction(tables.FilterAction):
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):
def allowed(self, *args):
return True
@ -221,6 +236,18 @@ class MyTable(tables.DataTable):
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 Meta:
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[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):
# Non ajax inline edit request should return None.
url = ('/my_url/?action=cell_update'
@ -1183,6 +1236,10 @@ class SingleTableView(table_views.DataTableView):
return TEST_DATA
class APIFilterTableView(SingleTableView):
table_class = MyServerFilterTable
class TableWithPermissions(tables.DataTable):
id = tables.Column('id')
@ -1248,6 +1305,26 @@ class DataTableViewTests(test.TestCase):
self.assertEqual(context['table_with_permissions_table'].__class__,
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):

View File

@ -55,6 +55,15 @@ class UpdateRow(tables.Row):
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):
name = tables.Column("name",
link="horizon:admin:images:detail",
@ -65,5 +74,6 @@ class AdminImagesTable(project_tables.ImagesTable):
row_class = UpdateRow
status_columns = ["status"]
verbose_name = _("Images")
table_actions = (AdminCreateImage, AdminDeleteImage)
table_actions = (AdminCreateImage, AdminDeleteImage,
AdminImageFilterAction)
row_actions = (AdminEditImage, ViewCustomProperties, AdminDeleteImage)

View File

@ -16,6 +16,8 @@
# License for the specific language governing permissions and limitations
# under the License.
import logging
from django.core.urlresolvers import reverse_lazy
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 \
import tables as project_tables
LOG = logging.getLogger(__name__)
class IndexView(tables.DataTableView):
table_class = project_tables.AdminImagesTable
@ -42,8 +46,7 @@ class IndexView(tables.DataTableView):
def get_data(self):
images = []
filters = {'is_public': None}
filters = self.get_filters()
prev_marker = self.request.GET.get(
project_tables.AdminImagesTable._meta.prev_pagination_param, None)
@ -73,6 +76,28 @@ class IndexView(tables.DataTableView):
exceptions.handle(self.request, msg)
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):
template_name = 'admin/images/create.html'

View File

@ -82,26 +82,28 @@ class AdminUpdateRow(project_tables.UpdateRow):
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_choices = (('project', _("Project")),
('name', _("Name"))
)
needs_preloading = True
filter_choices = (('project', _("Project"), False),
('host', _("Host ="), True),
('name', _("Name"), 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):
"""Server side search.
When filtering is supported in the api, then we will handle in view
"""
filter_field = table.request.POST.get('instances__filter__q_field')
self.filter_field = filter_field
self.filter_string = filter_string
filter_field = table.get_filter_field()
if filter_field == 'project' and filter_string:
return [inst for inst in instances
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

View File

@ -72,11 +72,11 @@ class AdminIndexView(tables.DataTableView):
instances = []
marker = self.request.GET.get(
project_tables.AdminInstancesTable._meta.pagination_param, None)
search_opts = self.get_filters({'marker': marker, 'paginate': True})
try:
instances, self._more = api.nova.server_list(
self.request,
search_opts={'marker': marker,
'paginate': True},
search_opts=search_opts,
all_tenants=True)
except Exception:
self._more = False
@ -126,6 +126,15 @@ class AdminIndexView(tables.DataTableView):
inst.tenant_name = getattr(tenant, "name", None)
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):
form_class = project_forms.LiveMigrateForm

View File

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

View File

@ -57,12 +57,12 @@ class IndexView(tables.DataTableView):
def get_data(self):
marker = self.request.GET.get(
project_tables.InstancesTable._meta.pagination_param, None)
search_opts = self.get_filters({'marker': marker, 'paginate': True})
# Gather our instances
try:
instances, self._more = api.nova.server_list(
self.request,
search_opts={'marker': marker,
'paginate': True})
search_opts=search_opts)
except Exception:
self._more = False
instances = []
@ -120,6 +120,15 @@ class IndexView(tables.DataTableView):
exceptions.handle(self.request, msg)
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):
workflow_class = project_workflows.LaunchInstance

View File

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