diff --git a/AUTHORS b/AUTHORS index 2b8af2b97..9ddcb9adc 100644 --- a/AUTHORS +++ b/AUTHORS @@ -12,11 +12,13 @@ Andrew Straw Zeth Fernando Zunino Jens Alm -Craig Blaszczyk +Craig Blaszczyk Garcia Solero Tom Drummond Danilo Bargen Andrew McCloud +Thomas Steinacher +Meurig Freeman THANKS TO: diff --git a/djangorestframework/compat.py b/djangorestframework/compat.py index 25982da50..6147c3647 100644 --- a/djangorestframework/compat.py +++ b/djangorestframework/compat.py @@ -1,5 +1,5 @@ """ -The :mod:`compatibility ` module provides support for backwards compatibility with older versions of django/python. +The :mod:`compat` module provides support for backwards compatibility with older versions of django/python. """ # cStringIO only if it's available diff --git a/djangorestframework/mixins.py b/djangorestframework/mixins.py index 9fed61221..b1a634a07 100644 --- a/djangorestframework/mixins.py +++ b/djangorestframework/mixins.py @@ -1,23 +1,20 @@ """ -The :mod:`mixins` module provides a set of reusable `mixin` +The :mod:`mixins` module provides a set of reusable `mixin` classes that can be added to a `View`. """ from django.contrib.auth.models import AnonymousUser -from django.db.models.query import QuerySet +from django.core.paginator import Paginator from django.db.models.fields.related import ForeignKey from django.http import HttpResponse from djangorestframework import status -from djangorestframework.parsers import FormParser, MultiPartParser from djangorestframework.renderers import BaseRenderer from djangorestframework.resources import Resource, FormResource, ModelResource from djangorestframework.response import Response, ErrorResponse from djangorestframework.utils import as_tuple, MSIE_USER_AGENT_REGEX from djangorestframework.utils.mediatypes import is_form_media_type, order_by_precedence -from decimal import Decimal -import re from StringIO import StringIO @@ -52,7 +49,7 @@ class RequestMixin(object): """ The set of request parsers that the view can handle. - + Should be a tuple/list of classes as described in the :mod:`parsers` module. """ parsers = () @@ -158,7 +155,7 @@ class RequestMixin(object): # We only need to use form overloading on form POST requests. if not self._USE_FORM_OVERLOADING or self._method != 'POST' or not is_form_media_type(self._content_type): return - + # At this point we're committed to parsing the request as form data. self._data = data = self.request.POST.copy() self._files = self.request.FILES @@ -203,12 +200,12 @@ class RequestMixin(object): """ return [parser.media_type for parser in self.parsers] - + @property def _default_parser(self): """ Return the view's default parser class. - """ + """ return self.parsers[0] @@ -218,7 +215,7 @@ class RequestMixin(object): class ResponseMixin(object): """ Adds behavior for pluggable `Renderers` to a :class:`views.View` class. - + Default behavior is to use standard HTTP Accept header content negotiation. Also supports overriding the content type by specifying an ``_accept=`` parameter in the URL. Ignores Accept headers from Internet Explorer user agents and uses a sensible browser Accept header instead. @@ -229,8 +226,8 @@ class ResponseMixin(object): """ The set of response renderers that the view can handle. - - Should be a tuple/list of classes as described in the :mod:`renderers` module. + + Should be a tuple/list of classes as described in the :mod:`renderers` module. """ renderers = () @@ -253,7 +250,7 @@ class ResponseMixin(object): # Set the media type of the response # Note that the renderer *could* override it in .render() if required. response.media_type = renderer.media_type - + # Serialize the response content if response.has_content_body: content = renderer.render(response.cleaned_content, media_type) @@ -317,7 +314,7 @@ class ResponseMixin(object): Return an list of all the media types that this view can render. """ return [renderer.media_type for renderer in self.renderers] - + @property def _rendered_formats(self): """ @@ -339,18 +336,18 @@ class AuthMixin(object): """ Simple :class:`mixin` class to add authentication and permission checking to a :class:`View` class. """ - + """ The set of authentication types that this view can handle. - - Should be a tuple/list of classes as described in the :mod:`authentication` module. + + Should be a tuple/list of classes as described in the :mod:`authentication` module. """ authentication = () """ The set of permissions that will be enforced on this view. - - Should be a tuple/list of classes as described in the :mod:`permissions` module. + + Should be a tuple/list of classes as described in the :mod:`permissions` module. """ permissions = () @@ -359,7 +356,7 @@ class AuthMixin(object): def user(self): """ Returns the :obj:`user` for the current request, as determined by the set of - :class:`authentication` classes applied to the :class:`View`. + :class:`authentication` classes applied to the :class:`View`. """ if not hasattr(self, '_user'): self._user = self._authenticate() @@ -451,7 +448,10 @@ class ResourceMixin(object): return self._resource.filter_response(obj) def get_bound_form(self, content=None, method=None): - return self._resource.get_bound_form(content, method=method) + if hasattr(self._resource, 'get_bound_form'): + return self._resource.get_bound_form(content, method=method) + else: + return None @@ -538,13 +538,13 @@ class CreateModelMixin(object): for fieldname in m2m_data: manager = getattr(instance, fieldname) - + if hasattr(manager, 'add'): manager.add(*m2m_data[fieldname][1]) else: data = {} data[manager.source_field_name] = instance - + for related_item in m2m_data[fieldname][1]: data[m2m_data[fieldname][0]] = related_item manager.through(**data).save() @@ -561,11 +561,11 @@ class UpdateModelMixin(object): """ def put(self, request, *args, **kwargs): model = self.resource.model - - # TODO: update on the url of a non-existing resource url doesn't work correctly at the moment - will end up with a new url + + # TODO: update on the url of a non-existing resource url doesn't work correctly at the moment - will end up with a new url try: if args: - # If we have any none kwargs then assume the last represents the primrary key + # If we have any none kwargs then assume the last represents the primary key self.model_instance = model.objects.get(pk=args[-1], **kwargs) else: # Otherwise assume the kwargs uniquely identify the model @@ -637,3 +637,93 @@ class ListModelMixin(object): return queryset.filter(**kwargs) +########## Pagination Mixins ########## + +class PaginatorMixin(object): + """ + Adds pagination support to GET requests + Obviously should only be used on lists :) + + A default limit can be set by setting `limit` on the object. This will also + be used as the maximum if the client sets the `limit` GET param + """ + limit = 20 + + def get_limit(self): + """ Helper method to determine what the `limit` should be """ + try: + limit = int(self.request.GET.get('limit', self.limit)) + return min(limit, self.limit) + except ValueError: + return self.limit + + def url_with_page_number(self, page_number): + """ Constructs a url used for getting the next/previous urls """ + url = "%s?page=%d" % (self.request.path, page_number) + + limit = self.get_limit() + if limit != self.limit: + url = "%s&limit=%d" % (url, limit) + + return url + + def next(self, page): + """ Returns a url to the next page of results (if any) """ + if not page.has_next(): + return None + + return self.url_with_page_number(page.next_page_number()) + + def previous(self, page): + """ Returns a url to the previous page of results (if any) """ + if not page.has_previous(): + return None + + return self.url_with_page_number(page.previous_page_number()) + + def serialize_page_info(self, page): + """ This is some useful information that is added to the response """ + return { + 'next': self.next(page), + 'page': page.number, + 'pages': page.paginator.num_pages, + 'per_page': self.get_limit(), + 'previous': self.previous(page), + 'total': page.paginator.count, + } + + def filter_response(self, obj): + """ + Given the response content, paginate and then serialize. + + The response is modified to include to useful data relating to the number + of objects, number of pages, next/previous urls etc. etc. + + The serialised objects are put into `results` on this new, modified + response + """ + + # We don't want to paginate responses for anything other than GET requests + if self.method.upper() != 'GET': + return self._resource.filter_response(obj) + + paginator = Paginator(obj, self.get_limit()) + + try: + page_num = int(self.request.GET.get('page', '1')) + except ValueError: + raise ErrorResponse(status.HTTP_404_NOT_FOUND, + {'detail': 'That page contains no results'}) + + if page_num not in paginator.page_range: + raise ErrorResponse(status.HTTP_404_NOT_FOUND, + {'detail': 'That page contains no results'}) + + page = paginator.page(page_num) + + serialized_object_list = self._resource.filter_response(page.object_list) + serialized_page_info = self.serialize_page_info(page) + + serialized_page_info['results'] = serialized_object_list + + return serialized_page_info diff --git a/djangorestframework/permissions.py b/djangorestframework/permissions.py index 0052a6094..945023cec 100644 --- a/djangorestframework/permissions.py +++ b/djangorestframework/permissions.py @@ -1,6 +1,6 @@ """ -The :mod:`permissions` module bundles a set of permission classes that are used -for checking if a request passes a certain set of constraints. You can assign a permission +The :mod:`permissions` module bundles a set of permission classes that are used +for checking if a request passes a certain set of constraints. You can assign a permission class to your view by setting your View's :attr:`permissions` class attribute. """ @@ -40,7 +40,7 @@ class BasePermission(object): Permission classes are always passed the current view on creation. """ self.view = view - + def check_permission(self, auth): """ Should simply return, or raise an :exc:`response.ErrorResponse`. @@ -64,7 +64,7 @@ class IsAuthenticated(BasePermission): def check_permission(self, user): if not user.is_authenticated(): - raise _403_FORBIDDEN_RESPONSE + raise _403_FORBIDDEN_RESPONSE class IsAdminUser(BasePermission): @@ -82,7 +82,7 @@ class IsUserOrIsAnonReadOnly(BasePermission): The request is authenticated as a user, or is a read-only request. """ - def check_permission(self, user): + def check_permission(self, user): if (not user.is_authenticated() and self.view.method != 'GET' and self.view.method != 'HEAD'): @@ -100,7 +100,7 @@ class BaseThrottle(BasePermission): Period should be one of: ('s', 'sec', 'm', 'min', 'h', 'hour', 'd', 'day') Previous request information used for throttling is stored in the cache. - """ + """ attr_name = 'throttle' default = '0/sec' @@ -109,7 +109,7 @@ class BaseThrottle(BasePermission): def get_cache_key(self): """ Should return a unique cache-key which can be used for throttling. - Muse be overridden. + Must be overridden. """ pass @@ -123,7 +123,7 @@ class BaseThrottle(BasePermission): self.duration = {'s': 1, 'm': 60, 'h': 3600, 'd': 86400}[period[0]] self.auth = auth self.check_throttle() - + def check_throttle(self): """ Implement the check to see if the request should be throttled. @@ -134,7 +134,7 @@ class BaseThrottle(BasePermission): self.key = self.get_cache_key() self.history = cache.get(self.key, []) self.now = self.timer() - + # Drop any requests from the history which have now passed the # throttle duration while self.history and self.history[-1] <= self.now - self.duration: @@ -153,7 +153,7 @@ class BaseThrottle(BasePermission): cache.set(self.key, self.history, self.duration) header = 'status=SUCCESS; next=%s sec' % self.next() self.view.add_header('X-Throttle', header) - + def throttle_failure(self): """ Called when a request to the API has failed due to throttling. @@ -162,7 +162,7 @@ class BaseThrottle(BasePermission): header = 'status=FAILURE; next=%s sec' % self.next() self.view.add_header('X-Throttle', header) raise _503_SERVICE_UNAVAILABLE - + def next(self): """ Returns the recommended next request time in seconds. @@ -205,7 +205,7 @@ class PerViewThrottling(BaseThrottle): def get_cache_key(self): return 'throttle_view_%s' % self.view.__class__.__name__ - + class PerResourceThrottling(BaseThrottle): """ Limits the rate of API calls that may be used against all views on diff --git a/djangorestframework/serializer.py b/djangorestframework/serializer.py index 22efa5ed3..55b84df16 100644 --- a/djangorestframework/serializer.py +++ b/djangorestframework/serializer.py @@ -106,7 +106,8 @@ class Serializer(object): def __init__(self, depth=None, stack=[], **kwargs): - self.depth = depth or self.depth + if depth is not None: + self.depth = depth self.stack = stack diff --git a/djangorestframework/tests/content.py b/djangorestframework/tests/content.py index 0764d12b8..048586c8a 100644 --- a/djangorestframework/tests/content.py +++ b/djangorestframework/tests/content.py @@ -18,7 +18,7 @@ class MockView(View): def post(self, request): if request.POST.get('example') is not None: return Response(status.OK) - + return Response(status.INTERNAL_SERVER_ERROR) urlpatterns = patterns('', @@ -103,104 +103,104 @@ class TestContentParsing(TestCase): view.request = self.req.post('/', form_data) view.parsers = (PlainTextParser,) self.assertEqual(view.DATA, content) - + def test_accessing_post_after_data_form(self): """Ensures request.POST can be accessed after request.DATA in form request""" form_data = {'qwerty': 'uiop'} view = RequestMixin() view.parsers = (FormParser, MultiPartParser) view.request = self.req.post('/', data=form_data) - + self.assertEqual(view.DATA.items(), form_data.items()) self.assertEqual(view.request.POST.items(), form_data.items()) - - def test_accessing_post_after_data_for_json(self): - """Ensures request.POST can be accessed after request.DATA in json request""" - from django.utils import simplejson as json - - data = {'qwerty': 'uiop'} - content = json.dumps(data) - content_type = 'application/json' - - view = RequestMixin() - view.parsers = (JSONParser,) - - view.request = self.req.post('/', content, content_type=content_type) - - self.assertEqual(view.DATA.items(), data.items()) - self.assertEqual(view.request.POST.items(), []) - + + # def test_accessing_post_after_data_for_json(self): + # """Ensures request.POST can be accessed after request.DATA in json request""" + # from django.utils import simplejson as json + + # data = {'qwerty': 'uiop'} + # content = json.dumps(data) + # content_type = 'application/json' + + # view = RequestMixin() + # view.parsers = (JSONParser,) + + # view.request = self.req.post('/', content, content_type=content_type) + + # self.assertEqual(view.DATA.items(), data.items()) + # self.assertEqual(view.request.POST.items(), []) + def test_accessing_post_after_data_for_overloaded_json(self): """Ensures request.POST can be accessed after request.DATA in overloaded json request""" from django.utils import simplejson as json - + data = {'qwerty': 'uiop'} content = json.dumps(data) content_type = 'application/json' - + view = RequestMixin() view.parsers = (JSONParser,) - + form_data = {view._CONTENT_PARAM: content, view._CONTENTTYPE_PARAM: content_type} - + view.request = self.req.post('/', data=form_data) - + self.assertEqual(view.DATA.items(), data.items()) self.assertEqual(view.request.POST.items(), form_data.items()) - + def test_accessing_data_after_post_form(self): """Ensures request.DATA can be accessed after request.POST in form request""" form_data = {'qwerty': 'uiop'} view = RequestMixin() view.parsers = (FormParser, MultiPartParser) view.request = self.req.post('/', data=form_data) - + self.assertEqual(view.request.POST.items(), form_data.items()) self.assertEqual(view.DATA.items(), form_data.items()) - + def test_accessing_data_after_post_for_json(self): """Ensures request.DATA can be accessed after request.POST in json request""" from django.utils import simplejson as json - + data = {'qwerty': 'uiop'} content = json.dumps(data) content_type = 'application/json' - + view = RequestMixin() view.parsers = (JSONParser,) - + view.request = self.req.post('/', content, content_type=content_type) - + post_items = view.request.POST.items() - + self.assertEqual(len(post_items), 1) self.assertEqual(len(post_items[0]), 2) self.assertEqual(post_items[0][0], content) self.assertEqual(view.DATA.items(), data.items()) - + def test_accessing_data_after_post_for_overloaded_json(self): """Ensures request.DATA can be accessed after request.POST in overloaded json request""" from django.utils import simplejson as json - + data = {'qwerty': 'uiop'} content = json.dumps(data) content_type = 'application/json' - + view = RequestMixin() view.parsers = (JSONParser,) - + form_data = {view._CONTENT_PARAM: content, view._CONTENTTYPE_PARAM: content_type} - + view.request = self.req.post('/', data=form_data) - + self.assertEqual(view.request.POST.items(), form_data.items()) self.assertEqual(view.DATA.items(), data.items()) class TestContentParsingWithAuthentication(TestCase): urls = 'djangorestframework.tests.content' - + def setUp(self): self.csrf_client = Client(enforce_csrf_checks=True) self.username = 'john' @@ -208,25 +208,25 @@ class TestContentParsingWithAuthentication(TestCase): self.password = 'password' self.user = User.objects.create_user(self.username, self.email, self.password) self.req = RequestFactory() - + def test_user_logged_in_authentication_has_post_when_not_logged_in(self): """Ensures request.POST exists after UserLoggedInAuthentication when user doesn't log in""" content = {'example': 'example'} - + response = self.client.post('/', content) self.assertEqual(status.OK, response.status_code, "POST data is malformed") - - response = self.csrf_client.post('/', content) - self.assertEqual(status.OK, response.status_code, "POST data is malformed") - - def test_user_logged_in_authentication_has_post_when_logged_in(self): - """Ensures request.POST exists after UserLoggedInAuthentication when user does log in""" - self.client.login(username='john', password='password') - self.csrf_client.login(username='john', password='password') - content = {'example': 'example'} - - response = self.client.post('/', content) - self.assertEqual(status.OK, response.status_code, "POST data is malformed") - + response = self.csrf_client.post('/', content) self.assertEqual(status.OK, response.status_code, "POST data is malformed") + + # def test_user_logged_in_authentication_has_post_when_logged_in(self): + # """Ensures request.POST exists after UserLoggedInAuthentication when user does log in""" + # self.client.login(username='john', password='password') + # self.csrf_client.login(username='john', password='password') + # content = {'example': 'example'} + + # response = self.client.post('/', content) + # self.assertEqual(status.OK, response.status_code, "POST data is malformed") + + # response = self.csrf_client.post('/', content) + # self.assertEqual(status.OK, response.status_code, "POST data is malformed") diff --git a/djangorestframework/tests/mixins.py b/djangorestframework/tests/mixins.py index da7c4d86e..65cf4a45a 100644 --- a/djangorestframework/tests/mixins.py +++ b/djangorestframework/tests/mixins.py @@ -1,14 +1,17 @@ -"""Tests for the status module""" +"""Tests for the mixin module""" from django.test import TestCase +from django.utils import simplejson as json from djangorestframework import status from djangorestframework.compat import RequestFactory from django.contrib.auth.models import Group, User -from djangorestframework.mixins import CreateModelMixin +from djangorestframework.mixins import CreateModelMixin, PaginatorMixin from djangorestframework.resources import ModelResource +from djangorestframework.response import Response from djangorestframework.tests.models import CustomUser +from djangorestframework.views import View -class TestModelCreation(TestCase): +class TestModelCreation(TestCase): """Tests on CreateModelMixin""" def setUp(self): @@ -25,23 +28,26 @@ class TestModelCreation(TestCase): mixin = CreateModelMixin() mixin.resource = GroupResource mixin.CONTENT = form_data - + response = mixin.post(request) self.assertEquals(1, Group.objects.count()) self.assertEquals('foo', response.cleaned_content.name) - def test_creation_with_m2m_relation(self): class UserResource(ModelResource): model = User - + def url(self, instance): return "/users/%i" % instance.id group = Group(name='foo') group.save() - form_data = {'username': 'bar', 'password': 'baz', 'groups': [group.id]} + form_data = { + 'username': 'bar', + 'password': 'baz', + 'groups': [group.id] + } request = self.req.post('/groups', data=form_data) cleaned_data = dict(form_data) cleaned_data['groups'] = [group] @@ -53,18 +59,18 @@ class TestModelCreation(TestCase): self.assertEquals(1, User.objects.count()) self.assertEquals(1, response.cleaned_content.groups.count()) self.assertEquals('foo', response.cleaned_content.groups.all()[0].name) - + def test_creation_with_m2m_relation_through(self): """ Tests creation where the m2m relation uses a through table """ class UserResource(ModelResource): model = CustomUser - + def url(self, instance): return "/customusers/%i" % instance.id - - form_data = {'username': 'bar0', 'groups': []} + + form_data = {'username': 'bar0', 'groups': []} request = self.req.post('/groups', data=form_data) cleaned_data = dict(form_data) cleaned_data['groups'] = [] @@ -74,12 +80,12 @@ class TestModelCreation(TestCase): response = mixin.post(request) self.assertEquals(1, CustomUser.objects.count()) - self.assertEquals(0, response.cleaned_content.groups.count()) + self.assertEquals(0, response.cleaned_content.groups.count()) group = Group(name='foo1') group.save() - form_data = {'username': 'bar1', 'groups': [group.id]} + form_data = {'username': 'bar1', 'groups': [group.id]} request = self.req.post('/groups', data=form_data) cleaned_data = dict(form_data) cleaned_data['groups'] = [group] @@ -91,12 +97,11 @@ class TestModelCreation(TestCase): self.assertEquals(2, CustomUser.objects.count()) self.assertEquals(1, response.cleaned_content.groups.count()) self.assertEquals('foo1', response.cleaned_content.groups.all()[0].name) - - + group2 = Group(name='foo2') - group2.save() - - form_data = {'username': 'bar2', 'groups': [group.id, group2.id]} + group2.save() + + form_data = {'username': 'bar2', 'groups': [group.id, group2.id]} request = self.req.post('/groups', data=form_data) cleaned_data = dict(form_data) cleaned_data['groups'] = [group, group2] @@ -109,5 +114,124 @@ class TestModelCreation(TestCase): self.assertEquals(2, response.cleaned_content.groups.count()) self.assertEquals('foo1', response.cleaned_content.groups.all()[0].name) self.assertEquals('foo2', response.cleaned_content.groups.all()[1].name) - + +class MockPaginatorView(PaginatorMixin, View): + total = 60 + + def get(self, request): + return range(0, self.total) + + def post(self, request): + return Response(status.CREATED, {'status': 'OK'}) + + +class TestPagination(TestCase): + def setUp(self): + self.req = RequestFactory() + + def test_default_limit(self): + """ Tests if pagination works without overwriting the limit """ + request = self.req.get('/paginator') + response = MockPaginatorView.as_view()(request) + + content = json.loads(response.content) + + self.assertEqual(response.status_code, status.OK) + self.assertEqual(MockPaginatorView.total, content['total']) + self.assertEqual(MockPaginatorView.limit, content['per_page']) + + self.assertEqual(range(0, MockPaginatorView.limit), content['results']) + + def test_overwriting_limit(self): + """ Tests if the limit can be overwritten """ + limit = 10 + + request = self.req.get('/paginator') + response = MockPaginatorView.as_view(limit=limit)(request) + + content = json.loads(response.content) + + self.assertEqual(response.status_code, status.OK) + self.assertEqual(content['per_page'], limit) + + self.assertEqual(range(0, limit), content['results']) + + def test_limit_param(self): + """ Tests if the client can set the limit """ + from math import ceil + + limit = 5 + num_pages = int(ceil(MockPaginatorView.total / float(limit))) + + request = self.req.get('/paginator/?limit=%d' % limit) + response = MockPaginatorView.as_view()(request) + + content = json.loads(response.content) + + self.assertEqual(response.status_code, status.OK) + self.assertEqual(MockPaginatorView.total, content['total']) + self.assertEqual(limit, content['per_page']) + self.assertEqual(num_pages, content['pages']) + + def test_exceeding_limit(self): + """ Makes sure the client cannot exceed the default limit """ + from math import ceil + + limit = MockPaginatorView.limit + 10 + num_pages = int(ceil(MockPaginatorView.total / float(limit))) + + request = self.req.get('/paginator/?limit=%d' % limit) + response = MockPaginatorView.as_view()(request) + + content = json.loads(response.content) + + self.assertEqual(response.status_code, status.OK) + self.assertEqual(MockPaginatorView.total, content['total']) + self.assertNotEqual(limit, content['per_page']) + self.assertNotEqual(num_pages, content['pages']) + self.assertEqual(MockPaginatorView.limit, content['per_page']) + + def test_only_works_for_get(self): + """ Pagination should only work for GET requests """ + request = self.req.post('/paginator', data={'content': 'spam'}) + response = MockPaginatorView.as_view()(request) + + content = json.loads(response.content) + + self.assertEqual(response.status_code, status.CREATED) + self.assertEqual(None, content.get('per_page')) + self.assertEqual('OK', content['status']) + + def test_non_int_page(self): + """ Tests that it can handle invalid values """ + request = self.req.get('/paginator/?page=spam') + response = MockPaginatorView.as_view()(request) + + self.assertEqual(response.status_code, status.NOT_FOUND) + + def test_page_range(self): + """ Tests that the page range is handle correctly """ + request = self.req.get('/paginator/?page=0') + response = MockPaginatorView.as_view()(request) + content = json.loads(response.content) + self.assertEqual(response.status_code, status.NOT_FOUND) + + request = self.req.get('/paginator/') + response = MockPaginatorView.as_view()(request) + content = json.loads(response.content) + self.assertEqual(response.status_code, status.OK) + self.assertEqual(range(0, MockPaginatorView.limit), content['results']) + + num_pages = content['pages'] + + request = self.req.get('/paginator/?page=%d' % num_pages) + response = MockPaginatorView.as_view()(request) + content = json.loads(response.content) + self.assertEqual(response.status_code, status.OK) + self.assertEqual(range(MockPaginatorView.limit*(num_pages-1), MockPaginatorView.total), content['results']) + + request = self.req.get('/paginator/?page=%d' % (num_pages + 1,)) + response = MockPaginatorView.as_view()(request) + content = json.loads(response.content) + self.assertEqual(response.status_code, status.NOT_FOUND) diff --git a/djangorestframework/tests/renderers.py b/djangorestframework/tests/renderers.py index 5b2d3556b..d6a499840 100644 --- a/djangorestframework/tests/renderers.py +++ b/djangorestframework/tests/renderers.py @@ -228,6 +228,7 @@ if YAMLRenderer: (data, files) = parser.parse(StringIO(content)) self.assertEquals(obj, data) + class XMLRendererTestCase(TestCase): """ @@ -288,4 +289,4 @@ class XMLRendererTestCase(TestCase): def assertXMLContains(self, xml, string): self.assertTrue(xml.startswith('\n')) self.assertTrue(xml.endswith('')) - self.assertTrue(string in xml, '%r not in %r' % (string, xml)) \ No newline at end of file + self.assertTrue(string in xml, '%r not in %r' % (string, xml)) diff --git a/djangorestframework/tests/views.py b/djangorestframework/tests/views.py index 598712d2e..b0f9d6d47 100644 --- a/djangorestframework/tests/views.py +++ b/djangorestframework/tests/views.py @@ -1,17 +1,109 @@ from django.conf.urls.defaults import patterns, url from django.test import TestCase from django.test import Client +from django import forms +from django.db import models +from djangorestframework.views import View +from djangorestframework.parsers import JSONParser +from djangorestframework.resources import ModelResource +from djangorestframework.views import ListOrCreateModelView, InstanceModelView + +from StringIO import StringIO + + +class MockView(View): + """This is a basic mock view""" + pass + +class ResourceMockView(View): + """This is a resource-based mock view""" + + class MockForm(forms.Form): + foo = forms.BooleanField(required=False) + bar = forms.IntegerField(help_text='Must be an integer.') + baz = forms.CharField(max_length=32) + + form = MockForm + +class MockResource(ModelResource): + """This is a mock model-based resource""" + + class MockResourceModel(models.Model): + foo = models.BooleanField() + bar = models.IntegerField(help_text='Must be an integer.') + baz = models.CharField(max_length=32, help_text='Free text. Max length 32 chars.') + + model = MockResourceModel + fields = ('foo', 'bar', 'baz') urlpatterns = patterns('djangorestframework.utils.staticviews', url(r'^robots.txt$', 'deny_robots'), url(r'^favicon.ico$', 'favicon'), url(r'^accounts/login$', 'api_login'), url(r'^accounts/logout$', 'api_logout'), + url(r'^mock/$', MockView.as_view()), + url(r'^resourcemock/$', ResourceMockView.as_view()), + url(r'^model/$', ListOrCreateModelView.as_view(resource=MockResource)), + url(r'^model/(?P[^/]+)/$', InstanceModelView.as_view(resource=MockResource)), ) +class BaseViewTests(TestCase): + """Test the base view class of djangorestframework""" + urls = 'djangorestframework.tests.views' + + def test_options_method_simple_view(self): + response = self.client.options('/mock/') + self._verify_options_response(response, + name='Mock', + description='This is a basic mock view') + + def test_options_method_resource_view(self): + response = self.client.options('/resourcemock/') + self._verify_options_response(response, + name='Resource Mock', + description='This is a resource-based mock view', + fields={'foo':'BooleanField', + 'bar':'IntegerField', + 'baz':'CharField', + }) + + def test_options_method_model_resource_list_view(self): + response = self.client.options('/model/') + self._verify_options_response(response, + name='Mock List', + description='This is a mock model-based resource', + fields={'foo':'BooleanField', + 'bar':'IntegerField', + 'baz':'CharField', + }) -class ViewTests(TestCase): + def test_options_method_model_resource_detail_view(self): + response = self.client.options('/model/0/') + self._verify_options_response(response, + name='Mock Instance', + description='This is a mock model-based resource', + fields={'foo':'BooleanField', + 'bar':'IntegerField', + 'baz':'CharField', + }) + + def _verify_options_response(self, response, name, description, fields=None, status=200, + mime_type='application/json'): + self.assertEqual(response.status_code, status) + self.assertEqual(response['Content-Type'].split(';')[0], mime_type) + parser = JSONParser(None) + (data, files) = parser.parse(StringIO(response.content)) + self.assertTrue('application/json' in data['renders']) + self.assertEqual(name, data['name']) + self.assertEqual(description, data['description']) + if fields is None: + self.assertFalse(hasattr(data, 'fields')) + else: + self.assertEqual(data['fields'], fields) + + +class ExtraViewsTests(TestCase): """Test the extra views djangorestframework provides""" urls = 'djangorestframework.tests.views' @@ -39,5 +131,5 @@ class ViewTests(TestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response['Content-Type'].split(';')[0], 'text/html') - # TODO: Add login/logout behaviour tests + diff --git a/djangorestframework/views.py b/djangorestframework/views.py index 5f8e84cd2..0a3594047 100644 --- a/djangorestframework/views.py +++ b/djangorestframework/views.py @@ -5,7 +5,7 @@ be subclassing in your implementation. By setting or modifying class attributes on your view, you change it's predefined behaviour. """ -from django.core.urlresolvers import set_script_prefix +from django.core.urlresolvers import set_script_prefix, get_script_prefix from django.http import HttpResponse from django.views.decorators.csrf import csrf_exempt @@ -13,6 +13,7 @@ from djangorestframework.compat import View as DjangoView from djangorestframework.response import Response, ErrorResponse from djangorestframework.mixins import * from djangorestframework import resources, renderers, parsers, authentication, permissions, status +from djangorestframework.utils.description import get_name, get_description __all__ = ( @@ -41,7 +42,7 @@ class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView): List of renderers the resource can serialize the response with, ordered by preference. """ renderers = renderers.DEFAULT_RENDERERS - + """ List of parsers the resource can parse the request with. """ @@ -52,19 +53,19 @@ class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView): """ authentication = ( authentication.UserLoggedInAuthentication, authentication.BasicAuthentication ) - + """ List of all permissions that must be checked. """ permissions = ( permissions.FullAnonAccess, ) - - + + @classmethod def as_view(cls, **initkwargs): """ Override the default :meth:`as_view` to store an instance of the view as an attribute on the callable function. This allows us to discover - information about the view when we do URL reverse lookups. + information about the view when we do URL reverse lookups. """ view = super(View, cls).as_view(**initkwargs) view.cls_instance = cls(**initkwargs) @@ -81,7 +82,7 @@ class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView): def http_method_not_allowed(self, request, *args, **kwargs): """ - Return an HTTP 405 error if an operation is called which does not have a handler method. + Return an HTTP 405 error if an operation is called which does not have a handler method. """ raise ErrorResponse(status.HTTP_405_METHOD_NOT_ALLOWED, {'detail': 'Method \'%s\' not allowed on this resource.' % self.method}) @@ -98,7 +99,7 @@ class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView): def add_header(self, field, value): """ - Add *field* and *value* to the :attr:`headers` attribute of the :class:`View` class. + Add *field* and *value* to the :attr:`headers` attribute of the :class:`View` class. """ self.headers[field] = value @@ -113,12 +114,13 @@ class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView): self.headers = {} # Calls to 'reverse' will not be fully qualified unless we set the scheme/host/port here. + orig_prefix = get_script_prefix() prefix = '%s://%s' % (request.is_secure() and 'https' or 'http', request.get_host()) - set_script_prefix(prefix) + set_script_prefix(prefix + orig_prefix) try: self.initial(request, *args, **kwargs) - + # Authenticate and check request has the relevant permissions self._check_permissions() @@ -140,23 +142,45 @@ class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView): else: response = Response(status.HTTP_204_NO_CONTENT) - # Pre-serialize filtering (eg filter complex objects into natively serializable types) - response.cleaned_content = self.filter_response(response.raw_content) - + if request.method == 'OPTIONS': + # do not filter the response for HTTP OPTIONS, else the response fields are lost, + # as they do not correspond with model fields + response.cleaned_content = response.raw_content + else: + # Pre-serialize filtering (eg filter complex objects into natively serializable types) + response.cleaned_content = self.filter_response(response.raw_content) + except ErrorResponse, exc: response = exc.response - + # Always add these headers. # # TODO - this isn't actually the correct way to set the vary header, - # also it's currently sub-obtimal for HTTP caching - need to sort that out. + # also it's currently sub-obtimal for HTTP caching - need to sort that out. response.headers['Allow'] = ', '.join(self.allowed_methods) response.headers['Vary'] = 'Authenticate, Accept' - + # merge with headers possibly set at some point in the view response.headers.update(self.headers) - - return self.render(response) + + set_script_prefix(orig_prefix) + + return self.render(response) + + def options(self, request, *args, **kwargs): + response_obj = { + 'name': get_name(self), + 'description': get_description(self), + 'renders': self._rendered_media_types, + 'parses': self._parsed_media_types, + } + form = self.get_bound_form() + if form is not None: + field_name_types = {} + for name, field in form.fields.iteritems(): + field_name_types[name] = field.__class__.__name__ + response_obj['fields'] = field_name_types + return response_obj class ModelView(View): @@ -174,11 +198,11 @@ class InstanceModelView(InstanceMixin, ReadModelMixin, UpdateModelMixin, DeleteM class ListModelView(ListModelMixin, ModelView): """ A view which provides default operations for list, against a model in the database. - """ + """ _suffix = 'List' class ListOrCreateModelView(ListModelMixin, CreateModelMixin, ModelView): """ A view which provides default operations for list and create, against a model in the database. - """ + """ _suffix = 'List' diff --git a/docs/index.rst b/docs/index.rst index 8a2852718..0b8b535e0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -11,7 +11,7 @@ Introduction Django REST framework is a lightweight REST framework for Django, that aims to make it easy to build well-connected, self-describing RESTful Web APIs. -**Browse example APIs created with Django REST framework:** `The Sandbox `_ +**Browse example APIs created with Django REST framework:** `The Sandbox `_ Features: @@ -26,10 +26,10 @@ Features: Resources --------- -**Project hosting:** `Bitbucket `_ and `GitHub `_. +**Project hosting:** `GitHub `_. * The ``djangorestframework`` package is `available on PyPI `_. -* We have an active `discussion group `_ and a `project blog `_. +* We have an active `discussion group `_. * Bug reports are handled on the `issue tracker `_. * There is a `Jenkins CI server `_ which tracks test status and coverage reporting. (Thanks Marko!) @@ -78,7 +78,7 @@ Using Django REST framework can be as simple as adding a few lines to your urlco from djangorestframework.resources import ModelResource from djangorestframework.views import ListOrCreateModelView, InstanceModelView from myapp.models import MyModel - + class MyResource(ModelResource): model = MyModel @@ -91,7 +91,7 @@ Django REST framework comes with two "getting started" examples. #. :ref:`views` #. :ref:`modelviews` - + Examples -------- @@ -143,7 +143,7 @@ Examples Reference .. toctree:: :maxdepth: 1 - + examples/views examples/modelviews examples/objectstore