diff --git a/awx/api/filters.py b/awx/api/filters.py index 8bf40557ad..b667a74deb 100644 --- a/awx/api/filters.py +++ b/awx/api/filters.py @@ -114,6 +114,7 @@ class FieldLookupBackend(BaseFilterBackend): # below is (negate, field, value). and_filters = [] or_filters = [] + chain_filters = [] for key, values in request.QUERY_PARAMS.lists(): if key in self.RESERVED_NAMES: continue @@ -123,11 +124,18 @@ class FieldLookupBackend(BaseFilterBackend): if key.endswith('__int'): key = key[:-5] q_int = True - # Custom or__ filter prefix (or__ can precede not__). + + # Custom chain__ and or__ filters, mutually exclusive (both can + # precede not__). + q_chain = False q_or = False - if key.startswith('or__'): + if key.startswith('chain__'): + key = key[7:] + q_chain = True + elif key.startswith('or__'): key = key[4:] q_or = True + # Custom not__ filter prefix. q_not = False if key.startswith('not__'): @@ -139,13 +147,15 @@ class FieldLookupBackend(BaseFilterBackend): if q_int: value = int(value) value = self.value_to_python(queryset.model, key, value) - if q_or: + if q_chain: + chain_filters.append((q_not, key, value)) + elif q_or: or_filters.append((q_not, key, value)) else: and_filters.append((q_not, key, value)) # Now build Q objects for database query filter. - if and_filters or or_filters: + if and_filters or or_filters or chain_filters: args = [] for n, k, v in and_filters: if n: @@ -160,8 +170,14 @@ class FieldLookupBackend(BaseFilterBackend): else: q |= Q(**{k:v}) args.append(q) + for n,k,v in chain_filters: + if n: + q = ~Q(**{k:v}) + else: + q = Q(**{k:v}) + queryset = queryset.filter(q) queryset = queryset.filter(*args) - return queryset + return queryset.distinct() except (FieldError, FieldDoesNotExist, ValueError), e: raise ParseError(e.args[0]) except ValidationError, e: diff --git a/awx/api/generics.py b/awx/api/generics.py index e48c6aa6fa..218582a72a 100644 --- a/awx/api/generics.py +++ b/awx/api/generics.py @@ -106,6 +106,7 @@ class APIView(views.APIView): 'docstring': type(self).__doc__ or '', 'new_in_13': getattr(self, 'new_in_13', False), 'new_in_14': getattr(self, 'new_in_14', False), + 'new_in_15': getattr(self, 'new_in_15', False), } def get_description(self, html=False): diff --git a/awx/api/templates/api/_list_common.md b/awx/api/templates/api/_list_common.md index 1118a31f74..3a9184bbd9 100644 --- a/awx/api/templates/api/_list_common.md +++ b/awx/api/templates/api/_list_common.md @@ -83,6 +83,20 @@ with `or__`: ?or__field=value&or__field=othervalue ?or__not__field=value&or__field=othervalue +(_New in AWX 1.5_) The default AND filtering applies all filters simultaneously +to each related object being filtered across database relationships. The chain +filter instead applies filters separately for each related object. To use, +prefix the query string parameter with `chain__`: + + ?chain__related__field=value&chain__related__field2=othervalue + ?chain__not__related__field=value&chain__related__field2=othervalue + +If the first query above were written as +`?related__field=value&related__field2=othervalue`, it would return only the +primary objects where the *same* related object satisfied both conditions. As +written using the chain filter, it would return the intersection of primary +objects matching each condition. + Field lookups may also be used for more advanced queries, by appending the lookup to the field name: diff --git a/awx/api/templates/api/_new_in_awx.md b/awx/api/templates/api/_new_in_awx.md index 757ee26e86..a49e4bfd2b 100644 --- a/awx/api/templates/api/_new_in_awx.md +++ b/awx/api/templates/api/_new_in_awx.md @@ -1,2 +1,3 @@ {% if new_in_13 %}> _New in AWX 1.3_{% endif %} {% if new_in_14 %}> _New in AWX 1.4_{% endif %} +{% if new_in_15 %}> _New in AWX 1.5_{% endif %} diff --git a/awx/main/tests/users.py b/awx/main/tests/users.py index 3523c6b12d..e646d00ed4 100644 --- a/awx/main/tests/users.py +++ b/awx/main/tests/users.py @@ -28,10 +28,11 @@ class UsersTest(BaseTest): def setUp(self): super(UsersTest, self).setUp() self.setup_users() - self.organizations = self.make_organizations(self.super_django_user, 1) + self.organizations = self.make_organizations(self.super_django_user, 2) self.organizations[0].admins.add(self.normal_django_user) self.organizations[0].users.add(self.other_django_user) self.organizations[0].users.add(self.normal_django_user) + self.organizations[1].users.add(self.other_django_user) def test_only_super_user_or_org_admin_can_add_users(self): url = reverse('api:user_list') @@ -560,6 +561,19 @@ class UsersTest(BaseTest): self.assertTrue(qs.count()) self.check_get_list(url, self.super_django_user, qs) + # Verify difference between normal AND filter vs. filtering with + # chain__ prefix. + url = '%s?organizations__name__startswith=org0&organizations__name__startswith=org1' % base_url + qs = base_qs.filter(Q(organizations__name__startswith='org0'), + Q(organizations__name__startswith='org1')) + self.assertFalse(qs.count()) + self.check_get_list(url, self.super_django_user, qs) + url = '%s?chain__organizations__name__startswith=org0&chain__organizations__name__startswith=org1' % base_url + qs = base_qs.filter(organizations__name__startswith='org0') + qs = qs.filter(organizations__name__startswith='org1') + self.assertTrue(qs.count()) + self.check_get_list(url, self.super_django_user, qs) + # Filter by related organization not present. url = '%s?organizations=None' % base_url qs = base_qs.filter(organizations=None)