diff --git a/horizon/tables/actions.py b/horizon/tables/actions.py index f7ac62a992..49a989aa3a 100644 --- a/horizon/tables/actions.py +++ b/horizon/tables/actions.py @@ -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.""" diff --git a/horizon/tables/base.py b/horizon/tables/base.py index f7f6cfa726..7673f8515d 100644 --- a/horizon/tables/base.py +++ b/horizon/tables/base.py @@ -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 diff --git a/horizon/tables/views.py b/horizon/tables/views.py index 0b4058c4ac..b82388097e 100644 --- a/horizon/tables/views.py +++ b/horizon/tables/views.py @@ -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 diff --git a/horizon/test/tests/tables.py b/horizon/test/tests/tables.py index df7a23a135..5e25bcc9d6 100644 --- a/horizon/test/tests/tables.py +++ b/horizon/test/tests/tables.py @@ -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, + ['']) + + # 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, + ['', + '', + '']) + 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, + ['', + '', + '']) + self.assertEqual(req.session.get(filter_value_param), 'up') + self.assertEqual(req.session.get(filter_field_param), 'status') + class FormsetTableTests(test.TestCase): diff --git a/openstack_dashboard/dashboards/admin/images/tables.py b/openstack_dashboard/dashboards/admin/images/tables.py index c9b4c51204..32a7fef0ee 100644 --- a/openstack_dashboard/dashboards/admin/images/tables.py +++ b/openstack_dashboard/dashboards/admin/images/tables.py @@ -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) diff --git a/openstack_dashboard/dashboards/admin/images/views.py b/openstack_dashboard/dashboards/admin/images/views.py index 6fdf04f56c..ee687fbaef 100644 --- a/openstack_dashboard/dashboards/admin/images/views.py +++ b/openstack_dashboard/dashboards/admin/images/views.py @@ -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' diff --git a/openstack_dashboard/dashboards/admin/instances/tables.py b/openstack_dashboard/dashboards/admin/instances/tables.py index c46376ce22..41997948bb 100644 --- a/openstack_dashboard/dashboards/admin/instances/tables.py +++ b/openstack_dashboard/dashboards/admin/instances/tables.py @@ -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 diff --git a/openstack_dashboard/dashboards/admin/instances/views.py b/openstack_dashboard/dashboards/admin/instances/views.py index 23e377ebff..35ffa8237e 100644 --- a/openstack_dashboard/dashboards/admin/instances/views.py +++ b/openstack_dashboard/dashboards/admin/instances/views.py @@ -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 diff --git a/openstack_dashboard/dashboards/project/instances/tables.py b/openstack_dashboard/dashboards/project/instances/tables.py index 4994fe1e64..e04fc46697 100644 --- a/openstack_dashboard/dashboards/project/instances/tables.py +++ b/openstack_dashboard/dashboards/project/instances/tables.py @@ -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): diff --git a/openstack_dashboard/dashboards/project/instances/views.py b/openstack_dashboard/dashboards/project/instances/views.py index efc1b04866..954e2e1d55 100644 --- a/openstack_dashboard/dashboards/project/instances/views.py +++ b/openstack_dashboard/dashboards/project/instances/views.py @@ -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 diff --git a/openstack_dashboard/static/dashboard/scss/horizon.scss b/openstack_dashboard/static/dashboard/scss/horizon.scss index 65cb4f1984..24c416da13 100644 --- a/openstack_dashboard/static/dashboard/scss/horizon.scss +++ b/openstack_dashboard/static/dashboard/scss/horizon.scss @@ -641,6 +641,9 @@ table form { input[type="text"] { padding-right: 26px; } + select { + width: auto; + } } td.no-transition {