diff --git a/README.md b/README.md index d710f3d4a..814da6e92 100644 --- a/README.md +++ b/README.md @@ -26,10 +26,9 @@ The initial aim is to provide a single full-time position on REST framework. -

-*Many thanks to all our [wonderful sponsors][sponsors], and in particular to our premium backers, [Rover](http://jobs.rover.com/), [Sentry](https://getsentry.com/welcome/), [Stream](https://getstream.io/?utm_source=drf&utm_medium=banner&utm_campaign=drf), [Machinalis](https://hello.machinalis.co.uk/), [Rollbar](https://rollbar.com), and [MicroPyramid](https://micropyramid.com/django-rest-framework-development-services/).* +*Many thanks to all our [wonderful sponsors][sponsors], and in particular to our premium backers, [Rover](http://jobs.rover.com/), [Sentry](https://getsentry.com/welcome/), [Stream](https://getstream.io/?utm_source=drf&utm_medium=banner&utm_campaign=drf), [Machinalis](https://hello.machinalis.co.uk/), and [Rollbar](https://rollbar.com/).* --- diff --git a/docs/api-guide/authentication.md b/docs/api-guide/authentication.md index 05b8523f8..a7a24029b 100644 --- a/docs/api-guide/authentication.md +++ b/docs/api-guide/authentication.md @@ -222,6 +222,21 @@ It is also possible to create Tokens manually through admin interface. In case y TokenAdmin.raw_id_fields = ('user',) +#### Using Django manage.py command + +Since version 3.6.4 it's possible to generate a user token using the following command: + + ./manage.py drf_create_token + +this command will return the API token for the given user, creating it if it doesn't exist: + + Generated token 9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b for user user1 + +In case you want to regenerate the token (for example if it has been compromised or leaked) you can pass an additional parameter: + + ./manage.py drf_create_token -r + + ## SessionAuthentication This authentication scheme uses Django's default session backend for authentication. Session authentication is appropriate for AJAX clients that are running in the same session context as your website. @@ -239,6 +254,28 @@ If you're using an AJAX style API with SessionAuthentication, you'll need to mak CSRF validation in REST framework works slightly differently to standard Django due to the need to support both session and non-session based authentication to the same views. This means that only authenticated requests require CSRF tokens, and anonymous requests may be sent without CSRF tokens. This behaviour is not suitable for login views, which should always have CSRF validation applied. + +## RemoteUserAuthentication + +This authentication scheme allows you to delegate authentication to your web server, which sets the `REMOTE_USER` +environment variable. + +To use it, you must have `django.contrib.auth.backends.RemoteUserBackend` (or a subclass) in your +`AUTHENTICATION_BACKENDS` setting. By default, `RemoteUserBackend` creates `User` objects for usernames that don't +already exist. To change this and other behaviour, consult the +[Django documentation](https://docs.djangoproject.com/en/stable/howto/auth-remote-user/). + +If successfully authenticated, `RemoteUserAuthentication` provides the following credentials: + +* `request.user` will be a Django `User` instance. +* `request.auth` will be `None`. + +Consult your web server's documentation for information about configuring an authentication method, e.g.: + +* [Apache Authentication How-To](https://httpd.apache.org/docs/2.4/howto/auth.html) +* [NGINX (Restricting Access)](https://www.nginx.com/resources/admin-guide/#restricting_access) + + # Custom authentication To implement a custom authentication scheme, subclass `BaseAuthentication` and override the `.authenticate(self, request)` method. The method should return a two-tuple of `(user, auth)` if authentication succeeds, or `None` otherwise. diff --git a/docs/api-guide/fields.md b/docs/api-guide/fields.md index 28d06f25c..b93ac389b 100644 --- a/docs/api-guide/fields.md +++ b/docs/api-guide/fields.md @@ -641,7 +641,7 @@ The `.fail()` method is a shortcut for raising `ValidationError` that takes a me return Color(red, green, blue) -This style keeps you error messages more cleanly separated from your code, and should be preferred. +This style keeps your error messages cleaner and more separated from your code, and should be preferred. # Third party packages diff --git a/docs/api-guide/pagination.md b/docs/api-guide/pagination.md index 888390018..d767e45de 100644 --- a/docs/api-guide/pagination.md +++ b/docs/api-guide/pagination.md @@ -242,29 +242,6 @@ We'd then need to setup the custom class in our configuration: Note that if you care about how the ordering of keys is displayed in responses in the browsable API you might choose to use an `OrderedDict` when constructing the body of paginated responses, but this is optional. -## Header based pagination - -Let's modify the built-in `PageNumberPagination` style, so that instead of include the pagination links in the body of the response, we'll instead include a `Link` header, in a [similar style to the GitHub API][github-link-pagination]. - - class LinkHeaderPagination(pagination.PageNumberPagination): - def get_paginated_response(self, data): - next_url = self.get_next_link() - previous_url = self.get_previous_link() - - if next_url is not None and previous_url is not None: - link = '<{next_url}>; rel="next", <{previous_url}>; rel="prev"' - elif next_url is not None: - link = '<{next_url}>; rel="next"' - elif previous_url is not None: - link = '<{previous_url}>; rel="prev"' - else: - link = '' - - link = link.format(next_url=next_url, previous_url=previous_url) - headers = {'Link': link} if link else {} - - return Response(data, headers=headers) - ## Using your custom pagination class To have your custom pagination class be used by default, use the `DEFAULT_PAGINATION_CLASS` setting: @@ -328,10 +305,15 @@ The [`DRF-extensions` package][drf-extensions] includes a [`PaginateByMaxMixin` The [`drf-proxy-pagination` package][drf-proxy-pagination] includes a `ProxyPagination` class which allows to choose pagination class with a query parameter. +## link-header-pagination + +The [`django-rest-framework-link-header-pagination` package][drf-link-header-pagination] includes a `LinkHeaderPagination` class which provides pagination via an HTTP `Link` header as desribed in [Github's developer documentation](github-link-pagination). + [cite]: https://docs.djangoproject.com/en/stable/topics/pagination/ [github-link-pagination]: https://developer.github.com/guides/traversing-with-pagination/ [link-header]: ../img/link-header-pagination.png [drf-extensions]: http://chibisov.github.io/drf-extensions/docs/ [paginate-by-max-mixin]: http://chibisov.github.io/drf-extensions/docs/#paginatebymaxmixin [drf-proxy-pagination]: https://github.com/tuffnatty/drf-proxy-pagination +[drf-link-header-pagination]: https://github.com/tbeadle/django-rest-framework-link-header-pagination [disqus-cursor-api]: http://cramer.io/2011/03/08/building-cursors-for-the-disqus-api diff --git a/docs/api-guide/testing.md b/docs/api-guide/testing.md index 753c77e2f..2b93080b3 100644 --- a/docs/api-guide/testing.md +++ b/docs/api-guide/testing.md @@ -82,7 +82,7 @@ For example, when forcibly authenticating using a token, you might do something user = User.objects.get(username='olivia') request = factory.get('/accounts/django-superstars/') - force_authenticate(request, user=user, token=user.token) + force_authenticate(request, user=user, token=user.auth_token) --- diff --git a/docs/api-guide/views.md b/docs/api-guide/views.md index c0c4f67e4..4fa36d0fc 100644 --- a/docs/api-guide/views.md +++ b/docs/api-guide/views.md @@ -23,6 +23,7 @@ For example: from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import authentication, permissions + from django.contrib.auth.models import User class ListUsers(APIView): """ diff --git a/docs/index.md b/docs/index.md index 23433a6b7..fbb5bc1bb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -75,11 +75,10 @@ continued development by **[signing up for a paid plan][funding]**.
  • Stream
  • Machinalis
  • Rollbar
  • -
  • MicroPyramid
  • -*Many thanks to all our [wonderful sponsors][sponsors], and in particular to our premium backers, [Rover](http://jobs.rover.com/), [Sentry](https://getsentry.com/welcome/), [Stream](https://getstream.io/?utm_source=drf&utm_medium=banner&utm_campaign=drf), [Machinalis](https://hello.machinalis.co.uk/), [Rollbar](https://rollbar.com), and [MicroPyramid](https://micropyramid.com/django-rest-framework-development-services/).* +*Many thanks to all our [wonderful sponsors][sponsors], and in particular to our premium backers, [Rover](http://jobs.rover.com/), [Sentry](https://getsentry.com/welcome/), [Stream](https://getstream.io/?utm_source=drf&utm_medium=banner&utm_campaign=drf), [Machinalis](https://hello.machinalis.co.uk/), and [Rollbar](https://rollbar.com).* --- diff --git a/docs/topics/api-clients.md b/docs/topics/api-clients.md index 6e0df69e5..73434416d 100644 --- a/docs/topics/api-clients.md +++ b/docs/topics/api-clients.md @@ -408,7 +408,7 @@ The `coreapi` library, and the `schema` object will now both be available on the In order to interact with the API you'll need a client instance. - var client = coreapi.Client() + var client = new coreapi.Client() Typically you'll also want to provide some authentication credentials when instantiating the client. @@ -423,7 +423,7 @@ the user to login, and then instantiate a client using session authentication: csrfCookieName: 'csrftoken', csrfHeaderName: 'X-CSRFToken' }) - let client = coreapi.Client({auth: auth}) + let client = new coreapi.Client({auth: auth}) The authentication scheme will handle including a CSRF header in any outgoing requests for unsafe HTTP methods. @@ -437,7 +437,7 @@ The `TokenAuthentication` class can be used to support REST framework's built-in scheme: 'JWT' token: '' }) - let client = coreapi.Client({auth: auth}) + let client = new coreapi.Client({auth: auth}) When using TokenAuthentication you'll probably need to implement a login flow using the CoreAPI client. @@ -448,7 +448,7 @@ request to an "obtain token" endpoint For example, using the "Django REST framework JWT" package // Setup some globally accessible state - window.client = coreapi.Client() + window.client = new coreapi.Client() window.loggedIn = false function loginUser(username, password) { @@ -475,7 +475,7 @@ The `BasicAuthentication` class can be used to support HTTP Basic Authentication username: '', password: '' }) - let client = coreapi.Client({auth: auth}) + let client = new coreapi.Client({auth: auth}) ## Using the client diff --git a/docs/topics/documenting-your-api.md b/docs/topics/documenting-your-api.md index 9a87c17c1..da1dbe358 100644 --- a/docs/topics/documenting-your-api.md +++ b/docs/topics/documenting-your-api.md @@ -42,7 +42,7 @@ For example: class UserList(generics.ListAPIView): """ Return a list of all the existing users. - """" + """ If a view supports multiple methods, you should split your documentation using `method:` style delimiters. diff --git a/docs/topics/funding.md b/docs/topics/funding.md index 6c196faf0..259b69fd2 100644 --- a/docs/topics/funding.md +++ b/docs/topics/funding.md @@ -329,7 +329,7 @@ For further enquires please contact diff --git a/docs/tutorial/7-schemas-and-client-libraries.md b/docs/tutorial/7-schemas-and-client-libraries.md index 4d7193986..26ee86871 100644 --- a/docs/tutorial/7-schemas-and-client-libraries.md +++ b/docs/tutorial/7-schemas-and-client-libraries.md @@ -36,15 +36,15 @@ API schema. We can now include a schema for our API, by including an autogenerated schema view in our URL configuration. -``` - from rest_framework.schemas import get_schema_view +```python +from rest_framework.schemas import get_schema_view - schema_view = get_schema_view(title='Pastebin API') +schema_view = get_schema_view(title='Pastebin API') - urlpatterns = [ -    url(r'^schema/$', schema_view), - ... - ] +urlpatterns = [ +    url(r'^schema/$', schema_view), + ... +] ``` If you visit the API root endpoint in a browser you should now see `corejson` diff --git a/rest_framework/authentication.py b/rest_framework/authentication.py index cb9608a3c..606f572ab 100644 --- a/rest_framework/authentication.py +++ b/rest_framework/authentication.py @@ -201,3 +201,24 @@ class TokenAuthentication(BaseAuthentication): def authenticate_header(self, request): return self.keyword + + +class RemoteUserAuthentication(BaseAuthentication): + """ + REMOTE_USER authentication. + + To use this, set up your web server to perform authentication, which will + set the REMOTE_USER environment variable. You will need to have + 'django.contrib.auth.backends.RemoteUserBackend in your + AUTHENTICATION_BACKENDS setting + """ + + # Name of request header to grab username from. This will be the key as + # used in the request.META dictionary, i.e. the normalization of headers to + # all uppercase and the addition of "HTTP_" prefix apply. + header = "REMOTE_USER" + + def authenticate(self, request): + user = authenticate(remote_user=request.META.get(self.header)) + if user and user.is_active: + return (user, None) diff --git a/rest_framework/exceptions.py b/rest_framework/exceptions.py index e84074a07..aac31c453 100644 --- a/rest_framework/exceptions.py +++ b/rest_framework/exceptions.py @@ -129,8 +129,8 @@ class ValidationError(APIException): if code is None: code = self.default_code - # For validation failures, we may collect may errors together, so the - # details should always be coerced to a list if not already. + # For validation failures, we may collect many errors together, + # so the details should always be coerced to a list if not already. if not isinstance(detail, dict) and not isinstance(detail, list): detail = [detail] diff --git a/rest_framework/fields.py b/rest_framework/fields.py index ce34cf1cd..232fbc0a2 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -25,10 +25,7 @@ from django.utils.dateparse import ( ) from django.utils.duration import duration_string from django.utils.encoding import is_protected_type, smart_text -from django.utils.formats import ( - localize_input, number_format, sanitize_separators -) -from django.utils.functional import cached_property +from django.utils.formats import localize_input, number_format, sanitize_separators from django.utils.ipv6 import clean_ipv6_address from django.utils.timezone import utc from django.utils.translation import ugettext_lazy as _ @@ -588,7 +585,7 @@ class Field(object): message_string = msg.format(**kwargs) raise ValidationError(message_string, code=key) - @cached_property + @property def root(self): """ Returns the top-level serializer for this field. @@ -598,7 +595,7 @@ class Field(object): root = root.parent return root - @cached_property + @property def context(self): """ Returns the context as passed to the root serializer on initialization. diff --git a/rest_framework/filters.py b/rest_framework/filters.py index bdab97b58..63ebf05ef 100644 --- a/rest_framework/filters.py +++ b/rest_framework/filters.py @@ -140,12 +140,14 @@ class SearchFilter(BaseFilterBackend): ] base = queryset + conditions = [] for search_term in search_terms: queries = [ models.Q(**{orm_lookup: search_term}) for orm_lookup in orm_lookups ] - queryset = queryset.filter(reduce(operator.or_, queries)) + conditions.append(reduce(operator.or_, queries)) + queryset = queryset.filter(reduce(operator.and_, conditions)) if self.must_call_distinct(queryset, search_fields): # Filtering against a many-to-many field requires us to diff --git a/rest_framework/pagination.py b/rest_framework/pagination.py index 0255cfc7f..61e0a80b0 100644 --- a/rest_framework/pagination.py +++ b/rest_framework/pagination.py @@ -31,7 +31,7 @@ def _positive_int(integer_string, strict=False, cutoff=None): if ret < 0 or (ret == 0 and strict): raise ValueError() if cutoff: - ret = min(ret, cutoff) + return min(ret, cutoff) return ret @@ -95,7 +95,7 @@ def _get_displayed_page_numbers(current, final): # Now sort the page numbers and drop anything outside the limits. included = [ idx for idx in sorted(list(included)) - if idx > 0 and idx <= final + if 0 < idx <= final ] # Finally insert any `...` breaks @@ -473,7 +473,7 @@ class CursorPagination(BasePagination): """ The cursor pagination implementation is necessarily complex. For an overview of the position/offset style we use, see this post: - http://cramer.io/2011/03/08/building-cursors-for-the-disqus-api + http://cra.mr/2011/03/08/building-cursors-for-the-disqus-api """ cursor_query_param = 'cursor' cursor_query_description = _('The pagination cursor value.') diff --git a/rest_framework/status.py b/rest_framework/status.py index c016b63c6..d4522df3d 100644 --- a/rest_framework/status.py +++ b/rest_framework/status.py @@ -9,23 +9,23 @@ from __future__ import unicode_literals def is_informational(code): - return code >= 100 and code <= 199 + return 100 <= code <= 199 def is_success(code): - return code >= 200 and code <= 299 + return 200 <= code <= 299 def is_redirect(code): - return code >= 300 and code <= 399 + return 300 <= code <= 399 def is_client_error(code): - return code >= 400 and code <= 499 + return 400 <= code <= 499 def is_server_error(code): - return code >= 500 and code <= 599 + return 500 <= code <= 599 HTTP_100_CONTINUE = 100 diff --git a/rest_framework/utils/formatting.py b/rest_framework/utils/formatting.py index 78cb37e56..aa805f14e 100644 --- a/rest_framework/utils/formatting.py +++ b/rest_framework/utils/formatting.py @@ -52,8 +52,8 @@ def camelcase_to_spaces(content): Translate 'CamelCaseNames' to 'Camel Case Names'. Used when generating names from view classes. """ - camelcase_boundry = '(((?<=[a-z])[A-Z])|([A-Z](?![A-Z]|$)))' - content = re.sub(camelcase_boundry, ' \\1', content).strip() + camelcase_boundary = '(((?<=[a-z])[A-Z])|([A-Z](?![A-Z]|$)))' + content = re.sub(camelcase_boundary, ' \\1', content).strip() return ' '.join(content.split('_')).title() diff --git a/tests/test_authentication.py b/tests/test_authentication.py index f2e0fb424..fdbc28a2a 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -16,9 +16,8 @@ from rest_framework import ( HTTP_HEADER_ENCODING, exceptions, permissions, renderers, status ) from rest_framework.authentication import ( - BaseAuthentication, BasicAuthentication, SessionAuthentication, - TokenAuthentication -) + BaseAuthentication, BasicAuthentication, RemoteUserAuthentication, SessionAuthentication, + TokenAuthentication) from rest_framework.authtoken.models import Token from rest_framework.authtoken.views import obtain_auth_token from rest_framework.compat import is_authenticated @@ -64,6 +63,10 @@ urlpatterns = [ r'^basic/$', MockView.as_view(authentication_classes=[BasicAuthentication]) ), + url( + r'^remote-user/$', + MockView.as_view(authentication_classes=[RemoteUserAuthentication]) + ), url( r'^token/$', MockView.as_view(authentication_classes=[TokenAuthentication]) @@ -523,3 +526,20 @@ class BasicAuthenticationUnitTests(TestCase): auth.authenticate_credentials('foo', 'bar') assert 'User inactive or deleted.' in str(error) authentication.authenticate = old_authenticate + + +@override_settings(ROOT_URLCONF='tests.test_authentication', + AUTHENTICATION_BACKENDS=('django.contrib.auth.backends.RemoteUserBackend',)) +class RemoteUserAuthenticationUnitTests(TestCase): + def setUp(self): + self.username = 'john' + self.email = 'lennon@thebeatles.com' + self.password = 'password' + self.user = User.objects.create_user( + self.username, self.email, self.password + ) + + def test_remote_user_works(self): + response = self.client.post('/remote-user/', + REMOTE_USER=self.username) + self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/tests/test_fields.py b/tests/test_fields.py index 1e1ca9f27..980ac6379 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -502,6 +502,16 @@ class TestCreateOnlyDefault: assert serializer.validated_data['context_set'] == 'success' +class Test5087Regression: + def test_parent_binding(self): + parent = serializers.Serializer() + field = serializers.CharField() + + assert field.root is field + field.bind('name', parent) + assert field.root is parent + + # Tests for field input and output values. # ---------------------------------------- diff --git a/tests/test_filters.py b/tests/test_filters.py index b2de80998..6df0a3169 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -5,6 +5,7 @@ import unittest import warnings from decimal import Decimal +import django import pytest from django.conf.urls import url from django.core.exceptions import ImproperlyConfigured @@ -645,6 +646,51 @@ class SearchFilterM2MTests(TestCase): ) +class Blog(models.Model): + name = models.CharField(max_length=20) + + +class Entry(models.Model): + blog = models.ForeignKey(Blog, on_delete=models.CASCADE) + headline = models.CharField(max_length=120) + pub_date = models.DateField(null=True) + + +class BlogSerializer(serializers.ModelSerializer): + class Meta: + model = Blog + fields = '__all__' + + +class SearchFilterToManyTests(TestCase): + + @classmethod + def setUpTestData(cls): + b1 = Blog.objects.create(name='Blog 1') + b2 = Blog.objects.create(name='Blog 2') + + # Multiple entries on Lennon published in 1979 - distinct should deduplicate + Entry.objects.create(blog=b1, headline='Something about Lennon', pub_date=datetime.date(1979, 1, 1)) + Entry.objects.create(blog=b1, headline='Another thing about Lennon', pub_date=datetime.date(1979, 6, 1)) + + # Entry on Lennon *and* a separate entry in 1979 - should not match + Entry.objects.create(blog=b2, headline='Something unrelated', pub_date=datetime.date(1979, 1, 1)) + Entry.objects.create(blog=b2, headline='Retrospective on Lennon', pub_date=datetime.date(1990, 6, 1)) + + @unittest.skipIf(django.VERSION < (1, 9), "Django 1.8 does not support transforms") + def test_multiple_filter_conditions(self): + class SearchListView(generics.ListAPIView): + queryset = Blog.objects.all() + serializer_class = BlogSerializer + filter_backends = (filters.SearchFilter,) + search_fields = ('=name', 'entry__headline', '=entry__pub_date__year') + + view = SearchListView.as_view() + request = factory.get('/', {'search': 'Lennon,1979'}) + response = view(request) + assert len(response.data) == 1 + + class OrderingFilterModel(models.Model): title = models.CharField(max_length=20, verbose_name='verbose title') text = models.CharField(max_length=100) diff --git a/tests/test_serializer.py b/tests/test_serializer.py index f76cec9c3..91430a193 100644 --- a/tests/test_serializer.py +++ b/tests/test_serializer.py @@ -469,6 +469,22 @@ class TestSerializerValidationWithCompiledRegexField: assert serializer.errors == {} +class Test2505Regression: + def test_serializer_context(self): + class NestedSerializer(serializers.Serializer): + def __init__(self, *args, **kwargs): + super(NestedSerializer, self).__init__(*args, **kwargs) + # .context should not cache + self.context + + class ParentSerializer(serializers.Serializer): + nested = NestedSerializer() + + serializer = ParentSerializer(data={}, context={'foo': 'bar'}) + assert serializer.context == {'foo': 'bar'} + assert serializer.fields['nested'].context == {'foo': 'bar'} + + class Test4606Regression: def setup(self): class ExampleSerializer(serializers.Serializer):