diff --git a/.travis.yml b/.travis.yml index 0e177a95a..fa8693a06 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,6 +11,7 @@ env: install: - pip install $DJANGO + - pip install -r requirements.txt --use-mirrors - export PYTHONPATH=. script: diff --git a/README.md b/README.md index a6f0438d2..4b2245cca 100644 --- a/README.md +++ b/README.md @@ -88,13 +88,13 @@ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. [build-status-image]: https://secure.travis-ci.org/tomchristie/django-rest-framework.png?branch=restframework2 -[travis]: http://travis-ci.org/tomchristie/django-rest-framework?branch=restframework2 +[travis]: http://travis-ci.org/tomchristie/django-rest-framework?branch=master [twitter]: https://twitter.com/_tomchristie [0.4]: https://github.com/tomchristie/django-rest-framework/tree/0.4.X [sandbox]: http://restframework.herokuapp.com/ [rest-framework-2-announcement]: topics/rest-framework-2-announcement.md -[docs]: http://tomchristie.github.com/django-rest-framework/ +[docs]: http://django-rest-framework.org/ [urlobject]: https://github.com/zacharyvoase/urlobject [markdown]: http://pypi.python.org/pypi/Markdown/ [pyyaml]: http://pypi.python.org/pypi/PyYAML diff --git a/docs/api-guide/authentication.md b/docs/api-guide/authentication.md index 889d16c03..3137b9d4c 100644 --- a/docs/api-guide/authentication.md +++ b/docs/api-guide/authentication.md @@ -30,7 +30,7 @@ The default authentication policy may be set globally, using the `DEFAULT_AUTHEN REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': ( - 'rest_framework.authentication.UserBasicAuthentication', + 'rest_framework.authentication.BasicAuthentication', 'rest_framework.authentication.SessionAuthentication', ) } @@ -38,7 +38,7 @@ The default authentication policy may be set globally, using the `DEFAULT_AUTHEN You can also set the authentication policy on a per-view basis, using the `APIView` class based views. class ExampleView(APIView): - authentication_classes = (SessionAuthentication, UserBasicAuthentication) + authentication_classes = (SessionAuthentication, BasicAuthentication) permission_classes = (IsAuthenticated,) def get(self, request, format=None): @@ -51,7 +51,7 @@ You can also set the authentication policy on a per-view basis, using the `APIVi Or, if you're using the `@api_view` decorator with function based views. @api_view(['GET']) - @authentication_classes((SessionAuthentication, UserBasicAuthentication)) + @authentication_classes((SessionAuthentication, BasicAuthentication)) @permissions_classes((IsAuthenticated,)) def example_view(request, format=None): content = { diff --git a/docs/api-guide/settings.md b/docs/api-guide/settings.md index a3668e2a9..4f87b30da 100644 --- a/docs/api-guide/settings.md +++ b/docs/api-guide/settings.md @@ -13,7 +13,7 @@ For example your project's `settings.py` file might include something like this: REST_FRAMEWORK = { 'DEFAULT_RENDERER_CLASSES': ( 'rest_framework.renderers.YAMLRenderer', - ) + ), 'DEFAULT_PARSER_CLASSES': ( 'rest_framework.parsers.YAMLParser', ) diff --git a/docs/api-guide/throttling.md b/docs/api-guide/throttling.md index bfda7079b..b03bc9e04 100644 --- a/docs/api-guide/throttling.md +++ b/docs/api-guide/throttling.md @@ -31,8 +31,8 @@ The default throttling policy may be set globally, using the `DEFAULT_THROTTLE_C REST_FRAMEWORK = { 'DEFAULT_THROTTLE_CLASSES': ( - 'rest_framework.throttles.AnonThrottle', - 'rest_framework.throttles.UserThrottle' + 'rest_framework.throttling.AnonRateThrottle', + 'rest_framework.throttling.UserRateThrottle' ), 'DEFAULT_THROTTLE_RATES': { 'anon': '100/day', @@ -136,7 +136,7 @@ For example, given the following views... REST_FRAMEWORK = { 'DEFAULT_THROTTLE_CLASSES': ( - 'rest_framework.throttles.ScopedRateThrottle' + 'rest_framework.throttling.ScopedRateThrottle' ), 'DEFAULT_THROTTLE_RATES': { 'contacts': '1000/day', diff --git a/docs/index.md b/docs/index.md index a96d0925b..75a1cf6e6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -159,7 +159,7 @@ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -[travis]: http://travis-ci.org/tomchristie/django-rest-framework?branch=restframework2 +[travis]: http://travis-ci.org/tomchristie/django-rest-framework?branch=master [travis-build-image]: https://secure.travis-ci.org/tomchristie/django-rest-framework.png?branch=restframework2 [urlobject]: https://github.com/zacharyvoase/urlobject [markdown]: http://pypi.python.org/pypi/Markdown/ diff --git a/docs/topics/credits.md b/docs/topics/credits.md index 69d57802f..a74f79838 100644 --- a/docs/topics/credits.md +++ b/docs/topics/credits.md @@ -51,6 +51,7 @@ The following people have helped make REST framework great. * Daniel Vaca Araujo - [diviei] * Madis Väin - [madisvain] * Stephan Groß - [minddust] +* Pavel Savchenko - [asfaltboy] Many thanks to everyone who's contributed to the project. @@ -136,4 +137,5 @@ To contact the author directly: [rdobson]: https://github.com/rdobson [diviei]: https://github.com/diviei [madisvain]: https://github.com/madisvain -[minddust]: https://github.com/minddust \ No newline at end of file +[minddust]: https://github.com/minddust +[asfaltboy]: https://github.com/asfaltboy diff --git a/docs/topics/release-notes.md b/docs/topics/release-notes.md index a466f4b18..b336aeab9 100644 --- a/docs/topics/release-notes.md +++ b/docs/topics/release-notes.md @@ -4,6 +4,10 @@ > > — Eric S. Raymond, [The Cathedral and the Bazaar][cite]. +## Master + +* If PUT creates an instance return '201 Created', instead of '200 OK'. + ## 2.0.0 * **Fix all of the things.** (Well, almost.) diff --git a/docs/tutorial/1-serialization.md b/docs/tutorial/1-serialization.md index 5cf16a67f..316a3c255 100644 --- a/docs/tutorial/1-serialization.md +++ b/docs/tutorial/1-serialization.md @@ -201,7 +201,7 @@ Open the file `snippets/serializers.py` again, and edit the `SnippetSerializer` class SnippetSerializer(serializers.ModelSerializer): class Meta: model = Snippet - fields = ('pk', 'title', 'code', 'linenos', 'language', 'style') + fields = ('id', 'title', 'code', 'linenos', 'language', 'style') diff --git a/docs/tutorial/2-requests-and-responses.md b/docs/tutorial/2-requests-and-responses.md index 938739fa6..a7c23cba6 100644 --- a/docs/tutorial/2-requests-and-responses.md +++ b/docs/tutorial/2-requests-and-responses.md @@ -130,8 +130,6 @@ Go ahead and test the API from the command line, as we did in [tutorial part 1][ Now go and open the API in a web browser, by visiting [http://127.0.0.1:8000/snippets/][devserver]." -**Note: Right now the Browseable API only works with the CBV's. Need to fix that.** - ### Browsability Because the API chooses a return format based on what the client asks for, it will, by default, return an HTML-formatted representation of the resource when that resource is requested by a browser. This allows for the API to be easily browsable and usable by humans. diff --git a/docs/tutorial/3-class-based-views.md b/docs/tutorial/3-class-based-views.md index f27b5af01..a31dccb2f 100644 --- a/docs/tutorial/3-class-based-views.md +++ b/docs/tutorial/3-class-based-views.md @@ -69,8 +69,8 @@ We'll also need to refactor our URLconf slightly now we're using class based vie from snippetpost import views urlpatterns = patterns('', - url(r'^$', views.SnippetList.as_view()), - url(r'^(?P[0-9]+)$', views.SnippetDetail.as_view()) + url(r'^snippets/$', views.SnippetList.as_view()), + url(r'^snippets/(?P[0-9]+)/$', views.SnippetDetail.as_view()) ) urlpatterns = format_suffix_patterns(urlpatterns) diff --git a/docs/tutorial/4-authentication-and-permissions.md b/docs/tutorial/4-authentication-and-permissions.md index b0ed8f2a2..f85250bea 100644 --- a/docs/tutorial/4-authentication-and-permissions.md +++ b/docs/tutorial/4-authentication-and-permissions.md @@ -59,7 +59,7 @@ Now that we've got some users to work with, we'd better add representations of t class Meta: model = User - fields = ('pk', 'username', 'snippets') + fields = ('id', 'username', 'snippets') Because `'snippets'` is a *reverse* relationship on the User model, it will not be included by default when using the `ModelSerializer` class, so we've needed to add an explicit field for it. @@ -85,7 +85,7 @@ Right now, if we created a code snippet, there'd be no way of associating the us The way we deal with that is by overriding a `.pre_save()` method on our snippet views, that allows us to handle any information that is implicit in the incoming request or requested URL. -On **both** the `SnippetList` and `SnippetInstance` view classes, add the following method: +On **both** the `SnippetList` and `SnippetDetail` view classes, add the following method: def pre_save(self, obj): obj.owner = self.request.user @@ -112,7 +112,11 @@ Now that code snippets are associated with users we want to make sure that only REST framework includes a number of permission classes that we can use to restrict who can access a given view. In this case the one we're looking for is `IsAuthenticatedOrReadOnly`, which will ensure that authenticated requests get read-write access, and unauthenticated requests get read-only access. -Add the following property to **both** the `SnippetList` and `SnippetInstance` view classes. +First add the following import in the views module + + from rest_framework import permissions + +Then, add the following property to **both** the `SnippetList` and `SnippetDetail` view classes. permission_classes = (permissions.IsAuthenticatedOrReadOnly,) @@ -169,7 +173,7 @@ In the snippets app, create a new file, `permissions.py` # Write permissions are only allowed to the owner of the snippet return obj.owner == request.user -Now we can add that custom permission to our snippet instance endpoint, by editing the `permission_classes` property on the `SnippetInstance` class: +Now we can add that custom permission to our snippet instance endpoint, by editing the `permission_classes` property on the `SnippetDetail` class: permission_classes = (permissions.IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly,) diff --git a/docs/tutorial/5-relationships-and-hyperlinked-apis.md b/docs/tutorial/5-relationships-and-hyperlinked-apis.md index 1f663745d..3113249b5 100644 --- a/docs/tutorial/5-relationships-and-hyperlinked-apis.md +++ b/docs/tutorial/5-relationships-and-hyperlinked-apis.md @@ -27,9 +27,12 @@ The other obvious thing that's still missing from our pastebin API is the code h Unlike all our other API endpoints, we don't want to use JSON, but instead just present an HTML representation. There are two style of HTML renderer provided by REST framework, one for dealing with HTML rendered using templates, the other for dealing with pre-rendered HTML. The second renderer is the one we'd like to use for this endpoint. -The other thing we need to consider when creating the code highlight view is that there's no existing concreate generic view that we can use. We're not returning an object instance, but instead a property of an object instance. +The other thing we need to consider when creating the code highlight view is that there's no existing concrete generic view that we can use. We're not returning an object instance, but instead a property of an object instance. -Instead of using a concrete generic view, we'll use the base class for representing instances, and create our own `.get()` method. +Instead of using a concrete generic view, we'll use the base class for representing instances, and create our own `.get()` method. In your snippets.views add: + + from rest_framework import renderers + from rest_framework.response import Response class SnippetHighlight(generics.SingleObjectAPIView): model = Snippet @@ -111,7 +114,7 @@ After adding all those names into our URLconf, our final `'urls.py'` file should views.SnippetList.as_view(), name='snippet-list'), url(r'^snippets/(?P[0-9]+)/$', - views.SnippetInstance.as_view(), + views.SnippetDetail.as_view(), name='snippet-detail'), url(r'^snippets/(?P[0-9]+)/highlight/$' views.SnippetHighlight.as_view(), diff --git a/mkdocs.py b/mkdocs.py index 3f6c5f655..8106e8e22 100755 --- a/mkdocs.py +++ b/mkdocs.py @@ -18,7 +18,7 @@ if local: index = 'index.html' else: base_url = 'http://django-rest-framework.org' - suffix = '' + suffix = '.html' index = '' diff --git a/requirements.txt b/requirements.txt index 730c1d07a..48ff9d653 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ Django>=1.3 +-e git+https://github.com/alex/django-filter.git@0e4b3d703b31574922ab86fc78a86164aad0c1d0#egg=django-filter diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 1d6d760e3..73c8f72b9 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -211,9 +211,9 @@ class ModelField(WritableField): def from_native(self, value): try: rel = self.model_field.rel + return rel.to._meta.get_field(rel.field_name).to_python(value) except: return self.model_field.to_python(value) - return rel.to._meta.get_field(rel.field_name).to_python(value) def field_to_native(self, obj, field_name): value = self.model_field._get_val_from_obj(obj) diff --git a/rest_framework/generics.py b/rest_framework/generics.py index 27540a57f..063382bbc 100644 --- a/rest_framework/generics.py +++ b/rest_framework/generics.py @@ -6,7 +6,7 @@ from rest_framework import views, mixins from rest_framework.settings import api_settings from django.views.generic.detail import SingleObjectMixin from django.views.generic.list import MultipleObjectMixin - +import django_filters ### Base classes for the generic views ### @@ -58,6 +58,37 @@ class MultipleObjectAPIView(MultipleObjectMixin, GenericAPIView): pagination_serializer_class = api_settings.DEFAULT_PAGINATION_SERIALIZER_CLASS paginate_by = api_settings.PAGINATE_BY + filter_class = None + filter_fields = None + + def get_filter_class(self): + """ + Return the django-filters `FilterSet` used to filter the queryset. + """ + if self.filter_class: + return self.filter_class + + if self.filter_fields: + class AutoFilterSet(django_filters.FilterSet): + class Meta: + model = self.model + fields = self.filter_fields + return AutoFilterSet + + return None + + def filter_queryset(self, queryset): + filter_class = self.get_filter_class() + + if filter_class: + assert issubclass(filter_class.Meta.model, self.model), \ + "%s is not a subclass of %s" % (filter_class.Meta.model, self.model) + return filter_class(self.request.GET, queryset=queryset) + + return queryset + + def get_filtered_queryset(self): + return self.filter_queryset(self.get_queryset()) def get_pagination_serializer_class(self): """ diff --git a/rest_framework/mixins.py b/rest_framework/mixins.py index 8873e4aed..b0cc043ae 100644 --- a/rest_framework/mixins.py +++ b/rest_framework/mixins.py @@ -3,9 +3,6 @@ Basic building blocks for generic class based views. We don't bind behaviour to http method handlers yet, which allows mixin classes to be composed in interesting ways. - -Eg. Use mixins to build a Resource class, and have a Router class - perform the binding of http methods to actions for us. """ from django.http import Http404 from rest_framework import status @@ -37,7 +34,7 @@ class ListModelMixin(object): empty_error = u"Empty list and '%(class_name)s.allow_empty' is False." def list(self, request, *args, **kwargs): - self.object_list = self.get_queryset() + self.object_list = self.get_filtered_queryset() # Default is to allow empty querysets. This can be altered by setting # `.allow_empty = False`, to raise 404 errors on empty querysets. @@ -78,15 +75,17 @@ class UpdateModelMixin(object): def update(self, request, *args, **kwargs): try: self.object = self.get_object() + success_status = status.HTTP_200_OK except Http404: self.object = None + success_status = status.HTTP_201_CREATED serializer = self.get_serializer(data=request.DATA, instance=self.object) if serializer.is_valid(): self.pre_save(serializer.object) self.object = serializer.save() - return Response(serializer.data) + return Response(serializer.data, status=success_status) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/rest_framework/pagination.py b/rest_framework/pagination.py index 131718fd7..c77a10051 100644 --- a/rest_framework/pagination.py +++ b/rest_framework/pagination.py @@ -3,7 +3,11 @@ from rest_framework import serializers # TODO: Support URLconf kwarg-style paging -class NextPageField(serializers.Field): +class PageField(serializers.Field): + page_field = 'page' + + +class NextPageField(PageField): """ Field that returns a link to the next page in paginated results. """ @@ -12,13 +16,16 @@ class NextPageField(serializers.Field): return None page = value.next_page_number() request = self.context.get('request') - relative_url = '?page=%d' % page + relative_url = '?%s=%d' % (self.page_field, page) if request: + for field, value in request.QUERY_PARAMS.iteritems(): + if field != self.page_field: + relative_url += '&%s=%s' % (field, value) return request.build_absolute_uri(relative_url) return relative_url -class PreviousPageField(serializers.Field): +class PreviousPageField(PageField): """ Field that returns a link to the previous page in paginated results. """ @@ -27,9 +34,12 @@ class PreviousPageField(serializers.Field): return None page = value.previous_page_number() request = self.context.get('request') - relative_url = '?page=%d' % page + relative_url = '?%s=%d' % (self.page_field, page) if request: - return request.build_absolute_uri('?page=%d' % page) + for field, value in request.QUERY_PARAMS.iteritems(): + if field != self.page_field: + relative_url += '&%s=%s' % (field, value) + return request.build_absolute_uri(relative_url) return relative_url diff --git a/rest_framework/tests/filterset.py b/rest_framework/tests/filterset.py new file mode 100644 index 000000000..5374eefc8 --- /dev/null +++ b/rest_framework/tests/filterset.py @@ -0,0 +1,160 @@ +import datetime +from decimal import Decimal +from django.test import TestCase +from django.test.client import RequestFactory +from rest_framework import generics, status +from rest_framework.tests.models import FilterableItem, BasicModel +import django_filters + +factory = RequestFactory() + +# Basic filter on a list view. +class FilterFieldsRootView(generics.ListCreateAPIView): + model = FilterableItem + filter_fields = ['decimal', 'date'] + + +# These class are used to test a filter class. +class SeveralFieldsFilter(django_filters.FilterSet): + text = django_filters.CharFilter(lookup_type='icontains') + decimal = django_filters.NumberFilter(lookup_type='lt') + date = django_filters.DateFilter(lookup_type='gt') + class Meta: + model = FilterableItem + fields = ['text', 'decimal', 'date'] + + +class FilterClassRootView(generics.ListCreateAPIView): + model = FilterableItem + filter_class = SeveralFieldsFilter + + +# These classes are used to test a misconfigured filter class. +class MisconfiguredFilter(django_filters.FilterSet): + text = django_filters.CharFilter(lookup_type='icontains') + class Meta: + model = BasicModel + fields = ['text'] + + +class IncorrectlyConfiguredRootView(generics.ListCreateAPIView): + model = FilterableItem + filter_class = MisconfiguredFilter + + +class IntegrationTestFiltering(TestCase): + """ + Integration tests for filtered list views. + """ + + def setUp(self): + """ + Create 10 FilterableItem instances. + """ + base_data = ('a', Decimal('0.25'), datetime.date(2012, 10, 8)) + for i in range(10): + text = chr(i + ord(base_data[0])) * 3 # Produces string 'aaa', 'bbb', etc. + decimal = base_data[1] + i + date = base_data[2] - datetime.timedelta(days=i * 2) + FilterableItem(text=text, decimal=decimal, date=date).save() + + self.objects = FilterableItem.objects + self.data = [ + {'id': obj.id, 'text': obj.text, 'decimal': obj.decimal, 'date': obj.date} + for obj in self.objects.all() + ] + + def test_get_filtered_fields_root_view(self): + """ + GET requests to paginated ListCreateAPIView should return paginated results. + """ + view = FilterFieldsRootView.as_view() + + # Basic test with no filter. + request = factory.get('/') + response = view(request).render() + self.assertEquals(response.status_code, status.HTTP_200_OK) + self.assertEquals(response.data, self.data) + + # Tests that the decimal filter works. + search_decimal = Decimal('2.25') + request = factory.get('/?decimal=%s' % search_decimal) + response = view(request).render() + self.assertEquals(response.status_code, status.HTTP_200_OK) + expected_data = [ f for f in self.data if f['decimal'] == search_decimal ] + self.assertEquals(response.data, expected_data) + + # Tests that the date filter works. + search_date = datetime.date(2012, 9, 22) + request = factory.get('/?date=%s' % search_date) # search_date str: '2012-09-22' + response = view(request).render() + self.assertEquals(response.status_code, status.HTTP_200_OK) + expected_data = [ f for f in self.data if f['date'] == search_date ] + self.assertEquals(response.data, expected_data) + + def test_get_filtered_class_root_view(self): + """ + GET requests to filtered ListCreateAPIView that have a filter_class set + should return filtered results. + """ + view = FilterClassRootView.as_view() + + # Basic test with no filter. + request = factory.get('/') + response = view(request).render() + self.assertEquals(response.status_code, status.HTTP_200_OK) + self.assertEquals(response.data, self.data) + + # Tests that the decimal filter set with 'lt' in the filter class works. + search_decimal = Decimal('4.25') + request = factory.get('/?decimal=%s' % search_decimal) + response = view(request).render() + self.assertEquals(response.status_code, status.HTTP_200_OK) + expected_data = [ f for f in self.data if f['decimal'] < search_decimal ] + self.assertEquals(response.data, expected_data) + + # Tests that the date filter set with 'gt' in the filter class works. + search_date = datetime.date(2012, 10, 2) + request = factory.get('/?date=%s' % search_date) # search_date str: '2012-10-02' + response = view(request).render() + self.assertEquals(response.status_code, status.HTTP_200_OK) + expected_data = [ f for f in self.data if f['date'] > search_date ] + self.assertEquals(response.data, expected_data) + + # Tests that the text filter set with 'icontains' in the filter class works. + search_text = 'ff' + request = factory.get('/?text=%s' % search_text) + response = view(request).render() + self.assertEquals(response.status_code, status.HTTP_200_OK) + expected_data = [ f for f in self.data if search_text in f['text'].lower() ] + self.assertEquals(response.data, expected_data) + + # Tests that multiple filters works. + search_decimal = Decimal('5.25') + search_date = datetime.date(2012, 10, 2) + request = factory.get('/?decimal=%s&date=%s' % (search_decimal, search_date)) + response = view(request).render() + self.assertEquals(response.status_code, status.HTTP_200_OK) + expected_data = [ f for f in self.data if f['date'] > search_date and + f['decimal'] < search_decimal ] + self.assertEquals(response.data, expected_data) + + def test_incorrectly_configured_filter(self): + """ + An error should be displayed when the filter class is misconfigured. + """ + view = IncorrectlyConfiguredRootView.as_view() + + request = factory.get('/') + self.assertRaises(AssertionError, view, request) + + def test_unknown_filter(self): + """ + GET requests with filters that aren't configured should return 200. + """ + view = FilterFieldsRootView.as_view() + + search_integer = 10 + request = factory.get('/?integer=%s' % search_integer) + response = view(request).render() + self.assertEquals(response.status_code, status.HTTP_200_OK) \ No newline at end of file diff --git a/rest_framework/tests/generics.py b/rest_framework/tests/generics.py index d45ea9760..a8279ef2b 100644 --- a/rest_framework/tests/generics.py +++ b/rest_framework/tests/generics.py @@ -236,7 +236,7 @@ class TestInstanceView(TestCase): request = factory.put('/1', json.dumps(content), content_type='application/json') response = self.view(request, pk=1).render() - self.assertEquals(response.status_code, status.HTTP_200_OK) + self.assertEquals(response.status_code, status.HTTP_201_CREATED) self.assertEquals(response.data, {'id': 1, 'text': 'foobar'}) updated = self.objects.get(id=1) self.assertEquals(updated.text, 'foobar') @@ -251,7 +251,7 @@ class TestInstanceView(TestCase): request = factory.put('/5', json.dumps(content), content_type='application/json') response = self.view(request, pk=5).render() - self.assertEquals(response.status_code, status.HTTP_200_OK) + self.assertEquals(response.status_code, status.HTTP_201_CREATED) new_obj = self.objects.get(pk=5) self.assertEquals(new_obj.text, 'foobar') @@ -264,7 +264,7 @@ class TestInstanceView(TestCase): request = factory.put('/test_slug', json.dumps(content), content_type='application/json') response = self.slug_based_view(request, slug='test_slug').render() - self.assertEquals(response.status_code, status.HTTP_200_OK) + self.assertEquals(response.status_code, status.HTTP_201_CREATED) self.assertEquals(response.data, {'slug': 'test_slug', 'text': 'foobar'}) new_obj = SlugBasedModel.objects.get(slug='test_slug') self.assertEquals(new_obj.text, 'foobar') diff --git a/rest_framework/tests/models.py b/rest_framework/tests/models.py index 415e4d062..9efedbc47 100644 --- a/rest_framework/tests/models.py +++ b/rest_framework/tests/models.py @@ -95,6 +95,13 @@ class Bookmark(RESTFrameworkModel): tags = GenericRelation(TaggedItem) +# Model to test filtering. +class FilterableItem(RESTFrameworkModel): + text = models.CharField(max_length=100) + decimal = models.DecimalField(max_digits=4, decimal_places=2) + date = models.DateField() + + # Model for regression test for #285 class Comment(RESTFrameworkModel): diff --git a/rest_framework/tests/pagination.py b/rest_framework/tests/pagination.py index a939c9ef5..170515a76 100644 --- a/rest_framework/tests/pagination.py +++ b/rest_framework/tests/pagination.py @@ -1,8 +1,11 @@ +import datetime +from decimal import Decimal from django.core.paginator import Paginator from django.test import TestCase from django.test.client import RequestFactory from rest_framework import generics, status, pagination -from rest_framework.tests.models import BasicModel +from rest_framework.tests.models import BasicModel, FilterableItem +import django_filters factory = RequestFactory() @@ -15,6 +18,19 @@ class RootView(generics.ListCreateAPIView): paginate_by = 10 +class DecimalFilter(django_filters.FilterSet): + decimal = django_filters.NumberFilter(lookup_type='lt') + class Meta: + model = FilterableItem + fields = ['text', 'decimal', 'date'] + + +class FilterFieldsRootView(generics.ListCreateAPIView): + model = FilterableItem + paginate_by = 10 + filter_class = DecimalFilter + + class IntegrationTestPagination(TestCase): """ Integration tests for paginated list views. @@ -22,7 +38,7 @@ class IntegrationTestPagination(TestCase): def setUp(self): """ - Create 26 BasicModel intances. + Create 26 BasicModel instances. """ for char in 'abcdefghijklmnopqrstuvwxyz': BasicModel(text=char * 3).save() @@ -62,6 +78,57 @@ class IntegrationTestPagination(TestCase): self.assertNotEquals(response.data['previous'], None) +class IntegrationTestPaginationAndFiltering(TestCase): + + def setUp(self): + """ + Create 50 FilterableItem instances. + """ + base_data = ('a', Decimal('0.25'), datetime.date(2012, 10, 8)) + for i in range(26): + text = chr(i + ord(base_data[0])) * 3 # Produces string 'aaa', 'bbb', etc. + decimal = base_data[1] + i + date = base_data[2] - datetime.timedelta(days=i * 2) + FilterableItem(text=text, decimal=decimal, date=date).save() + + self.objects = FilterableItem.objects + self.data = [ + {'id': obj.id, 'text': obj.text, 'decimal': obj.decimal, 'date': obj.date} + for obj in self.objects.all() + ] + self.view = FilterFieldsRootView.as_view() + + def test_get_paginated_filtered_root_view(self): + """ + GET requests to paginated filtered ListCreateAPIView should return + paginated results. The next and previous links should preserve the + filtered parameters. + """ + request = factory.get('/?decimal=15.20') + response = self.view(request).render() + self.assertEquals(response.status_code, status.HTTP_200_OK) + self.assertEquals(response.data['count'], 15) + self.assertEquals(response.data['results'], self.data[:10]) + self.assertNotEquals(response.data['next'], None) + self.assertEquals(response.data['previous'], None) + + request = factory.get(response.data['next']) + response = self.view(request).render() + self.assertEquals(response.status_code, status.HTTP_200_OK) + self.assertEquals(response.data['count'], 15) + self.assertEquals(response.data['results'], self.data[10:15]) + self.assertEquals(response.data['next'], None) + self.assertNotEquals(response.data['previous'], None) + + request = factory.get(response.data['previous']) + response = self.view(request).render() + self.assertEquals(response.status_code, status.HTTP_200_OK) + self.assertEquals(response.data['count'], 15) + self.assertEquals(response.data['results'], self.data[:10]) + self.assertNotEquals(response.data['next'], None) + self.assertEquals(response.data['previous'], None) + + class UnitTestPagination(TestCase): """ Unit tests for pagination of primative objects. diff --git a/setup.py b/setup.py index 8ac2f03f5..601bb65b8 100755 --- a/setup.py +++ b/setup.py @@ -52,7 +52,7 @@ if sys.argv[-1] == 'publish': setup( - name='rest_framework', + name='djangorestframework', version=version, url='http://django-rest-framework.org', download_url='http://pypi.python.org/pypi/rest_framework/', @@ -63,7 +63,13 @@ setup( packages=get_packages('rest_framework'), package_data=get_package_data('rest_framework'), test_suite='rest_framework.runtests.runtests.main', - install_requires=[], + install_requires=[ + 'Django>=1.3.0', + 'django-filter', + ], + dependency_links = [ + 'git+https://github.com/alex/django-filter.git@0e4b3d703b31574922ab86fc78a86164aad0c1d0#egg=django-filter', + ], classifiers=[ 'Development Status :: 4 - Beta', 'Environment :: Web Environment', diff --git a/tox.ini b/tox.ini index bcfff6729..3596bbdc3 100644 --- a/tox.ini +++ b/tox.ini @@ -8,23 +8,29 @@ commands = {envpython} rest_framework/runtests/runtests.py [testenv:py2.7-django1.5] basepython = python2.7 deps = https://github.com/django/django/zipball/master + git+https://github.com/alex/django-filter.git@0e4b3d703b31574922ab86fc78a86164aad0c1d0#egg=django-filter [testenv:py2.7-django1.4] basepython = python2.7 deps = django==1.4.1 + git+https://github.com/alex/django-filter.git@0e4b3d703b31574922ab86fc78a86164aad0c1d0#egg=django-filter [testenv:py2.7-django1.3] basepython = python2.7 deps = django==1.3.3 + git+https://github.com/alex/django-filter.git@0e4b3d703b31574922ab86fc78a86164aad0c1d0#egg=django-filter [testenv:py2.6-django1.5] basepython = python2.6 deps = https://github.com/django/django/zipball/master + git+https://github.com/alex/django-filter.git@0e4b3d703b31574922ab86fc78a86164aad0c1d0#egg=django-filter [testenv:py2.6-django1.4] basepython = python2.6 deps = django==1.4.1 + git+https://github.com/alex/django-filter.git@0e4b3d703b31574922ab86fc78a86164aad0c1d0#egg=django-filter [testenv:py2.6-django1.3] basepython = python2.6 deps = django==1.3.3 + git+https://github.com/alex/django-filter.git@0e4b3d703b31574922ab86fc78a86164aad0c1d0#egg=django-filter