Merge remote branch 'upstream/master'

This commit is contained in:
chouseknecht
2013-10-27 08:38:38 +00:00
8 changed files with 414 additions and 34 deletions

View File

@@ -168,6 +168,8 @@ class GenericAPIView(generics.GenericAPIView, APIView):
actions['GET'] = serializer.metadata() actions['GET'] = serializer.metadata()
if actions: if actions:
ret['actions'] = actions ret['actions'] = actions
if getattr(self, 'search_fields', None):
ret['search_fields'] = self.search_fields
return ret return ret
class ListAPIView(generics.ListAPIView, GenericAPIView): class ListAPIView(generics.ListAPIView, GenericAPIView):
@@ -188,6 +190,15 @@ class ListAPIView(generics.ListAPIView, GenericAPIView):
}) })
return d 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): class ListCreateAPIView(ListAPIView, generics.ListCreateAPIView):
# Base class for a list view that allows creating new objects. # 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. 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', SUPPORTED_LOOKUPS = ('exact', 'iexact', 'contains', 'icontains',
'startswith', 'istartswith', 'endswith', 'iendswith', 'startswith', 'istartswith', 'endswith', 'iendswith',
@@ -109,33 +110,57 @@ class FieldLookupBackend(BaseFilterBackend):
def filter_queryset(self, request, queryset, view): def filter_queryset(self, request, queryset, view):
try: try:
# Apply filters and excludes specified via QUERY_PARAMS. # Apply filters specified via QUERY_PARAMS. Each entry in the lists
filters = {} # below is (negate, field, value).
excludes = {} and_filters = []
for key, value in request.QUERY_PARAMS.items(): or_filters = []
for key, values in request.QUERY_PARAMS.lists():
if key in self.RESERVED_NAMES: if key in self.RESERVED_NAMES:
continue continue
# Custom __int filter suffix (internal use only). # Custom __int filter suffix (internal use only).
q_int = False
if key.endswith('__int'): if key.endswith('__int'):
key = key[:-5] 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. # Custom not__ filter prefix.
q_not = False q_not = False
if key.startswith('not__'): if key.startswith('not__'):
key = key[5:] key = key[5:]
q_not = True 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: # Convert value(s) to python and add to the appropriate list.
queryset = queryset.filter(**filters) for value in values:
if excludes: if q_int:
queryset = queryset.exclude(**excludes) 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 return queryset
except (FieldError, FieldDoesNotExist, ValueError), e: except (FieldError, FieldDoesNotExist, ValueError), e:
raise ParseError(e.args[0]) raise ParseError(e.args[0])

View File

@@ -418,7 +418,11 @@ class Command(NoArgsCommand):
self.logger = logging.getLogger('awx.main.commands.inventory_import') self.logger = logging.getLogger('awx.main.commands.inventory_import')
self.logger.setLevel(log_levels.get(self.verbosity, 0)) self.logger.setLevel(log_levels.get(self.verbosity, 0))
handler = logging.StreamHandler() 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) handler.setFormatter(formatter)
self.logger.addHandler(handler) self.logger.addHandler(handler)
self.logger.propagate = False self.logger.propagate = False

View File

@@ -47,6 +47,15 @@ a particular page of results.
The `previous` and `next` links returned with the results will set these query The `previous` and `next` links returned with the results will set these query
string parameters automatically. 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 ## Filtering
Any additional query string parameters may be used to filter the list of 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 ?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 Field lookups may also be used for more advanced queries, by appending the
lookup to the field name: lookup to the field name:

View File

@@ -125,20 +125,6 @@ class BaseTestMixin(object):
)) ))
return results 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): def setup_users(self, just_super_user=False):
# Create a user. # Create a user.
self.super_username = 'admin' self.super_username = 'admin'
@@ -296,12 +282,34 @@ class BaseTestMixin(object):
else: else:
f(url, expect=401) 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, 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 Check that the given list view URL returns results for the given user
that match the given queryset. that match the given queryset.
''' '''
offset = offset or 0
with self.current_user(user): with self.current_user(user):
if expect == 400: if expect == 400:
self.options(url, expect=200) self.options(url, expect=200)
@@ -311,7 +319,14 @@ class BaseTestMixin(object):
response = self.get(url, expect=expect) response = self.get(url, expect=expect)
if expect != 200: if expect != 200:
return 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) self.check_list_ids(response, qs, check_order)
if fields: if fields:
for obj in response['results']: for obj in response['results']:

View File

