diff --git a/awx/main/base_views.py b/awx/main/base_views.py index 71d354640c..0b1d75a621 100644 --- a/awx/main/base_views.py +++ b/awx/main/base_views.py @@ -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. diff --git a/awx/main/filters.py b/awx/main/filters.py index ef1ca37b4a..8bf40557ad 100644 --- a/awx/main/filters.py +++ b/awx/main/filters.py @@ -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]) diff --git a/awx/main/management/commands/inventory_import.py b/awx/main/management/commands/inventory_import.py index dd25ffc18b..9cb57b2e0f 100644 --- a/awx/main/management/commands/inventory_import.py +++ b/awx/main/management/commands/inventory_import.py @@ -418,7 +418,11 @@ class Command(NoArgsCommand): self.logger = logging.getLogger('awx.main.commands.inventory_import') self.logger.setLevel(log_levels.get(self.verbosity, 0)) handler = logging.StreamHandler() - formatter = logging.Formatter('%(asctime)s %(levelname)-8s %(message)s') + class Formatter(logging.Formatter): + def format(self, record): + record.relativeSeconds = record.relativeCreated / 1000.0 + return super(Formatter, self).format(record) + formatter = Formatter('%(relativeSeconds)9.3f %(levelname)-8s %(message)s') handler.setFormatter(formatter) self.logger.addHandler(handler) self.logger.propagate = False diff --git a/awx/main/templates/main/_list_common.md b/awx/main/templates/main/_list_common.md index ab20a762fc..7cacf39366 100644 --- a/awx/main/templates/main/_list_common.md +++ b/awx/main/templates/main/_list_common.md @@ -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: diff --git a/awx/main/tests/base.py b/awx/main/tests/base.py index a2abb1dc2c..d751225436 100644 --- a/awx/main/tests/base.py +++ b/awx/main/tests/base.py @@ -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']: diff --git a/awx/main/tests/users.py b/awx/main/tests/users.py index b0322ac39b..f34fdf3eff 100644 --- a/awx/main/tests/users.py +++ b/awx/main/tests/users.py @@ -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¬__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): diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 061dd21776..0b16690641 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -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': ( diff --git a/awx/templates/rest_framework/base.html b/awx/templates/rest_framework/base.html new file mode 100644 index 0000000000..e4b2062885 --- /dev/null +++ b/awx/templates/rest_framework/base.html @@ -0,0 +1,236 @@ +{# Copy of base.html from rest_framework with minor AWX change. #} +{% load url from future %} +{% load rest_framework %} + + + + {% block head %} + + {% block meta %} + + + {% endblock %} + + {% block title %}Django REST framework{% endblock %} + + {% block style %} + {% block bootstrap_theme %} + + + {% endblock %} + + + {% endblock %} + + {% endblock %} + + + + +
+ + {% block navbar %} + + {% endblock %} + + {% block breadcrumbs %} + + {% endblock %} + + +
+ + {% if 'GET' in allowed_methods %} +
+
+
+ GET + + + +
+ +
+
+ {% endif %} + + {% if options_form %} +
+ {% csrf_token %} + + +
+ {% endif %} + + {% if delete_form %} +
+ {% csrf_token %} + + +
+ {% endif %} + +
+ + {{ description }} +
+
{{ request.method }} {{ request.get_full_path }}
+
+
+
HTTP {{ response.status_code }} {{ response.status_text }}{% autoescape off %} +{% for key, val in response.items %}{{ key }}: {{ val|break_long_headers|urlize_quoted_links }} +{% endfor %} +{# Original line below had content|urlize_quoted_links; for AWX disable automatic URL creation here. #} +
{{ content }}
{% endautoescape %} +
+
+ + {% if response.status_code != 403 %} + + {% if post_form or raw_data_post_form %} +
+ {% if post_form %} + + {% endif %} +
+ {% if post_form %} +
+ {% with form=post_form %} +
+
+ {{ post_form }} +
+ +
+
+
+ {% endwith %} +
+ {% endif %} +
+ {% with form=raw_data_post_form %} +
+
+ {% include "rest_framework/form.html" %} +
+ +
+
+
+ {% endwith %} +
+
+
+ {% endif %} + + {% if put_form or raw_data_put_form or raw_data_patch_form %} +
+ {% if put_form %} + + {% endif %} +
+ {% if put_form %} +
+
+
+ {{ put_form }} +
+ +
+
+
+
+ {% endif %} +
+ {% with form=raw_data_put_or_patch_form %} +
+
+ {% include "rest_framework/form.html" %} +
+ {% if raw_data_put_form %} + + {% endif %} + {% if raw_data_patch_form %} + + {% endif %} +
+
+
+ {% endwith %} +
+
+
+ {% endif %} + {% endif %} + +
+ + +
+ + +
+ + + + + + {% block footer %} + + {% endblock %} + + {% block script %} + + + + + {% endblock %} + +