Added API support for OR queries and searching text fields.

This commit is contained in:
Chris Church 2013-10-26 22:44:25 -04:00
parent 3760ce6641
commit d410f57e08
6 changed files with 173 additions and 33 deletions

View File

@ -168,6 +168,8 @@ class GenericAPIView(generics.GenericAPIView, APIView):
actions['GET'] = serializer.metadata()
if actions:
ret['actions'] = actions
if getattr(self, 'search_fields', None):
ret['search_fields'] = self.search_fields
return ret
class ListAPIView(generics.ListAPIView, GenericAPIView):
@ -188,6 +190,15 @@ class ListAPIView(generics.ListAPIView, GenericAPIView):
})
return d
@property
def search_fields(self):
fields = []
for field in self.model._meta.fields:
if field.name in ('username', 'first_name', 'last_name', 'email',
'name', 'description', 'email'):
fields.append(field.name)
return fields
class ListCreateAPIView(ListAPIView, generics.ListCreateAPIView):
# Base class for a list view that allows creating new objects.

View File

@ -33,7 +33,8 @@ class FieldLookupBackend(BaseFilterBackend):
Filter using field lookups provided via query string parameters.
'''
RESERVED_NAMES = ('page', 'page_size', 'format', 'order', 'order_by')
RESERVED_NAMES = ('page', 'page_size', 'format', 'order', 'order_by',
'search')
SUPPORTED_LOOKUPS = ('exact', 'iexact', 'contains', 'icontains',
'startswith', 'istartswith', 'endswith', 'iendswith',
@ -109,33 +110,57 @@ class FieldLookupBackend(BaseFilterBackend):
def filter_queryset(self, request, queryset, view):
try:
# Apply filters and excludes specified via QUERY_PARAMS.
filters = {}
excludes = {}
for key, value in request.QUERY_PARAMS.items():
# Apply filters specified via QUERY_PARAMS. Each entry in the lists
# below is (negate, field, value).
and_filters = []
or_filters = []
for key, values in request.QUERY_PARAMS.lists():
if key in self.RESERVED_NAMES:
continue
# Custom __int filter suffix (internal use only).
q_int = False
if key.endswith('__int'):
key = key[:-5]
value = int(value)
q_int = True
# Custom or__ filter prefix (or__ can precede not__).
q_or = False
if key.startswith('or__'):
key = key[4:]
q_or = True
# Custom not__ filter prefix.
q_not = False
if key.startswith('not__'):
key = key[5:]
q_not = True
# Convert value to python and add to the appropriate dict.
value = self.value_to_python(queryset.model, key, value)
if q_not:
excludes[key] = value
else:
filters[key] = value
if filters:
queryset = queryset.filter(**filters)
if excludes:
queryset = queryset.exclude(**excludes)
# Convert value(s) to python and add to the appropriate list.
for value in values:
if q_int:
value = int(value)
value = self.value_to_python(queryset.model, key, value)
if 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:
args = []
for n, k, v in and_filters:
if n:
args.append(~Q(**{k:v}))
else:
args.append(Q(**{k:v}))
if or_filters:
q = Q()
for n,k,v in or_filters:
if n:
q |= ~Q(**{k:v})
else:
q |= Q(**{k:v})
args.append(q)
queryset = queryset.filter(*args)
return queryset
except (FieldError, FieldDoesNotExist, ValueError), e:
raise ParseError(e.args[0])

View File

@ -47,6 +47,15 @@ a particular page of results.
The `previous` and `next` links returned with the results will set these query
string parameters automatically.
## Searching
Use the `search` query string parameter to perform a case-insensitive search
within all designated text fields of a model.
?search=findme
_New in AWX 1.4_
## Filtering
Any additional query string parameters may be used to filter the list of
@ -66,6 +75,14 @@ To exclude results matching certain criteria, prefix the field parameter with
?not__field=value
(_New in AWX 1.4_) By default, all query string filters are AND'ed together, so
only the results matching *all* filters will be returned. To combine results
matching *any* one of multiple criteria, prefix each query string parameter
with `or__`:
?or__field=value&or__field=othervalue
?or__not__field=value&or__field=othervalue
Field lookups may also be used for more advanced queries, by appending the
lookup to the field name:

View File

@ -125,20 +125,6 @@ class BaseTestMixin(object):
))
return results
def check_pagination_and_size(self, data, desired_count, previous=None, next=None):
self.assertTrue('results' in data)
self.assertEqual(data['count'], desired_count)
self.assertEqual(data['previous'], previous)
self.assertEqual(data['next'], next)
def check_list_ids(self, data, queryset, check_order=False):
data_ids = [x['id'] for x in data['results']]
qs_ids = queryset.values_list('pk', flat=True)
if check_order:
self.assertEqual(tuple(data_ids), tuple(qs_ids))
else:
self.assertEqual(set(data_ids), set(qs_ids))
def setup_users(self, just_super_user=False):
# Create a user.
self.super_username = 'admin'
@ -296,12 +282,34 @@ class BaseTestMixin(object):
else:
f(url, expect=401)
def check_pagination_and_size(self, data, desired_count, previous=False,
next=False):
self.assertTrue('results' in data)
self.assertEqual(data['count'], desired_count)
if previous:
self.assertTrue(data['previous'])
else:
self.assertFalse(data['previous'])
if next:
self.assertTrue(data['next'])
else:
self.assertFalse(data['next'])
def check_list_ids(self, data, queryset, check_order=False):
data_ids = [x['id'] for x in data['results']]
qs_ids = queryset.values_list('pk', flat=True)
if check_order:
self.assertEqual(tuple(data_ids), tuple(qs_ids))
else:
self.assertEqual(set(data_ids), set(qs_ids))
def check_get_list(self, url, user, qs, fields=None, expect=200,
check_order=False):
check_order=False, offset=None, limit=None):
'''
Check that the given list view URL returns results for the given user
that match the given queryset.
'''
offset = offset or 0
with self.current_user(user):
if expect == 400:
self.options(url, expect=200)
@ -311,7 +319,14 @@ class BaseTestMixin(object):
response = self.get(url, expect=expect)
if expect != 200:
return
self.check_pagination_and_size(response, qs.count())
total = qs.count()
if limit is not None:
if limit > 0:
qs = qs[offset:offset+limit]
else:
qs = qs.none()
self.check_pagination_and_size(response, total, offset > 0,
limit and ((offset + limit) < total))
self.check_list_ids(response, qs, check_order)
if fields:
for obj in response['results']:

View File

@ -9,6 +9,7 @@ import urllib
# Django
from django.conf import settings
from django.contrib.auth.models import User, Group
from django.db.models import Q
import django.test
from django.test.client import Client
from django.core.urlresolvers import reverse
@ -423,12 +424,36 @@ class UsersTest(BaseTest):
url = '%s?username__regex=%s' % (base_url, urllib.quote_plus('['))
self.check_get_list(url, self.super_django_user, base_qs, expect=400)
# Filter by multiple usernames (AND).
url = '%s?username=normal&username=nobody' % base_url
qs = base_qs.filter(username='normal', username__exact='nobody')
self.assertFalse(qs.count())
self.check_get_list(url, self.super_django_user, qs)
# Filter by multiple usernames (OR).
url = '%s?or__username=normal&or__username=nobody' % base_url
qs = base_qs.filter(Q(username='normal') | Q(username='nobody'))
self.assertTrue(qs.count())
self.check_get_list(url, self.super_django_user, qs)
# Exclude by username.
url = '%s?not__username=normal' % base_url
qs = base_qs.exclude(username='normal')
self.assertTrue(qs.count())
self.check_get_list(url, self.super_django_user, qs)
# Exclude by multiple usernames.
url = '%s?not__username=normal&not__username=nobody' % base_url
qs = base_qs.filter(~Q(username='normal') & ~Q(username='nobody'))
self.assertTrue(qs.count())
self.check_get_list(url, self.super_django_user, qs)
# Exclude by multiple usernames with OR.
url = '%s?or__not__username=normal&or__not__username=nobody' % base_url
qs = base_qs.filter(~Q(username='normal') | ~Q(username='nobody'))
self.assertTrue(qs.count())
self.check_get_list(url, self.super_django_user, qs)
# Exclude by username with suffix.
url = '%s?not__username__startswith=no' % base_url
qs = base_qs.exclude(username__startswith='no')
@ -625,6 +650,52 @@ class UsersTest(BaseTest):
url = u'%s?user\u2605name=normal' % base_url
self.check_get_list(url, self.super_django_user, base_qs, expect=400)
def test_user_list_pagination(self):
base_url = reverse('main:user_list')
base_qs = User.objects.distinct()
# Check list view with page size of 1.
url = '%s?order_by=username&page_size=1' % base_url
qs = base_qs.order_by('username')
self.check_get_list(url, self.super_django_user, qs, check_order=True,
limit=1)
# Check list view with page size of 1, remaining pages.
qs = base_qs.order_by('username')
for n in xrange(1, base_qs.count()):
url = '%s?order_by=username&page_size=1&page=%d' % (base_url, n+1)
self.check_get_list(url, self.super_django_user, qs,
check_order=True, offset=n, limit=1)
# Check list view with page size of 2.
qs = base_qs.order_by('username')
for n in xrange(0, base_qs.count(), 2):
url = '%s?order_by=username&page_size=2&page=%d' % (base_url, (n/2)+1)
self.check_get_list(url, self.super_django_user, qs,
check_order=True, offset=n, limit=2)
# Check list view with page size of 0 (to allow getting count of items
# matching a given filter). # FIXME: Make this work at some point!
#url = '%s?order_by=username&page_size=0' % base_url
#qs = base_qs.order_by('username')
#self.check_get_list(url, self.super_django_user, qs, check_order=True,
# limit=0)
def test_user_list_searching(self):
base_url = reverse('main:user_list')
base_qs = User.objects.distinct()
# Check search query parameter.
url = '%s?search=no' % base_url
qs = base_qs.filter(username__icontains='no')
self.check_get_list(url, self.super_django_user, qs)
# Check search query parameter.
url = '%s?search=example' % base_url
qs = base_qs.filter(email__icontains='example')
self.check_get_list(url, self.super_django_user, qs)
class LdapTest(BaseTest):
def use_test_setting(self, name, default=None, from_name=None):

View File

@ -150,6 +150,7 @@ REST_FRAMEWORK = {
'DEFAULT_FILTER_BACKENDS': (
'awx.main.filters.ActiveOnlyBackend',
'awx.main.filters.FieldLookupBackend',
'rest_framework.filters.SearchFilter',
'awx.main.filters.OrderByBackend',
),
'DEFAULT_PARSER_CLASSES': (