@@ -9,6 +9,7 @@ import urllib
# Django # Django
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User, Group from django.contrib.auth.models import User, Group
from django.db.models import Q
import django.test import django.test
from django.test.client import Client from django.test.client import Client
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
@@ -423,12 +424,36 @@ class UsersTest(BaseTest):
url = '%s?username__regex=%s' % (base_url, urllib.quote_plus('[')) url = '%s?username__regex=%s' % (base_url, urllib.quote_plus('['))
self.check_get_list(url, self.super_django_user, base_qs, expect=400) 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. # Exclude by username.
url = '%s?not__username=normal' % base_url url = '%s?not__username=normal' % base_url
qs = base_qs.exclude(username='normal') qs = base_qs.exclude(username='normal')
self.assertTrue(qs.count()) self.assertTrue(qs.count())
self.check_get_list(url, self.super_django_user, qs) 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. # Exclude by username with suffix.
url = '%s?not__username__startswith=no' % base_url url = '%s?not__username__startswith=no' % base_url
qs = base_qs.exclude(username__startswith='no') qs = base_qs.exclude(username__startswith='no')
@@ -625,6 +650,52 @@ class UsersTest(BaseTest):
url = u'%s?user\u2605name=normal' % base_url url = u'%s?user\u2605name=normal' % base_url
self.check_get_list(url, self.super_django_user, base_qs, expect=400) 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): class LdapTest(BaseTest):
def use_test_setting(self, name, default=None, from_name=None): def use_test_setting(self, name, default=None, from_name=None):

View File

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

View File

