From 6b64ef8f64d6bea4cd21acc3b324a2d63ab31b27 Mon Sep 17 00:00:00 2001 From: Jared Tabor Date: Tue, 3 Jul 2018 18:28:53 -0700 Subject: [PATCH 1/3] Changes a related search w/ two search params. Changes from using a pattern like "search=A&search=B" to "search=A,B". --- .../shared/smart-search/queryset.service.js | 51 ++++++++++--------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/awx/ui/client/src/shared/smart-search/queryset.service.js b/awx/ui/client/src/shared/smart-search/queryset.service.js index 5d860a55fa..d55f4e27f0 100644 --- a/awx/ui/client/src/shared/smart-search/queryset.service.js +++ b/awx/ui/client/src/shared/smart-search/queryset.service.js @@ -44,38 +44,39 @@ function QuerysetService ($q, Rest, ProcessErrors, $rootScope, Wait, DjangoSearc replaceEncodedTokens(value) { return decodeURIComponent(value).replace(/"|'/g, ""); }, - encodeTerms (values, key) { + encodeTerms(value, key){ key = this.replaceDefaultFlags(key); - - if (!Array.isArray(values)) { - values = [values]; - } - - return values - .map(value => { - value = this.replaceDefaultFlags(value); - value = this.replaceEncodedTokens(value); - return [key, value]; + value = this.replaceDefaultFlags(value); + var that = this; + if (Array.isArray(value)){ + value = _.uniq(_.flattenDeep(value)); + let concated = ''; + angular.forEach(value, function(item){ + if(item && typeof item === 'string') { + item = that.replaceEncodedTokens(item); + } + concated += `${key}=${item}&`; }); + return concated; + } + else { + if(value && typeof value === 'string') { + value = this.replaceEncodedTokens(value); + } + + return `${key}=${value}&`; + } }, // encodes ui-router params from {operand__key__comparator: value} pairs to API-consumable URL encodeQueryset(params) { - if (typeof params !== 'object') { - return ''; - } + let queryset; + queryset = _.reduce(params, (result, value, key) => { + return result + this.encodeTerms(value, key); + }, ''); + queryset = queryset.substring(0, queryset.length - 1); + return angular.isObject(params) ? `?${queryset}` : ''; - return _.reduce(params, (result, value, key) => { - if (result !== '?') { - result += '&'; - } - - const encodedTermString = this.encodeTerms(value, key) - .map(([key, value]) => `${key}=${value}`) - .join('&'); - - return result += encodedTermString; - }, '?'); }, // like encodeQueryset, but return an actual unstringified API-consumable http param object encodeQuerysetObject(params) { From b191f6cfc30225709fdfe2b39640248b69c38590 Mon Sep 17 00:00:00 2001 From: Yunfan Zhang Date: Wed, 1 Aug 2018 12:20:27 -0400 Subject: [PATCH 2/3] Add AND filter to related search. Signed-off-by: Yunfan Zhang --- awx/api/filters.py | 26 ++++++--- .../functional/api/test_search_filter.py | 54 +++++++++++++++++++ .../shared/smart-search/queryset.service.js | 5 +- 3 files changed, 77 insertions(+), 8 deletions(-) create mode 100644 awx/main/tests/functional/api/test_search_filter.py diff --git a/awx/api/filters.py b/awx/api/filters.py index 81290c377b..8f883191f3 100644 --- a/awx/api/filters.py +++ b/awx/api/filters.py @@ -4,6 +4,7 @@ # Python import re import json +from functools import reduce # Django from django.core.exceptions import FieldError, ValidationError @@ -238,7 +239,11 @@ class FieldLookupBackend(BaseFilterBackend): or_filters = [] chain_filters = [] role_filters = [] - search_filters = [] + search_filters = {} + # Can only have two values: 'AND', 'OR' + # If 'AND' is used, an iterm must satisfy all condition to show up in the results. + # If 'OR' is used, an item just need to satisfy one condition to appear in results. + search_filter_relation = 'OR' for key, values in request.query_params.lists(): if key in self.RESERVED_NAMES: continue @@ -262,11 +267,13 @@ class FieldLookupBackend(BaseFilterBackend): # Search across related objects. if key.endswith('__search'): + if values and ',' in values[0]: + search_filter_relation = 'AND' + values = reduce(lambda list1, list2: list1 + list2, [i.split(',') for i in values]) for value in values: search_value, new_keys = self.value_to_python(queryset.model, key, force_text(value)) assert isinstance(new_keys, list) - for new_key in new_keys: - search_filters.append((new_key, search_value)) + search_filters[search_value] = new_keys continue # Custom chain__ and or__ filters, mutually exclusive (both can @@ -355,11 +362,18 @@ class FieldLookupBackend(BaseFilterBackend): else: q |= Q(**{k:v}) args.append(q) - if search_filters: + if search_filters and search_filter_relation == 'OR': q = Q() - for k,v in search_filters: - q |= Q(**{k:v}) + for term, constrains in search_filters.iteritems(): + for constrain in constrains: + q |= Q(**{constrain: term}) args.append(q) + elif search_filters and search_filter_relation == 'AND': + for term, constrains in search_filters.iteritems(): + q_chain = Q() + for constrain in constrains: + q_chain |= Q(**{constrain: term}) + queryset = queryset.filter(q_chain) for n,k,v in chain_filters: if n: q = ~Q(**{k:v}) diff --git a/awx/main/tests/functional/api/test_search_filter.py b/awx/main/tests/functional/api/test_search_filter.py new file mode 100644 index 0000000000..4e67ed834a --- /dev/null +++ b/awx/main/tests/functional/api/test_search_filter.py @@ -0,0 +1,54 @@ +# Python +import pytest +import json + +# Django Rest Framework +from rest_framework.test import APIRequestFactory + +# AWX +from awx.api.views import HostList +from awx.main.models import Host, Group, Inventory +from awx.api.versioning import reverse + + +@pytest.mark.django_db +class TestSearchFilter: + def test_related_research_filter_relation(self, admin): + inv = Inventory.objects.create(name="inv") + group1 = Group.objects.create(name="g1", inventory=inv) + group2 = Group.objects.create(name="g2", inventory=inv) + host1 = Host.objects.create(name="host1", inventory=inv) + host2 = Host.objects.create(name="host2", inventory=inv) + host3 = Host.objects.create(name="host3", inventory=inv) + host1.groups.add(group1) + host2.groups.add(group1) + host2.groups.add(group2) + host3.groups.add(group2) + host1.save() + host2.save() + host3.save() + # Login the client + factory = APIRequestFactory() + # Actually test the endpoint. + host_list_url = reverse('api:host_list') + + # Test if the OR releation works. + request = factory.get(host_list_url, data={'groups__search': ['g1', 'g2']}) + request.user = admin + response = HostList.as_view()(request) + response.render() + result = json.loads(response.content) + assert result['count'] == 3 + expected_hosts = ['host1', 'host2', 'host3'] + for i in result['results']: + expected_hosts.remove(i['name']) + assert not expected_hosts + + # Test if the AND relation works. + request = factory.get(host_list_url, data={'groups__search': ['g1,g2']}) + request.user = admin + response = HostList.as_view()(request) + response.render() + result = json.loads(response.content) + assert result['count'] == 1 + assert result['results'][0]['name'] == 'host2' diff --git a/awx/ui/client/src/shared/smart-search/queryset.service.js b/awx/ui/client/src/shared/smart-search/queryset.service.js index d55f4e27f0..392ead279b 100644 --- a/awx/ui/client/src/shared/smart-search/queryset.service.js +++ b/awx/ui/client/src/shared/smart-search/queryset.service.js @@ -80,15 +80,16 @@ function QuerysetService ($q, Rest, ProcessErrors, $rootScope, Wait, DjangoSearc }, // like encodeQueryset, but return an actual unstringified API-consumable http param object encodeQuerysetObject(params) { + console.log(params); return _.reduce(params, (obj, value, key) => { const encodedTerms = this.encodeTerms(value, key); - + console.log(encodedTerms); for (let encodedIndex in encodedTerms) { const [encodedKey, encodedValue] = encodedTerms[encodedIndex]; obj[encodedKey] = obj[encodedKey] || []; obj[encodedKey].push(encodedValue); } - + console.log(obj); return obj; }, {}); }, From f6a960d8f49bbd3aa0431f16d28daf540f8a000d Mon Sep 17 00:00:00 2001 From: Jared Tabor Date: Wed, 1 Aug 2018 19:40:53 -0700 Subject: [PATCH 3/3] Changes the encodeQuerysetObject function for the job details search widget With this change, the stdout search will perform a search like search=A,B instead of search=A&search=B --- awx/ui/client/features/output/index.js | 1 + .../src/shared/smart-search/queryset.service.js | 17 ++++++++--------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/awx/ui/client/features/output/index.js b/awx/ui/client/features/output/index.js index 5434ab29d9..70ce3f2572 100644 --- a/awx/ui/client/features/output/index.js +++ b/awx/ui/client/features/output/index.js @@ -92,6 +92,7 @@ function resolveResource ( if (job_event_search) { // eslint-disable-line camelcase const query = qs.encodeQuerysetObject(qs.decodeArr(job_event_search)); + Object.assign(config.params, query); } diff --git a/awx/ui/client/src/shared/smart-search/queryset.service.js b/awx/ui/client/src/shared/smart-search/queryset.service.js index 392ead279b..edfa33dcdf 100644 --- a/awx/ui/client/src/shared/smart-search/queryset.service.js +++ b/awx/ui/client/src/shared/smart-search/queryset.service.js @@ -80,16 +80,15 @@ function QuerysetService ($q, Rest, ProcessErrors, $rootScope, Wait, DjangoSearc }, // like encodeQueryset, but return an actual unstringified API-consumable http param object encodeQuerysetObject(params) { - console.log(params); return _.reduce(params, (obj, value, key) => { - const encodedTerms = this.encodeTerms(value, key); - console.log(encodedTerms); - for (let encodedIndex in encodedTerms) { - const [encodedKey, encodedValue] = encodedTerms[encodedIndex]; - obj[encodedKey] = obj[encodedKey] || []; - obj[encodedKey].push(encodedValue); - } - console.log(obj); + const encodedKey = this.replaceDefaultFlags(key); + const values = Array.isArray(value) ? value : [value]; + + obj[encodedKey] = values + .map(value => this.replaceDefaultFlags(value)) + .map(value => this.replaceEncodedTokens(value)) + .join(','); + return obj; }, {}); },