@@ -0,0 +1,236 @@
{# Copy of base.html from rest_framework with minor AWX change. #}
{% load url from future %}
{% load rest_framework %}
<!DOCTYPE html>
<html>
<head>
{% block head %}
{% block meta %}
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<meta name="robots" content="NONE,NOARCHIVE" />
{% endblock %}
<title>{% block title %}Django REST framework{% endblock %}</title>
{% block style %}
{% block bootstrap_theme %}
<link rel="stylesheet" type="text/css" href="{% static "rest_framework/css/bootstrap.min.css" %}"/>
<link rel="stylesheet" type="text/css" href="{% static "rest_framework/css/bootstrap-tweaks.css" %}"/>
{% endblock %}
<link rel="stylesheet" type="text/css" href="{% static "rest_framework/css/prettify.css" %}"/>
<link rel="stylesheet" type="text/css" href="{% static "rest_framework/css/default.css" %}"/>
{% endblock %}
{% endblock %}
</head>
<body class="{% block bodyclass %}{% endblock %} container">
<div class="wrapper">
{% block navbar %}
<div class="navbar {% block bootstrap_navbar_variant %}navbar-inverse{% endblock %}">
<div class="navbar-inner">
<div class="container-fluid">
<span href="/">
{% block branding %}<a class='brand' href='http://django-rest-framework.org'>Django REST framework <span class="version">{{ version }}</span></a>{% endblock %}
</span>
<ul class="nav pull-right">
{% block userlinks %}
{% if user.is_authenticated %}
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">
{{ user }}
<b class="caret"></b>
</a>
<ul class="dropdown-menu">
<li>{% optional_logout request %}</li>
</ul>
</li>
{% else %}
<li>{% optional_login request %}</li>
{% endif %}
{% endblock %}
</ul>
</div>
</div>
</div>
{% endblock %}
{% block breadcrumbs %}
<ul class="breadcrumb">
{% for breadcrumb_name, breadcrumb_url in breadcrumblist %}
<li>
<a href="{{ breadcrumb_url }}" {% if forloop.last %}class="active"{% endif %}>{{ breadcrumb_name }}</a> {% if not forloop.last %}<span class="divider">&rsaquo;</span>{% endif %}
</li>
{% endfor %}
</ul>
{% endblock %}
<!-- Content -->
<div id="content">
{% if 'GET' in allowed_methods %}
<form id="get-form" class="pull-right">
<fieldset>
<div class="btn-group format-selection">
<a class="btn btn-primary js-tooltip" href='{{ request.get_full_path }}' rel="nofollow" title="Make a GET request on the {{ name }} resource">GET</a>
<button class="btn btn-primary dropdown-toggle js-tooltip" data-toggle="dropdown" title="Specify a format for the GET request">
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
{% for format in available_formats %}
<li>
<a class="js-tooltip format-option" href='{% add_query_param request api_settings.URL_FORMAT_OVERRIDE format %}' rel="nofollow" title="Make a GET request on the {{ name }} resource with the format set to `{{ format }}`">{{ format }}</a>
</li>
{% endfor %}
</ul>
</div>
</fieldset>
</form>
{% endif %}
{% if options_form %}
<form class="button-form" action="{{ request.get_full_path }}" method="POST" class="pull-right">
{% csrf_token %}
<input type="hidden" name="{{ api_settings.FORM_METHOD_OVERRIDE }}" value="OPTIONS" />
<button class="btn btn-primary js-tooltip" title="Make an OPTIONS request on the {{ name }} resource">OPTIONS</button>
</form>
{% endif %}
{% if delete_form %}
<form class="button-form" action="{{ request.get_full_path }}" method="POST" class="pull-right">
{% csrf_token %}
<input type="hidden" name="{{ api_settings.FORM_METHOD_OVERRIDE }}" value="DELETE" />
<button class="btn btn-danger js-tooltip" title="Make a DELETE request on the {{ name }} resource">DELETE</button>
</form>
{% endif %}
<div class="content-main">
<div class="page-header"><h1>{{ name }}</h1></div>
{{ description }}
<div class="request-info" style="clear: both" >
<pre class="prettyprint"><b>{{ request.method }}</b> {{ request.get_full_path }}</pre>
</div>
<div class="response-info">
<pre class="prettyprint"><div class="meta nocode"><b>HTTP {{ response.status_code }} {{ response.status_text }}</b>{% autoescape off %}
{% for key, val in response.items %}<b>{{ key }}:</b> <span class="lit">{{ val|break_long_headers|urlize_quoted_links }}</span>
{% endfor %}
{# Original line below had content|urlize_quoted_links; for AWX disable automatic URL creation here. #}
</div>{{ content }}</pre>{% endautoescape %}
</div>
</div>
{% if response.status_code != 403 %}
{% if post_form or raw_data_post_form %}
<div {% if post_form %}class="tabbable"{% endif %}>
{% if post_form %}
<ul class="nav nav-tabs form-switcher">
<li><a name='html-tab' href="#object-form" data-toggle="tab">HTML form</a></li>
<li><a name='raw-tab' href="#generic-content-form" data-toggle="tab">Raw data</a></li>
</ul>
{% endif %}
<div class="well tab-content">
{% if post_form %}
<div class="tab-pane" id="object-form">
{% with form=post_form %}
<form action="{{ request.get_full_path }}" method="POST" enctype="multipart/form-data" class="form-horizontal">
<fieldset>
{{ post_form }}
<div class="form-actions">
<button class="btn btn-primary" title="Make a POST request on the {{ name }} resource">POST</button>
</div>
</fieldset>
</form>
{% endwith %}
</div>
{% endif %}
<div {% if post_form %}class="tab-pane"{% endif %} id="generic-content-form">
{% with form=raw_data_post_form %}
<form action="{{ request.get_full_path }}" method="POST" class="form-horizontal">
<fieldset>
{% include "rest_framework/form.html" %}
<div class="form-actions">
<button class="btn btn-primary" title="Make a POST request on the {{ name }} resource">POST</button>
</div>
</fieldset>
</form>
{% endwith %}
</div>
</div>
</div>
{% endif %}
{% if put_form or raw_data_put_form or raw_data_patch_form %}
<div {% if put_form %}class="tabbable"{% endif %}>
{% if put_form %}
<ul class="nav nav-tabs form-switcher">
<li><a name='html-tab' href="#object-form" data-toggle="tab">HTML form</a></li>
<li><a name='raw-tab' href="#generic-content-form" data-toggle="tab">Raw data</a></li>
</ul>
{% endif %}
<div class="well tab-content">
{% if put_form %}
<div class="tab-pane" id="object-form">
<form action="{{ request.get_full_path }}" method="POST" enctype="multipart/form-data" class="form-horizontal">
<fieldset>
{{ put_form }}
<div class="form-actions">
<button class="btn btn-primary js-tooltip" name="{{ api_settings.FORM_METHOD_OVERRIDE }}" value="PUT" title="Make a PUT request on the {{ name }} resource">PUT</button>
</div>
</fieldset>
</form>
</div>
{% endif %}
<div {% if put_form %}class="tab-pane"{% endif %} id="generic-content-form">
{% with form=raw_data_put_or_patch_form %}
<form action="{{ request.get_full_path }}" method="POST" class="form-horizontal">
<fieldset>
{% include "rest_framework/form.html" %}
<div class="form-actions">
{% if raw_data_put_form %}
<button class="btn btn-primary js-tooltip" name="{{ api_settings.FORM_METHOD_OVERRIDE }}" value="PUT" title="Make a PUT request on the {{ name }} resource">PUT</button>
{% endif %}
{% if raw_data_patch_form %}
<button class="btn btn-primary js-tooltip" name="{{ api_settings.FORM_METHOD_OVERRIDE }}" value="PATCH" title="Make a PATCH request on the {{ name }} resource">PATCH</button>
{% endif %}
</div>
</fieldset>
</form>
{% endwith %}
</div>
</div>
</div>
{% endif %}
{% endif %}
</div>
<!-- END content-main -->
</div>
<!-- END Content -->
<div id="push"></div>
</div>
</div><!-- ./wrapper -->
{% block footer %}
<!--<div id="footer">
<a class="powered-by" href='http://django-rest-framework.org'>Django REST framework</a>
</div>-->
{% endblock %}
{% block script %}
<script src="{% static "rest_framework/js/jquery-1.8.1-min.js" %}"></script>
<script src="{% static "rest_framework/js/bootstrap.min.js" %}"></script>
<script src="{% static "rest_framework/js/prettify-min.js" %}"></script>
<script src="{% static "rest_framework/js/default.js" %}"></script>
{% endblock %}
</body>
</html>