From bd8360c826b7a922eeb6226beb17853cfadb466c Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 17 Oct 2012 14:02:30 +0100 Subject: Highlight use of permissions alnog with authentication --- docs/api-guide/authentication.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/api-guide/authentication.md b/docs/api-guide/authentication.md index 71f48163..959feaa6 100644 --- a/docs/api-guide/authentication.md +++ b/docs/api-guide/authentication.md @@ -16,6 +16,12 @@ The `request.user` property will typically be set to an instance of the `contrib The `request.auth` property is used for any additional authentication information, for example, it may be used to represent an authentication token that the request was signed with. +--- + +**Note:** Don't forget that authentication by itself wont allow or disallow an incoming request, it simply identifies the credentials that the request was made with. For information on how to setup the permission polices for your API please see the [permissions documentation][permission]. + +--- + ## How authentication is determined The authentication policy is always defined as a list of classes. REST framework will attempt to authenticate with each class in the list, and will set `request.user` and `request.auth` using the return value of the first class that successfully authenticates. -- cgit v1.2.3 From 4c17d1441f184eabea9000155f07445bcc2aa14c Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 17 Oct 2012 14:59:37 +0100 Subject: Add `Unauthenticated` exception. --- docs/api-guide/exceptions.md | 13 +++++++++++-- rest_framework/exceptions.py | 8 ++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/docs/api-guide/exceptions.md b/docs/api-guide/exceptions.md index c3bdb7b9..f5dff94a 100644 --- a/docs/api-guide/exceptions.md +++ b/docs/api-guide/exceptions.md @@ -49,11 +49,19 @@ Raised if the request contains malformed data when accessing `request.DATA` or ` By default this exception results in a response with the HTTP status code "400 Bad Request". +## Unauthenticated + +**Signature:** `Unauthenticated(detail=None)` + +Raised when an unauthenticated incoming request fails the permission checks. + +By default this exception results in a response with the HTTP status code "401 Unauthenticated", but it may also result in a "403 Forbidden" response, depending on the authentication scheme in use. See the [authentication documentation][authentication] for more details. + ## PermissionDenied **Signature:** `PermissionDenied(detail=None)` -Raised when an incoming request fails the permission checks. +Raised when an authenticated incoming request fails the permission checks. By default this exception results in a response with the HTTP status code "403 Forbidden". @@ -81,4 +89,5 @@ Raised when an incoming request fails the throttling checks. By default this exception results in a response with the HTTP status code "429 Too Many Requests". -[cite]: http://www.doughellmann.com/articles/how-tos/python-exception-handling/index.html \ No newline at end of file +[cite]: http://www.doughellmann.com/articles/how-tos/python-exception-handling/index.html +[authentication]: authentication.md \ No newline at end of file diff --git a/rest_framework/exceptions.py b/rest_framework/exceptions.py index 572425b9..1597da61 100644 --- a/rest_framework/exceptions.py +++ b/rest_framework/exceptions.py @@ -23,6 +23,14 @@ class ParseError(APIException): self.detail = detail or self.default_detail +class Unauthenticated(APIException): + status_code = status.HTTP_401_UNAUTHENTICATED + default_detail = 'Incorrect or absent authentication credentials.' + + def __init__(self, detail=None): + self.detail = detail or self.default_detail + + class PermissionDenied(APIException): status_code = status.HTTP_403_FORBIDDEN default_detail = 'You do not have permission to perform this action.' -- cgit v1.2.3 From 5ae49a4ec4ccfdab13bc848ecd175d44ecaf4ed1 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 17 Oct 2012 14:59:53 +0100 Subject: Add docs for 401 vs 403 responses --- docs/api-guide/authentication.md | 72 ++++++++++++++++++++++++++++++++-------- rest_framework/authentication.py | 8 +++++ 2 files changed, 66 insertions(+), 14 deletions(-) diff --git a/docs/api-guide/authentication.md b/docs/api-guide/authentication.md index 959feaa6..9c61c25f 100644 --- a/docs/api-guide/authentication.md +++ b/docs/api-guide/authentication.md @@ -8,7 +8,7 @@ Authentication is the mechanism of associating an incoming request with a set of identifying credentials, such as the user the request came from, or the token that it was signed with. The [permission] and [throttling] policies can then use those credentials to determine if the request should be permitted. -REST framework provides a number of authentication policies out of the box, and also allows you to implement custom policies. +REST framework provides a number of authentication schemes out of the box, and also allows you to implement custom schemes. Authentication will run the first time either the `request.user` or `request.auth` properties are accessed, and determines how those properties are initialized. @@ -18,21 +18,21 @@ The `request.auth` property is used for any additional authentication informatio --- -**Note:** Don't forget that authentication by itself wont allow or disallow an incoming request, it simply identifies the credentials that the request was made with. For information on how to setup the permission polices for your API please see the [permissions documentation][permission]. +**Note:** Don't forget that **authentication by itself won't allow or disallow an incoming request**, it simply identifies the credentials that the request was made with. For information on how to setup the permission polices for your API please see the [permissions documentation][permission]. --- ## How authentication is determined -The authentication policy is always defined as a list of classes. REST framework will attempt to authenticate with each class in the list, and will set `request.user` and `request.auth` using the return value of the first class that successfully authenticates. +The authentication schemes are always defined as a list of classes. REST framework will attempt to authenticate with each class in the list, and will set `request.user` and `request.auth` using the return value of the first class that successfully authenticates. If no class authenticates, `request.user` will be set to an instance of `django.contrib.auth.models.AnonymousUser`, and `request.auth` will be set to `None`. The value of `request.user` and `request.auth` for unauthenticated requests can be modified using the `UNAUTHENTICATED_USER` and `UNAUTHENTICATED_TOKEN` settings. -## Setting the authentication policy +## Setting the authentication scheme -The default authentication policy may be set globally, using the `DEFAULT_AUTHENTICATION` setting. For example. +The default authentication schemes may be set globally, using the `DEFAULT_AUTHENTICATION` setting. For example. REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION': ( @@ -41,7 +41,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. +You can also set the authentication scheme on a per-view basis, using the `APIView` class based views. class ExampleView(APIView): authentication_classes = (SessionAuthentication, UserBasicAuthentication) @@ -66,24 +66,43 @@ Or, if you're using the `@api_view` decorator with function based views. } return Response(content) +## Unauthorized and Forbidden responses + +When an unauthenticated request is denied permission there are two different error codes that may be appropriate. + +* [HTTP 401 Unauthorized][http401] +* [HTTP 403 Permission Denied][http403] + +The kind of response that will be used depends on the type of authentication scheme in use, and the ordering of the authentication classes. + +Although multiple authentication schemes may be in use, only one scheme may be used to determine the type of response. **The first authentication class set on the view is given priority when determining the type of response**. + +Note that when a *successfully authenticated* request is denied permission, a `403 Permission Denied` response will always be used, regardless of the authentication scheme. + +--- + # API Reference ## BasicAuthentication -This policy uses [HTTP Basic Authentication][basicauth], signed against a user's username and password. Basic authentication is generally only appropriate for testing. +This authentication scheme uses [HTTP Basic Authentication][basicauth], signed against a user's username and password. Basic authentication is generally only appropriate for testing. If successfully authenticated, `BasicAuthentication` provides the following credentials. * `request.user` will be a `django.contrib.auth.models.User` instance. * `request.auth` will be `None`. +Unauthenticated responses that are denied permission will result in an `HTTP 401 Unauthenticated` response with an appropriate WWW-Authenticate header. For example: + + WWW-Authenticate: Basic realm="api" + **Note:** If you use `BasicAuthentication` in production you must ensure that your API is only available over `https` only. You should also ensure that your API clients will always re-request the username and password at login, and will never store those details to persistent storage. ## TokenAuthentication -This policy uses a simple token-based HTTP Authentication scheme. Token authentication is appropriate for client-server setups, such as native desktop and mobile clients. +This authentication scheme uses a simple token-based HTTP Authentication scheme. Token authentication is appropriate for client-server setups, such as native desktop and mobile clients. -To use the `TokenAuthentication` policy, include `rest_framework.authtoken` in your `INSTALLED_APPS` setting. +To use the `TokenAuthentication` scheme, include `rest_framework.authtoken` in your `INSTALLED_APPS` setting. You'll also need to create tokens for your users. @@ -101,31 +120,56 @@ If successfully authenticated, `TokenAuthentication` provides the following cred * `request.user` will be a `django.contrib.auth.models.User` instance. * `request.auth` will be a `rest_framework.tokenauth.models.BasicToken` instance. +Unauthenticated responses that are denied permission will result in an `HTTP 401 Unauthenticated` response with an appropriate WWW-Authenticate header. For example: + + WWW-Authenticate: Token + **Note:** If you use `TokenAuthentication` in production you must ensure that your API is only available over `https` only. -## OAuthAuthentication +## OAuth2Authentication -This policy uses the [OAuth 2.0][oauth] protocol to authenticate requests. OAuth is appropriate for server-server setups, such as when you want to allow a third-party service to access your API on a user's behalf. +This authentication scheme uses the [OAuth 2.0][oauth] protocol to authenticate requests. OAuth is appropriate for server-server setups, such as when you want to allow a third-party service to access your API on a user's behalf. -If successfully authenticated, `OAuthAuthentication` provides the following credentials. +If successfully authenticated, `OAuth2Authentication` provides the following credentials. * `request.user` will be a `django.contrib.auth.models.User` instance. * `request.auth` will be a `rest_framework.models.OAuthToken` instance. +**TODO**: Note type of response (401 vs 403) + +**TODO**: Implement OAuth2Authentication, using django-oauth2-provider. + ## SessionAuthentication -This policy 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. +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. If successfully authenticated, `SessionAuthentication` provides the following credentials. * `request.user` will be a `django.contrib.auth.models.User` instance. * `request.auth` will be `None`. +Unauthenticated responses that are denied permission will result in an `HTTP 403 Forbidden` response. + +--- + # Custom authentication -To implement a custom authentication policy, 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. +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. + +In some circumstances instead of returning `None`, you may want to raise an `Unauthenticated` exception from the `.authenticate()` method. + +Typically the approach you should take is: + +* If authentication is not attempted, return `None`. Any other authentication schemes also in use will still be checked. +* If authentication is attempted but fails, raise an `Unauthenticated` exception. An error response will be returned immediately, without checking any other authentication schemes. + +You *may* also override the `.authentication_header(self, request)` method. If implemented, it should return a string that will be used as the value of the `WWW-Authenticate` header in a `HTTP 401 Unauthenticated` response. + +If the `.authentication_header()` method is not overridden, the authentication scheme will return `HTTP 403 Forbidden` responses when an unauthenticated request is denied access. [cite]: http://jacobian.org/writing/rest-worst-practices/ +[http401]: http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.2 +[http403]: http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.4 [basicauth]: http://tools.ietf.org/html/rfc2617 [oauth]: http://oauth.net/2/ [permission]: permissions.md diff --git a/rest_framework/authentication.py b/rest_framework/authentication.py index 30c78ebc..e557abed 100644 --- a/rest_framework/authentication.py +++ b/rest_framework/authentication.py @@ -21,6 +21,14 @@ class BaseAuthentication(object): """ raise NotImplementedError(".authenticate() must be overridden.") + def authenticate_header(self, request): + """ + Return a string to be used as the value of the `WWW-Authenticate` + header in a `401 Unauthenticated` response, or `None` if the + authentication scheme should return `403 Permission Denied` responses. + """ + pass + class BasicAuthentication(BaseAuthentication): """ -- cgit v1.2.3 From dc9384f9b4321f099e380f6b4a04fbe2eeb2b743 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 17 Oct 2012 15:09:20 +0100 Subject: Use correct status code --- rest_framework/exceptions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rest_framework/exceptions.py b/rest_framework/exceptions.py index 1597da61..2461cacd 100644 --- a/rest_framework/exceptions.py +++ b/rest_framework/exceptions.py @@ -24,7 +24,7 @@ class ParseError(APIException): class Unauthenticated(APIException): - status_code = status.HTTP_401_UNAUTHENTICATED + status_code = status.HTTP_401_UNAUTHORIZED default_detail = 'Incorrect or absent authentication credentials.' def __init__(self, detail=None): -- cgit v1.2.3 From a4d500ba107466e8d44a82ed8ca632a3ea81a016 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 17 Oct 2012 15:10:11 +0100 Subject: Use correct status code --- docs/api-guide/authentication.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/api-guide/authentication.md b/docs/api-guide/authentication.md index 9c61c25f..06f428c0 100644 --- a/docs/api-guide/authentication.md +++ b/docs/api-guide/authentication.md @@ -92,7 +92,7 @@ If successfully authenticated, `BasicAuthentication` provides the following cred * `request.user` will be a `django.contrib.auth.models.User` instance. * `request.auth` will be `None`. -Unauthenticated responses that are denied permission will result in an `HTTP 401 Unauthenticated` response with an appropriate WWW-Authenticate header. For example: +Unauthenticated responses that are denied permission will result in an `HTTP 401 Unauthorized` response with an appropriate WWW-Authenticate header. For example: WWW-Authenticate: Basic realm="api" @@ -120,7 +120,7 @@ If successfully authenticated, `TokenAuthentication` provides the following cred * `request.user` will be a `django.contrib.auth.models.User` instance. * `request.auth` will be a `rest_framework.tokenauth.models.BasicToken` instance. -Unauthenticated responses that are denied permission will result in an `HTTP 401 Unauthenticated` response with an appropriate WWW-Authenticate header. For example: +Unauthenticated responses that are denied permission will result in an `HTTP 401 Unauthorized` response with an appropriate WWW-Authenticate header. For example: WWW-Authenticate: Token @@ -163,7 +163,7 @@ Typically the approach you should take is: * If authentication is not attempted, return `None`. Any other authentication schemes also in use will still be checked. * If authentication is attempted but fails, raise an `Unauthenticated` exception. An error response will be returned immediately, without checking any other authentication schemes. -You *may* also override the `.authentication_header(self, request)` method. If implemented, it should return a string that will be used as the value of the `WWW-Authenticate` header in a `HTTP 401 Unauthenticated` response. +You *may* also override the `.authentication_header(self, request)` method. If implemented, it should return a string that will be used as the value of the `WWW-Authenticate` header in a `HTTP 401 Unauthorized` response. If the `.authentication_header()` method is not overridden, the authentication scheme will return `HTTP 403 Forbidden` responses when an unauthenticated request is denied access. -- cgit v1.2.3 From b78872b7dbb55f1aa2d21f15fbb952f0c7156326 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 17 Oct 2012 15:23:36 +0100 Subject: Use two seperate exceptions - `AuthenticationFailed`, and `NotAuthenticated` Cleaner seperation of exception and resulting HTTP response. Should result in more obvious error messages. --- docs/api-guide/authentication.md | 4 ++-- docs/api-guide/exceptions.md | 16 ++++++++++++---- rest_framework/exceptions.py | 12 ++++++++++-- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/docs/api-guide/authentication.md b/docs/api-guide/authentication.md index 06f428c0..3ace6519 100644 --- a/docs/api-guide/authentication.md +++ b/docs/api-guide/authentication.md @@ -156,12 +156,12 @@ Unauthenticated responses that are denied permission will result in an `HTTP 403 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. -In some circumstances instead of returning `None`, you may want to raise an `Unauthenticated` exception from the `.authenticate()` method. +In some circumstances instead of returning `None`, you may want to raise an `AuthenticationFailed` exception from the `.authenticate()` method. Typically the approach you should take is: * If authentication is not attempted, return `None`. Any other authentication schemes also in use will still be checked. -* If authentication is attempted but fails, raise an `Unauthenticated` exception. An error response will be returned immediately, without checking any other authentication schemes. +* If authentication is attempted but fails, raise a `AuthenticationFailed` exception. An error response will be returned immediately, without checking any other authentication schemes. You *may* also override the `.authentication_header(self, request)` method. If implemented, it should return a string that will be used as the value of the `WWW-Authenticate` header in a `HTTP 401 Unauthorized` response. diff --git a/docs/api-guide/exceptions.md b/docs/api-guide/exceptions.md index f5dff94a..c30f586a 100644 --- a/docs/api-guide/exceptions.md +++ b/docs/api-guide/exceptions.md @@ -49,11 +49,19 @@ Raised if the request contains malformed data when accessing `request.DATA` or ` By default this exception results in a response with the HTTP status code "400 Bad Request". -## Unauthenticated +## AuthenticationFailed -**Signature:** `Unauthenticated(detail=None)` +**Signature:** `AuthenticationFailed(detail=None)` -Raised when an unauthenticated incoming request fails the permission checks. +Raised when an incoming request includes incorrect authentication. + +By default this exception results in a response with the HTTP status code "401 Unauthenticated", but it may also result in a "403 Forbidden" response, depending on the authentication scheme in use. See the [authentication documentation][authentication] for more details. + +## NotAuthenticated + +**Signature:** `NotAuthenticated(detail=None)` + +Raised when an unauthenticated request fails the permission checks. By default this exception results in a response with the HTTP status code "401 Unauthenticated", but it may also result in a "403 Forbidden" response, depending on the authentication scheme in use. See the [authentication documentation][authentication] for more details. @@ -61,7 +69,7 @@ By default this exception results in a response with the HTTP status code "401 U **Signature:** `PermissionDenied(detail=None)` -Raised when an authenticated incoming request fails the permission checks. +Raised when an authenticated request fails the permission checks. By default this exception results in a response with the HTTP status code "403 Forbidden". diff --git a/rest_framework/exceptions.py b/rest_framework/exceptions.py index 2461cacd..6ae0c95c 100644 --- a/rest_framework/exceptions.py +++ b/rest_framework/exceptions.py @@ -23,9 +23,17 @@ class ParseError(APIException): self.detail = detail or self.default_detail -class Unauthenticated(APIException): +class AuthenticationFailed(APIException): status_code = status.HTTP_401_UNAUTHORIZED - default_detail = 'Incorrect or absent authentication credentials.' + default_detail = 'Incorrect authentication credentials.' + + def __init__(self, detail=None): + self.detail = detail or self.default_detail + + +class NotAuthenticated(APIException): + status_code = status.HTTP_401_UNAUTHORIZED + default_detail = 'Authentication credentials were not provided.' def __init__(self, detail=None): self.detail = detail or self.default_detail -- cgit v1.2.3 From 957700ecfb36322a8ea40ea473dc43ff1e92592f Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 13 Nov 2012 11:26:45 +0000 Subject: Remove OAuth2 from docs --- docs/api-guide/authentication.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/api-guide/authentication.md b/docs/api-guide/authentication.md index c87ba83e..b2323d62 100644 --- a/docs/api-guide/authentication.md +++ b/docs/api-guide/authentication.md @@ -126,7 +126,7 @@ Unauthenticated responses that are denied permission will result in an `HTTP 401 **Note:** If you use `TokenAuthentication` in production you must ensure that your API is only available over `https` only. -## OAuth2Authentication + ## SessionAuthentication -- cgit v1.2.3 From 873a142af2f63084fd10bf35c13e79131837da07 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 13 Nov 2012 11:27:09 +0000 Subject: Implementing 401 vs 403 responses --- rest_framework/authentication.py | 72 ++++++++++++++++++++++++++-------------- rest_framework/request.py | 29 +++++++++++----- rest_framework/views.py | 2 ++ 3 files changed, 69 insertions(+), 34 deletions(-) diff --git a/rest_framework/authentication.py b/rest_framework/authentication.py index e557abed..6dc80498 100644 --- a/rest_framework/authentication.py +++ b/rest_framework/authentication.py @@ -34,27 +34,33 @@ class BasicAuthentication(BaseAuthentication): """ HTTP Basic authentication against username/password. """ + www_authenticate_realm = 'api' def authenticate(self, request): """ Returns a `User` if a correct username and password have been supplied using HTTP Basic authentication. Otherwise returns `None`. """ - if 'HTTP_AUTHORIZATION' in request.META: - auth = request.META['HTTP_AUTHORIZATION'].split() - if len(auth) == 2 and auth[0].lower() == "basic": - try: - auth_parts = base64.b64decode(auth[1]).partition(':') - except TypeError: - return None - - try: - userid = smart_unicode(auth_parts[0]) - password = smart_unicode(auth_parts[2]) - except DjangoUnicodeDecodeError: - return None - - return self.authenticate_credentials(userid, password) + auth = request.META.get('HTTP_AUTHORIZATION', '').split() + + if not auth or auth[0].lower() != "basic": + return None + + if len(auth) != 2: + raise exceptions.AuthenticationFailed('Invalid basic header') + + try: + auth_parts = base64.b64decode(auth[1]).partition(':') + except TypeError: + raise exceptions.AuthenticationFailed('Invalid basic header') + + try: + userid = smart_unicode(auth_parts[0]) + password = smart_unicode(auth_parts[2]) + except DjangoUnicodeDecodeError: + raise exceptions.AuthenticationFailed('Invalid basic header') + + return self.authenticate_credentials(userid, password) def authenticate_credentials(self, userid, password): """ @@ -63,6 +69,10 @@ class BasicAuthentication(BaseAuthentication): user = authenticate(username=userid, password=password) if user is not None and user.is_active: return (user, None) + raise exceptions.AuthenticationFailed('Invalid username/password') + + def authenticate_header(self): + return 'Basic realm="%s"' % self.www_authenticate_realm class SessionAuthentication(BaseAuthentication): @@ -82,7 +92,7 @@ class SessionAuthentication(BaseAuthentication): # Unauthenticated, CSRF validation not required if not user or not user.is_active: - return + return None # Enforce CSRF validation for session based authentication. class CSRFCheck(CsrfViewMiddleware): @@ -93,7 +103,7 @@ class SessionAuthentication(BaseAuthentication): reason = CSRFCheck().process_view(http_request, None, (), {}) if reason: # CSRF failed, bail with explicit error message - raise exceptions.PermissionDenied('CSRF Failed: %s' % reason) + raise exceptions.AuthenticationFailed('CSRF Failed: %s' % reason) # CSRF passed with authenticated user return (user, None) @@ -120,14 +130,26 @@ class TokenAuthentication(BaseAuthentication): def authenticate(self, request): auth = request.META.get('HTTP_AUTHORIZATION', '').split() - if len(auth) == 2 and auth[0].lower() == "token": - key = auth[1] - try: - token = self.model.objects.get(key=key) - except self.model.DoesNotExist: - return None + if not auth or auth[0].lower() != "token": + return None + + if len(auth) != 2: + raise exceptions.AuthenticationFailed('Invalid token header') + + return self.authenticate_credentials(auth[1]) + + def authenticate_credentials(self, key): + try: + token = self.model.objects.get(key=key) + except self.model.DoesNotExist: + raise exceptions.AuthenticationFailed('Invalid token') + + if token.user.is_active: + return (token.user, token) + raise exceptions.AuthenticationFailed('User inactive or deleted') + + def authenticate_header(self): + return 'Token' - if token.user.is_active: - return (token.user, token) # TODO: OAuthAuthentication diff --git a/rest_framework/request.py b/rest_framework/request.py index a1827ba4..38ee36dd 100644 --- a/rest_framework/request.py +++ b/rest_framework/request.py @@ -86,6 +86,7 @@ class Request(object): self._method = Empty self._content_type = Empty self._stream = Empty + self._authenticator = None if self.parser_context is None: self.parser_context = {} @@ -166,7 +167,7 @@ class Request(object): by the authentication classes provided to the request. """ if not hasattr(self, '_user'): - self._user, self._auth = self._authenticate() + self._authenticator, self._user, self._auth = self._authenticate() return self._user @property @@ -176,9 +177,17 @@ class Request(object): request, such as an authentication token. """ if not hasattr(self, '_auth'): - self._user, self._auth = self._authenticate() + self._authenticator, self._user, self._auth = self._authenticate() return self._auth + @property + def successful_authenticator(self): + """ + Return the instance of the authentication instance class that was used + to authenticate the request, or `None`. + """ + return self._authenticator + def _load_data_and_files(self): """ Parses the request content into self.DATA and self.FILES. @@ -282,21 +291,23 @@ class Request(object): def _authenticate(self): """ - Attempt to authenticate the request using each authentication instance in turn. - Returns a two-tuple of (user, authtoken). + Attempt to authenticate the request using each authentication instance + in turn. + Returns a three-tuple of (authenticator, user, authtoken). """ for authenticator in self.authenticators: user_auth_tuple = authenticator.authenticate(self) if not user_auth_tuple is None: - return user_auth_tuple + user, auth = user_auth_tuple + return (authenticator, user, auth) return self._not_authenticated() def _not_authenticated(self): """ - Return a two-tuple of (user, authtoken), representing an - unauthenticated request. + Return a three-tuple of (authenticator, user, authtoken), representing + an unauthenticated request. - By default this will be (AnonymousUser, None). + By default this will be (None, AnonymousUser, None). """ if api_settings.UNAUTHENTICATED_USER: user = api_settings.UNAUTHENTICATED_USER() @@ -308,7 +319,7 @@ class Request(object): else: auth = None - return (user, auth) + return (None, user, auth) def __getattr__(self, attr): """ diff --git a/rest_framework/views.py b/rest_framework/views.py index 1afbd697..c470817a 100644 --- a/rest_framework/views.py +++ b/rest_framework/views.py @@ -148,6 +148,8 @@ class APIView(View): """ If request is not permitted, determine what kind of exception to raise. """ + if self.request.successful_authenticator: + raise exceptions.NotAuthenticated() raise exceptions.PermissionDenied() def throttled(self, request, wait): -- cgit v1.2.3 From 55cc7452546f44d48fd68b81eebc1eed75eff1df Mon Sep 17 00:00:00 2001 From: Federico Capoano Date: Wed, 16 Jan 2013 17:10:46 +0100 Subject: Update docs/api-guide/authentication.md Added mod_wsgi specific instructions--- docs/api-guide/authentication.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/api-guide/authentication.md b/docs/api-guide/authentication.md index afd9a261..e91f6c2e 100644 --- a/docs/api-guide/authentication.md +++ b/docs/api-guide/authentication.md @@ -60,6 +60,17 @@ Or, if you're using the `@api_view` decorator with function based views. } return Response(content) +## Apache mod_wsgi Specific Configuration + +Unlike other HTTP headers, the authorisation header is not passed through to a WSGI application by default. This is the case as doing so could leak information about passwords through to a WSGI application which should not be able to see them when Apache is performing authentication... + +If it is desired that the WSGI application be responsible for handling user authentication, then it is necessary to explicitly configure mod_wsgi to pass the required headers through to the application. This can be done by specifying the WSGIPassAuthorization directive in the appropriate context and setting it to 'On'. + + # this can go in either server config, virtual host, directory or .htaccess + WSGIPassAuthorization On + +[cite]: http://code.google.com/p/modwsgi/wiki/ConfigurationDirectives#WSGIPassAuthorization + # API Reference ## BasicAuthentication -- cgit v1.2.3 From f19d4ea8b126650bc23af822acd3d6af9c7fb632 Mon Sep 17 00:00:00 2001 From: Federico Capoano Date: Wed, 16 Jan 2013 17:17:07 +0100 Subject: Update docs/api-guide/authentication.md refined mod_wsgi--- docs/api-guide/authentication.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/api-guide/authentication.md b/docs/api-guide/authentication.md index e91f6c2e..330cf7a4 100644 --- a/docs/api-guide/authentication.md +++ b/docs/api-guide/authentication.md @@ -62,14 +62,14 @@ Or, if you're using the `@api_view` decorator with function based views. ## Apache mod_wsgi Specific Configuration -Unlike other HTTP headers, the authorisation header is not passed through to a WSGI application by default. This is the case as doing so could leak information about passwords through to a WSGI application which should not be able to see them when Apache is performing authentication... +Unlike other HTTP headers, the authorisation header is not passed through to a WSGI application by default. This is the case as doing so could leak information about passwords through to a WSGI application which should not be able to see them when Apache is performing authentication. If it is desired that the WSGI application be responsible for handling user authentication, then it is necessary to explicitly configure mod_wsgi to pass the required headers through to the application. This can be done by specifying the WSGIPassAuthorization directive in the appropriate context and setting it to 'On'. # this can go in either server config, virtual host, directory or .htaccess WSGIPassAuthorization On -[cite]: http://code.google.com/p/modwsgi/wiki/ConfigurationDirectives#WSGIPassAuthorization +[Reference to official mod_wsgi documentation][mod_wsgi_official] # API Reference @@ -157,3 +157,4 @@ To implement a custom authentication policy, subclass `BaseAuthentication` and o [permission]: permissions.md [throttling]: throttling.md [csrf-ajax]: https://docs.djangoproject.com/en/dev/ref/contrib/csrf/#ajax +[mod_wsgi_official]: http://code.google.com/p/modwsgi/wiki/ConfigurationDirectives#WSGIPassAuthorization -- cgit v1.2.3 From 72c04d570d167209f3f34d6d78492426f206b245 Mon Sep 17 00:00:00 2001 From: Mark Aaron Shirley Date: Fri, 4 Jan 2013 12:50:01 +0100 Subject: Add nested create for 1to1 reverse relationships --- rest_framework/serializers.py | 46 ++++++++++++++++++---- rest_framework/tests/nesting.py | 85 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 8 deletions(-) create mode 100644 rest_framework/tests/nesting.py diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index 27458f96..a43a81d7 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -93,7 +93,7 @@ class SerializerOptions(object): self.exclude = getattr(meta, 'exclude', ()) -class BaseSerializer(Field): +class BaseSerializer(WritableField): class Meta(object): pass @@ -218,7 +218,10 @@ class BaseSerializer(Field): try: field.field_from_native(data, files, field_name, reverted_data) except ValidationError as err: - self._errors[field_name] = list(err.messages) + if hasattr(err, 'message_dict'): + self._errors[field_name] = [err.message_dict] + else: + self._errors[field_name] = list(err.messages) return reverted_data @@ -369,6 +372,25 @@ class ModelSerializer(Serializer): """ _options_class = ModelSerializerOptions + def field_from_native(self, data, files, field_name, into): + if self.read_only: + return + + try: + native = data[field_name] + except KeyError: + if self.required: + raise ValidationError(self.error_messages['required']) + return + + obj = self.from_native(native, files) + if not self._errors: + self.object = obj + into[self.source or field_name] = self + else: + # Propagate errors up to our parent + raise ValidationError(self._errors) + def get_default_fields(self): """ Return all the fields that should be serialized for the model. @@ -542,10 +564,9 @@ class ModelSerializer(Serializer): return instance - def save(self): - """ - Save the deserialized object and return it. - """ + def _save(self, parent=None, fk_field=None): + if parent and fk_field: + setattr(self.object, fk_field, parent) self.object.save() if getattr(self, 'm2m_data', None): @@ -555,9 +576,18 @@ class ModelSerializer(Serializer): if getattr(self, 'related_data', None): for accessor_name, object_list in self.related_data.items(): - setattr(self.object, accessor_name, object_list) + if isinstance(object_list, ModelSerializer): + fk_field = self.object._meta.get_field_by_name(accessor_name)[0].field.name + object_list._save(parent=self.object, fk_field=fk_field) + else: + setattr(self.object, accessor_name, object_list) self.related_data = {} - + + def save(self): + """ + Save the deserialized object and return it. + """ + self._save() return self.object diff --git a/rest_framework/tests/nesting.py b/rest_framework/tests/nesting.py new file mode 100644 index 00000000..0c130dce --- /dev/null +++ b/rest_framework/tests/nesting.py @@ -0,0 +1,85 @@ +from django.db import models +from django.test import TestCase +from rest_framework import serializers + + +class OneToOneTarget(models.Model): + name = models.CharField(max_length=100) + + +class OneToOneTargetSource(models.Model): + name = models.CharField(max_length=100) + target = models.OneToOneField(OneToOneTarget, related_name='target_source') + + +class OneToOneSource(models.Model): + name = models.CharField(max_length=100) + target_source = models.OneToOneField(OneToOneTargetSource, related_name='source') + + +class OneToOneSourceSerializer(serializers.ModelSerializer): + class Meta: + model = OneToOneSource + exclude = ('target_source', ) + + +class OneToOneTargetSourceSerializer(serializers.ModelSerializer): + source = OneToOneSourceSerializer() + + class Meta: + model = OneToOneTargetSource + exclude = ('target', ) + +class OneToOneTargetSerializer(serializers.ModelSerializer): + target_source = OneToOneTargetSourceSerializer() + + class Meta: + model = OneToOneTarget + + +class NestedOneToOneTests(TestCase): + def setUp(self): + #import pdb ; pdb.set_trace() + for idx in range(1, 4): + target = OneToOneTarget(name='target-%d' % idx) + target.save() + target_source = OneToOneTargetSource(name='target-source-%d' % idx, target=target) + target_source.save() + source = OneToOneSource(name='source-%d' % idx, target_source=target_source) + source.save() + + def test_foreign_key_retrieve(self): + queryset = OneToOneTarget.objects.all() + serializer = OneToOneTargetSerializer(queryset) + expected = [ + {'id': 1, 'name': u'target-1', 'target_source': {'id': 1, 'name': u'target-source-1', 'source': {'id': 1, 'name': u'source-1'}}}, + {'id': 2, 'name': u'target-2', 'target_source': {'id': 2, 'name': u'target-source-2', 'source': {'id': 2, 'name': u'source-2'}}}, + {'id': 3, 'name': u'target-3', 'target_source': {'id': 3, 'name': u'target-source-3', 'source': {'id': 3, 'name': u'source-3'}}} + ] + self.assertEquals(serializer.data, expected) + + + def test_foreign_key_create(self): + data = {'id': 4, 'name': u'target-4', 'target_source': {'id': 4, 'name': u'target-source-4', 'source': {'id': 4, 'name': u'source-4'}}} + serializer = OneToOneTargetSerializer(data=data) + self.assertTrue(serializer.is_valid()) + obj = serializer.save() + self.assertEquals(serializer.data, data) + self.assertEqual(obj.name, u'target-4') + + # Ensure (source 4, target 4) is added, and everything else is as expected + queryset = OneToOneTarget.objects.all() + serializer = OneToOneTargetSerializer(queryset) + expected = [ + {'id': 1, 'name': u'target-1', 'target_source': {'id': 1, 'name': u'target-source-1', 'source': {'id': 1, 'name': u'source-1'}}}, + {'id': 2, 'name': u'target-2', 'target_source': {'id': 2, 'name': u'target-source-2', 'source': {'id': 2, 'name': u'source-2'}}}, + {'id': 3, 'name': u'target-3', 'target_source': {'id': 3, 'name': u'target-source-3', 'source': {'id': 3, 'name': u'source-3'}}}, + {'id': 4, 'name': u'target-4', 'target_source': {'id': 4, 'name': u'target-source-4', 'source': {'id': 4, 'name': u'source-4'}}} + ] + self.assertEquals(serializer.data, expected) + + def test_foreign_key_create_with_invalid_data(self): + data = {'id': 4, 'name': u'target-4', 'target_source': {'id': 4, 'name': u'target-source-4', 'source': {'id': 4}}} + serializer = OneToOneTargetSerializer(data=data) + self.assertFalse(serializer.is_valid()) + self.assertEquals(serializer.errors, {'target_source': [{'source': [{'name': [u'This field is required.']}]}]}) -- cgit v1.2.3 From e66eeb4af8611ba255274f561afb674b25a93c8a Mon Sep 17 00:00:00 2001 From: Mark Aaron Shirley Date: Fri, 4 Jan 2013 13:13:03 +0100 Subject: Remove commented out debug code --- rest_framework/tests/nesting.py | 1 - 1 file changed, 1 deletion(-) diff --git a/rest_framework/tests/nesting.py b/rest_framework/tests/nesting.py index 0c130dce..d6f9237f 100644 --- a/rest_framework/tests/nesting.py +++ b/rest_framework/tests/nesting.py @@ -39,7 +39,6 @@ class OneToOneTargetSerializer(serializers.ModelSerializer): class NestedOneToOneTests(TestCase): def setUp(self): - #import pdb ; pdb.set_trace() for idx in range(1, 4): target = OneToOneTarget(name='target-%d' % idx) target.save() -- cgit v1.2.3 From 46eea97380ab9723d747b41fab0a305dec19c738 Mon Sep 17 00:00:00 2001 From: Mark Aaron Shirley Date: Fri, 4 Jan 2013 13:48:01 +0100 Subject: Update one-to-one test names --- rest_framework/tests/nesting.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/rest_framework/tests/nesting.py b/rest_framework/tests/nesting.py index d6f9237f..9cc46c6c 100644 --- a/rest_framework/tests/nesting.py +++ b/rest_framework/tests/nesting.py @@ -47,7 +47,7 @@ class NestedOneToOneTests(TestCase): source = OneToOneSource(name='source-%d' % idx, target_source=target_source) source.save() - def test_foreign_key_retrieve(self): + def test_one_to_one_retrieve(self): queryset = OneToOneTarget.objects.all() serializer = OneToOneTargetSerializer(queryset) expected = [ @@ -58,7 +58,7 @@ class NestedOneToOneTests(TestCase): self.assertEquals(serializer.data, expected) - def test_foreign_key_create(self): + def test_one_to_one_create(self): data = {'id': 4, 'name': u'target-4', 'target_source': {'id': 4, 'name': u'target-source-4', 'source': {'id': 4, 'name': u'source-4'}}} serializer = OneToOneTargetSerializer(data=data) self.assertTrue(serializer.is_valid()) @@ -77,7 +77,7 @@ class NestedOneToOneTests(TestCase): ] self.assertEquals(serializer.data, expected) - def test_foreign_key_create_with_invalid_data(self): + def test_one_to_one_create_with_invalid_data(self): data = {'id': 4, 'name': u'target-4', 'target_source': {'id': 4, 'name': u'target-source-4', 'source': {'id': 4}}} serializer = OneToOneTargetSerializer(data=data) self.assertFalse(serializer.is_valid()) -- cgit v1.2.3 From 8e5003a1f6e61664e99a376ef8c200f53c4507e1 Mon Sep 17 00:00:00 2001 From: Mark Aaron Shirley Date: Fri, 4 Jan 2013 13:54:51 +0100 Subject: Update errant test comment --- rest_framework/tests/nesting.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rest_framework/tests/nesting.py b/rest_framework/tests/nesting.py index 9cc46c6c..dbc8ebc9 100644 --- a/rest_framework/tests/nesting.py +++ b/rest_framework/tests/nesting.py @@ -66,7 +66,8 @@ class NestedOneToOneTests(TestCase): self.assertEquals(serializer.data, data) self.assertEqual(obj.name, u'target-4') - # Ensure (source 4, target 4) is added, and everything else is as expected + # Ensure (target 4, target_source 4, source 4) are added, and + # everything else is as expected. queryset = OneToOneTarget.objects.all() serializer = OneToOneTargetSerializer(queryset) expected = [ -- cgit v1.2.3 From 2d62bcd5aaa6d8f25f22b3e6b89ce26c44d9dfc4 Mon Sep 17 00:00:00 2001 From: Mark Aaron Shirley Date: Sat, 5 Jan 2013 00:02:48 +0100 Subject: Add one-to-one nested update and delete functionality --- rest_framework/serializers.py | 14 ++++++++++++++ rest_framework/tests/nesting.py | 41 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index a43a81d7..42218e7d 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -107,6 +107,7 @@ class BaseSerializer(WritableField): self.parent = None self.root = None self.partial = partial + self.delete = False self.context = context or {} @@ -215,6 +216,15 @@ class BaseSerializer(WritableField): for field_name, field in self.fields.items(): field.initialize(parent=self, field_name=field_name) + if isinstance(field, ModelSerializer) and self.object: + # Set the serializer object if it exists + pk_field_name = field.opts.model._meta.pk.name + obj = getattr(self.object, field_name) + nested_data = data.get(field_name) + pk_val = nested_data.get(pk_field_name) if nested_data else None + if obj and (getattr(obj, pk_field_name) == pk_val): + field.object = obj + field.delete = nested_data.get('_delete') try: field.field_from_native(data, files, field_name, reverted_data) except ValidationError as err: @@ -565,6 +575,10 @@ class ModelSerializer(Serializer): return instance def _save(self, parent=None, fk_field=None): + if self.delete: + self.object.delete() + return + if parent and fk_field: setattr(self.object, fk_field, parent) self.object.save() diff --git a/rest_framework/tests/nesting.py b/rest_framework/tests/nesting.py index dbc8ebc9..10d5db99 100644 --- a/rest_framework/tests/nesting.py +++ b/rest_framework/tests/nesting.py @@ -9,7 +9,8 @@ class OneToOneTarget(models.Model): class OneToOneTargetSource(models.Model): name = models.CharField(max_length=100) - target = models.OneToOneField(OneToOneTarget, related_name='target_source') + target = models.OneToOneField(OneToOneTarget, null=True, blank=True, + related_name='target_source') class OneToOneSource(models.Model): @@ -83,3 +84,41 @@ class NestedOneToOneTests(TestCase): serializer = OneToOneTargetSerializer(data=data) self.assertFalse(serializer.is_valid()) self.assertEquals(serializer.errors, {'target_source': [{'source': [{'name': [u'This field is required.']}]}]}) + + def test_one_to_one_update(self): + data = {'id': 3, 'name': u'target-3-updated', 'target_source': {'id': 3, 'name': u'target-source-3-updated', 'source': {'id': 3, 'name': u'source-3-updated'}}} + instance = OneToOneTarget.objects.get(pk=3) + serializer = OneToOneTargetSerializer(instance, data=data) + self.assertTrue(serializer.is_valid()) + obj = serializer.save() + self.assertEquals(serializer.data, data) + self.assertEqual(obj.name, u'target-3-updated') + + # Ensure (target 3, target_source 3, source 3) are updated, + # and everything else is as expected. + queryset = OneToOneTarget.objects.all() + serializer = OneToOneTargetSerializer(queryset) + expected = [ + {'id': 1, 'name': u'target-1', 'target_source': {'id': 1, 'name': u'target-source-1', 'source': {'id': 1, 'name': u'source-1'}}}, + {'id': 2, 'name': u'target-2', 'target_source': {'id': 2, 'name': u'target-source-2', 'source': {'id': 2, 'name': u'source-2'}}}, + {'id': 3, 'name': u'target-3-updated', 'target_source': {'id': 3, 'name': u'target-source-3-updated', 'source': {'id': 3, 'name': u'source-3-updated'}}} + ] + self.assertEquals(serializer.data, expected) + + def test_one_to_one_delete(self): + data = {'id': 3, 'name': u'target-3', 'target_source': {'_delete': True, 'id': 3, 'name': u'target-source-3', 'source': {'id': 3, 'name': u'source-3'}}} + instance = OneToOneTarget.objects.get(pk=3) + serializer = OneToOneTargetSerializer(instance, data=data) + self.assertTrue(serializer.is_valid()) + obj = serializer.save() + + # Ensure (target_source 3, source 3) are deleted, + # and everything else is as expected. + queryset = OneToOneTarget.objects.all() + serializer = OneToOneTargetSerializer(queryset) + expected = [ + {'id': 1, 'name': u'target-1', 'target_source': {'id': 1, 'name': u'target-source-1', 'source': {'id': 1, 'name': u'source-1'}}}, + {'id': 2, 'name': u'target-2', 'target_source': {'id': 2, 'name': u'target-source-2', 'source': {'id': 2, 'name': u'source-2'}}}, + {'id': 3, 'name': u'target-3', 'target_source': None} + ] + self.assertEquals(serializer.data, expected) -- cgit v1.2.3 From 34e14b01e402a2b2bcaf57aab76397757e260fd6 Mon Sep 17 00:00:00 2001 From: Mark Aaron Shirley Date: Tue, 8 Jan 2013 09:38:15 -0800 Subject: Move nested serializer logic into .field_from_native() --- rest_framework/serializers.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index 42218e7d..83bf1bc3 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -216,15 +216,6 @@ class BaseSerializer(WritableField): for field_name, field in self.fields.items(): field.initialize(parent=self, field_name=field_name) - if isinstance(field, ModelSerializer) and self.object: - # Set the serializer object if it exists - pk_field_name = field.opts.model._meta.pk.name - obj = getattr(self.object, field_name) - nested_data = data.get(field_name) - pk_val = nested_data.get(pk_field_name) if nested_data else None - if obj and (getattr(obj, pk_field_name) == pk_val): - field.object = obj - field.delete = nested_data.get('_delete') try: field.field_from_native(data, files, field_name, reverted_data) except ValidationError as err: @@ -393,6 +384,15 @@ class ModelSerializer(Serializer): raise ValidationError(self.error_messages['required']) return + if self.parent.object: + # Set the serializer object if it exists + pk_field_name = self.opts.model._meta.pk.name + pk_val = native.get(pk_field_name) + obj = getattr(self.parent.object, field_name) + if obj and (getattr(obj, pk_field_name) == pk_val): + self.object = obj + self.delete = native.get('_delete') + obj = self.from_native(native, files) if not self._errors: self.object = obj -- cgit v1.2.3 From 221f7326c7db7b6fa1a9ba2f0181ac075e3b482c Mon Sep 17 00:00:00 2001 From: Mark Aaron Shirley Date: Wed, 16 Jan 2013 16:03:59 -0800 Subject: Use None to delete nested object as opposed to _delete flag --- rest_framework/serializers.py | 27 ++++++++++++++------------- rest_framework/tests/nesting.py | 2 +- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index 83bf1bc3..a84370e9 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -107,7 +107,6 @@ class BaseSerializer(WritableField): self.parent = None self.root = None self.partial = partial - self.delete = False self.context = context or {} @@ -119,6 +118,7 @@ class BaseSerializer(WritableField): self._data = None self._files = None self._errors = None + self._delete = False ##### # Methods to determine which fields to use when (de)serializing objects. @@ -378,7 +378,7 @@ class ModelSerializer(Serializer): return try: - native = data[field_name] + value = data[field_name] except KeyError: if self.required: raise ValidationError(self.error_messages['required']) @@ -387,19 +387,20 @@ class ModelSerializer(Serializer): if self.parent.object: # Set the serializer object if it exists pk_field_name = self.opts.model._meta.pk.name - pk_val = native.get(pk_field_name) obj = getattr(self.parent.object, field_name) - if obj and (getattr(obj, pk_field_name) == pk_val): - self.object = obj - self.delete = native.get('_delete') - - obj = self.from_native(native, files) - if not self._errors: self.object = obj - into[self.source or field_name] = self + + if value in (None, ''): + self._delete = True + into[(self.source or field_name)] = self else: - # Propagate errors up to our parent - raise ValidationError(self._errors) + obj = self.from_native(value, files) + if not self._errors: + self.object = obj + into[self.source or field_name] = self + else: + # Propagate errors up to our parent + raise ValidationError(self._errors) def get_default_fields(self): """ @@ -575,7 +576,7 @@ class ModelSerializer(Serializer): return instance def _save(self, parent=None, fk_field=None): - if self.delete: + if self._delete: self.object.delete() return diff --git a/rest_framework/tests/nesting.py b/rest_framework/tests/nesting.py index 10d5db99..e4e32667 100644 --- a/rest_framework/tests/nesting.py +++ b/rest_framework/tests/nesting.py @@ -106,7 +106,7 @@ class NestedOneToOneTests(TestCase): self.assertEquals(serializer.data, expected) def test_one_to_one_delete(self): - data = {'id': 3, 'name': u'target-3', 'target_source': {'_delete': True, 'id': 3, 'name': u'target-source-3', 'source': {'id': 3, 'name': u'source-3'}}} + data = {'id': 3, 'name': u'target-3', 'target_source': None} instance = OneToOneTarget.objects.get(pk=3) serializer = OneToOneTargetSerializer(instance, data=data) self.assertTrue(serializer.is_valid()) -- cgit v1.2.3 From c07cdbdaca16e226c2e042443994a7579964faf3 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 17 Jan 2013 13:08:06 +0000 Subject: Kick travis into action --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index a7020166..d7cc1c6d 100644 --- a/README.md +++ b/README.md @@ -295,4 +295,3 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. [markdown]: http://pypi.python.org/pypi/Markdown/ [pyyaml]: http://pypi.python.org/pypi/PyYAML [django-filter]: http://pypi.python.org/pypi/django-filter - -- cgit v1.2.3 From 80a8d0f2793835f1a33be309ae3e51d4b7dbae39 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 18 Jan 2013 14:04:26 +0000 Subject: Update docs to reference DabApps commercial support --- docs/index.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/index.md b/docs/index.md index 497f1900..3ed11c02 100644 --- a/docs/index.md +++ b/docs/index.md @@ -132,9 +132,9 @@ Run the tests: ## Support -For support please see the [REST framework discussion group][group], or try the `#restframework` channel on `irc.freenode.net`. +For support please see the [REST framework discussion group][group], try the `#restframework` channel on `irc.freenode.net`, or raise a question on [Stack Overflow][stack-overflow], making sure to include the ['django-rest-framework'][django-rest-framework-tag] tag. -Paid support is also available from [DabApps], and can include work on REST framework core, or support with building your REST framework API. Please contact [Tom Christie][email] if you'd like to discuss commercial support options. +[Paid support is available][paid-support] from [DabApps][dabapps], and can include work on REST framework core, or support with building your REST framework API. Please [contact DabApps][contact-dabapps] if you'd like to discuss commercial support options. ## License @@ -209,5 +209,9 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. [credits]: topics/credits.md [group]: https://groups.google.com/forum/?fromgroups#!forum/django-rest-framework -[DabApps]: http://dabapps.com -[email]: mailto:tom@tomchristie.com +[stack-overflow]: http://stackoverflow.com/ +[django-rest-framework-tag]: http://stackoverflow.com/questions/tagged/django-rest-framework +[django-tag]: http://stackoverflow.com/questions/tagged/django +[paid-support]: http://dabapps.com/services/build/api-development/ +[dabapps]: http://dabapps.com +[contact-dabapps]: http://dabapps.com/contact/ -- cgit v1.2.3 From 6385ac519defc8e434fd4e24a48a680845341cb7 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 18 Jan 2013 19:47:57 +0000 Subject: Revert accidental merge. --- rest_framework/serializers.py | 61 +++----------------- rest_framework/tests/nesting.py | 124 ---------------------------------------- 2 files changed, 8 insertions(+), 177 deletions(-) delete mode 100644 rest_framework/tests/nesting.py diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index a84370e9..27458f96 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -93,7 +93,7 @@ class SerializerOptions(object): self.exclude = getattr(meta, 'exclude', ()) -class BaseSerializer(WritableField): +class BaseSerializer(Field): class Meta(object): pass @@ -118,7 +118,6 @@ class BaseSerializer(WritableField): self._data = None self._files = None self._errors = None - self._delete = False ##### # Methods to determine which fields to use when (de)serializing objects. @@ -219,10 +218,7 @@ class BaseSerializer(WritableField): try: field.field_from_native(data, files, field_name, reverted_data) except ValidationError as err: - if hasattr(err, 'message_dict'): - self._errors[field_name] = [err.message_dict] - else: - self._errors[field_name] = list(err.messages) + self._errors[field_name] = list(err.messages) return reverted_data @@ -373,35 +369,6 @@ class ModelSerializer(Serializer): """ _options_class = ModelSerializerOptions - def field_from_native(self, data, files, field_name, into): - if self.read_only: - return - - try: - value = data[field_name] - except KeyError: - if self.required: - raise ValidationError(self.error_messages['required']) - return - - if self.parent.object: - # Set the serializer object if it exists - pk_field_name = self.opts.model._meta.pk.name - obj = getattr(self.parent.object, field_name) - self.object = obj - - if value in (None, ''): - self._delete = True - into[(self.source or field_name)] = self - else: - obj = self.from_native(value, files) - if not self._errors: - self.object = obj - into[self.source or field_name] = self - else: - # Propagate errors up to our parent - raise ValidationError(self._errors) - def get_default_fields(self): """ Return all the fields that should be serialized for the model. @@ -575,13 +542,10 @@ class ModelSerializer(Serializer): return instance - def _save(self, parent=None, fk_field=None): - if self._delete: - self.object.delete() - return - - if parent and fk_field: - setattr(self.object, fk_field, parent) + def save(self): + """ + Save the deserialized object and return it. + """ self.object.save() if getattr(self, 'm2m_data', None): @@ -591,18 +555,9 @@ class ModelSerializer(Serializer): if getattr(self, 'related_data', None): for accessor_name, object_list in self.related_data.items(): - if isinstance(object_list, ModelSerializer): - fk_field = self.object._meta.get_field_by_name(accessor_name)[0].field.name - object_list._save(parent=self.object, fk_field=fk_field) - else: - setattr(self.object, accessor_name, object_list) + setattr(self.object, accessor_name, object_list) self.related_data = {} - - def save(self): - """ - Save the deserialized object and return it. - """ - self._save() + return self.object diff --git a/rest_framework/tests/nesting.py b/rest_framework/tests/nesting.py deleted file mode 100644 index e4e32667..00000000 --- a/rest_framework/tests/nesting.py +++ /dev/null @@ -1,124 +0,0 @@ -from django.db import models -from django.test import TestCase -from rest_framework import serializers - - -class OneToOneTarget(models.Model): - name = models.CharField(max_length=100) - - -class OneToOneTargetSource(models.Model): - name = models.CharField(max_length=100) - target = models.OneToOneField(OneToOneTarget, null=True, blank=True, - related_name='target_source') - - -class OneToOneSource(models.Model): - name = models.CharField(max_length=100) - target_source = models.OneToOneField(OneToOneTargetSource, related_name='source') - - -class OneToOneSourceSerializer(serializers.ModelSerializer): - class Meta: - model = OneToOneSource - exclude = ('target_source', ) - - -class OneToOneTargetSourceSerializer(serializers.ModelSerializer): - source = OneToOneSourceSerializer() - - class Meta: - model = OneToOneTargetSource - exclude = ('target', ) - -class OneToOneTargetSerializer(serializers.ModelSerializer): - target_source = OneToOneTargetSourceSerializer() - - class Meta: - model = OneToOneTarget - - -class NestedOneToOneTests(TestCase): - def setUp(self): - for idx in range(1, 4): - target = OneToOneTarget(name='target-%d' % idx) - target.save() - target_source = OneToOneTargetSource(name='target-source-%d' % idx, target=target) - target_source.save() - source = OneToOneSource(name='source-%d' % idx, target_source=target_source) - source.save() - - def test_one_to_one_retrieve(self): - queryset = OneToOneTarget.objects.all() - serializer = OneToOneTargetSerializer(queryset) - expected = [ - {'id': 1, 'name': u'target-1', 'target_source': {'id': 1, 'name': u'target-source-1', 'source': {'id': 1, 'name': u'source-1'}}}, - {'id': 2, 'name': u'target-2', 'target_source': {'id': 2, 'name': u'target-source-2', 'source': {'id': 2, 'name': u'source-2'}}}, - {'id': 3, 'name': u'target-3', 'target_source': {'id': 3, 'name': u'target-source-3', 'source': {'id': 3, 'name': u'source-3'}}} - ] - self.assertEquals(serializer.data, expected) - - - def test_one_to_one_create(self): - data = {'id': 4, 'name': u'target-4', 'target_source': {'id': 4, 'name': u'target-source-4', 'source': {'id': 4, 'name': u'source-4'}}} - serializer = OneToOneTargetSerializer(data=data) - self.assertTrue(serializer.is_valid()) - obj = serializer.save() - self.assertEquals(serializer.data, data) - self.assertEqual(obj.name, u'target-4') - - # Ensure (target 4, target_source 4, source 4) are added, and - # everything else is as expected. - queryset = OneToOneTarget.objects.all() - serializer = OneToOneTargetSerializer(queryset) - expected = [ - {'id': 1, 'name': u'target-1', 'target_source': {'id': 1, 'name': u'target-source-1', 'source': {'id': 1, 'name': u'source-1'}}}, - {'id': 2, 'name': u'target-2', 'target_source': {'id': 2, 'name': u'target-source-2', 'source': {'id': 2, 'name': u'source-2'}}}, - {'id': 3, 'name': u'target-3', 'target_source': {'id': 3, 'name': u'target-source-3', 'source': {'id': 3, 'name': u'source-3'}}}, - {'id': 4, 'name': u'target-4', 'target_source': {'id': 4, 'name': u'target-source-4', 'source': {'id': 4, 'name': u'source-4'}}} - ] - self.assertEquals(serializer.data, expected) - - def test_one_to_one_create_with_invalid_data(self): - data = {'id': 4, 'name': u'target-4', 'target_source': {'id': 4, 'name': u'target-source-4', 'source': {'id': 4}}} - serializer = OneToOneTargetSerializer(data=data) - self.assertFalse(serializer.is_valid()) - self.assertEquals(serializer.errors, {'target_source': [{'source': [{'name': [u'This field is required.']}]}]}) - - def test_one_to_one_update(self): - data = {'id': 3, 'name': u'target-3-updated', 'target_source': {'id': 3, 'name': u'target-source-3-updated', 'source': {'id': 3, 'name': u'source-3-updated'}}} - instance = OneToOneTarget.objects.get(pk=3) - serializer = OneToOneTargetSerializer(instance, data=data) - self.assertTrue(serializer.is_valid()) - obj = serializer.save() - self.assertEquals(serializer.data, data) - self.assertEqual(obj.name, u'target-3-updated') - - # Ensure (target 3, target_source 3, source 3) are updated, - # and everything else is as expected. - queryset = OneToOneTarget.objects.all() - serializer = OneToOneTargetSerializer(queryset) - expected = [ - {'id': 1, 'name': u'target-1', 'target_source': {'id': 1, 'name': u'target-source-1', 'source': {'id': 1, 'name': u'source-1'}}}, - {'id': 2, 'name': u'target-2', 'target_source': {'id': 2, 'name': u'target-source-2', 'source': {'id': 2, 'name': u'source-2'}}}, - {'id': 3, 'name': u'target-3-updated', 'target_source': {'id': 3, 'name': u'target-source-3-updated', 'source': {'id': 3, 'name': u'source-3-updated'}}} - ] - self.assertEquals(serializer.data, expected) - - def test_one_to_one_delete(self): - data = {'id': 3, 'name': u'target-3', 'target_source': None} - instance = OneToOneTarget.objects.get(pk=3) - serializer = OneToOneTargetSerializer(instance, data=data) - self.assertTrue(serializer.is_valid()) - obj = serializer.save() - - # Ensure (target_source 3, source 3) are deleted, - # and everything else is as expected. - queryset = OneToOneTarget.objects.all() - serializer = OneToOneTargetSerializer(queryset) - expected = [ - {'id': 1, 'name': u'target-1', 'target_source': {'id': 1, 'name': u'target-source-1', 'source': {'id': 1, 'name': u'source-1'}}}, - {'id': 2, 'name': u'target-2', 'target_source': {'id': 2, 'name': u'target-source-2', 'source': {'id': 2, 'name': u'source-2'}}}, - {'id': 3, 'name': u'target-3', 'target_source': None} - ] - self.assertEquals(serializer.data, expected) -- cgit v1.2.3 From 211bb89eecfadd6831a0c59852926f16ea6bf733 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 18 Jan 2013 21:29:21 +0000 Subject: Raise Validation Errors when relationships receive incorrect types. Fixes #590. --- rest_framework/relations.py | 20 ++-- rest_framework/tests/relations_hyperlink.py | 9 +- rest_framework/tests/relations_pk.py | 7 ++ rest_framework/tests/relations_slug.py | 162 ++++++++++++++++++++++++++-- 4 files changed, 177 insertions(+), 21 deletions(-) diff --git a/rest_framework/relations.py b/rest_framework/relations.py index 7ded3891..af63ceaa 100644 --- a/rest_framework/relations.py +++ b/rest_framework/relations.py @@ -177,7 +177,7 @@ class PrimaryKeyRelatedField(RelatedField): default_error_messages = { 'does_not_exist': _("Invalid pk '%s' - object does not exist."), - 'invalid': _('Invalid value.'), + 'incorrect_type': _('Incorrect type. Expected pk value, received %s.'), } # TODO: Remove these field hacks... @@ -208,7 +208,8 @@ class PrimaryKeyRelatedField(RelatedField): msg = self.error_messages['does_not_exist'] % smart_unicode(data) raise ValidationError(msg) except (TypeError, ValueError): - msg = self.error_messages['invalid'] + received = type(data).__name__ + msg = self.error_messages['incorrect_type'] % received raise ValidationError(msg) def field_to_native(self, obj, field_name): @@ -235,7 +236,7 @@ class ManyPrimaryKeyRelatedField(ManyRelatedField): default_error_messages = { 'does_not_exist': _("Invalid pk '%s' - object does not exist."), - 'invalid': _('Invalid value.'), + 'incorrect_type': _('Incorrect type. Expected pk value, received %s.'), } def prepare_value(self, obj): @@ -275,7 +276,8 @@ class ManyPrimaryKeyRelatedField(ManyRelatedField): msg = self.error_messages['does_not_exist'] % smart_unicode(data) raise ValidationError(msg) except (TypeError, ValueError): - msg = self.error_messages['invalid'] + received = type(data).__name__ + msg = self.error_messages['incorrect_type'] % received raise ValidationError(msg) ### Slug relationships @@ -333,7 +335,7 @@ class HyperlinkedRelatedField(RelatedField): 'incorrect_match': _('Invalid hyperlink - Incorrect URL match'), 'configuration_error': _('Invalid hyperlink due to configuration error'), 'does_not_exist': _("Invalid hyperlink - object does not exist."), - 'invalid': _('Invalid value.'), + 'incorrect_type': _('Incorrect type. Expected url string, received %s.'), } def __init__(self, *args, **kwargs): @@ -397,8 +399,8 @@ class HyperlinkedRelatedField(RelatedField): try: http_prefix = value.startswith('http:') or value.startswith('https:') except AttributeError: - msg = self.error_messages['invalid'] - raise ValidationError(msg) + msg = self.error_messages['incorrect_type'] + raise ValidationError(msg % type(value).__name__) if http_prefix: # If needed convert absolute URLs to relative path @@ -434,8 +436,8 @@ class HyperlinkedRelatedField(RelatedField): except ObjectDoesNotExist: raise ValidationError(self.error_messages['does_not_exist']) except (TypeError, ValueError): - msg = self.error_messages['invalid'] - raise ValidationError(msg) + msg = self.error_messages['incorrect_type'] + raise ValidationError(msg % type(value).__name__) return obj diff --git a/rest_framework/tests/relations_hyperlink.py b/rest_framework/tests/relations_hyperlink.py index 7d65eae7..6d137f68 100644 --- a/rest_framework/tests/relations_hyperlink.py +++ b/rest_framework/tests/relations_hyperlink.py @@ -215,6 +215,13 @@ class HyperlinkedForeignKeyTests(TestCase): ] self.assertEquals(serializer.data, expected) + def test_foreign_key_update_incorrect_type(self): + data = {'url': '/foreignkeysource/1/', 'name': u'source-1', 'target': 2} + instance = ForeignKeySource.objects.get(pk=1) + serializer = ForeignKeySourceSerializer(instance, data=data) + self.assertFalse(serializer.is_valid()) + self.assertEquals(serializer.errors, {'target': [u'Incorrect type. Expected url string, received int.']}) + def test_reverse_foreign_key_update(self): data = {'url': '/foreignkeytarget/2/', 'name': u'target-2', 'sources': ['/foreignkeysource/1/', '/foreignkeysource/3/']} instance = ForeignKeyTarget.objects.get(pk=2) @@ -227,7 +234,7 @@ class HyperlinkedForeignKeyTests(TestCase): expected = [ {'url': '/foreignkeytarget/1/', 'name': u'target-1', 'sources': ['/foreignkeysource/1/', '/foreignkeysource/2/', '/foreignkeysource/3/']}, {'url': '/foreignkeytarget/2/', 'name': u'target-2', 'sources': []}, - ] + ] self.assertEquals(new_serializer.data, expected) serializer.save() diff --git a/rest_framework/tests/relations_pk.py b/rest_framework/tests/relations_pk.py index dd1e86b5..3391e60a 100644 --- a/rest_framework/tests/relations_pk.py +++ b/rest_framework/tests/relations_pk.py @@ -194,6 +194,13 @@ class PKForeignKeyTests(TestCase): ] self.assertEquals(serializer.data, expected) + def test_foreign_key_update_incorrect_type(self): + data = {'id': 1, 'name': u'source-1', 'target': 'foo'} + instance = ForeignKeySource.objects.get(pk=1) + serializer = ForeignKeySourceSerializer(instance, data=data) + self.assertFalse(serializer.is_valid()) + self.assertEquals(serializer.errors, {'target': [u'Incorrect type. Expected pk value, received str.']}) + def test_reverse_foreign_key_update(self): data = {'id': 2, 'name': u'target-2', 'sources': [1, 3]} instance = ForeignKeyTarget.objects.get(pk=2) diff --git a/rest_framework/tests/relations_slug.py b/rest_framework/tests/relations_slug.py index 503b61e8..37ccc75e 100644 --- a/rest_framework/tests/relations_slug.py +++ b/rest_framework/tests/relations_slug.py @@ -1,9 +1,23 @@ from django.test import TestCase from rest_framework import serializers -from rest_framework.tests.models import NullableForeignKeySource, ForeignKeyTarget +from rest_framework.tests.models import NullableForeignKeySource, ForeignKeySource, ForeignKeyTarget -class NullableSlugSourceSerializer(serializers.ModelSerializer): +class ForeignKeyTargetSerializer(serializers.ModelSerializer): + sources = serializers.ManySlugRelatedField(slug_field='name') + + class Meta: + model = ForeignKeyTarget + + +class ForeignKeySourceSerializer(serializers.ModelSerializer): + target = serializers.SlugRelatedField(slug_field='name') + + class Meta: + model = ForeignKeySource + + +class NullableForeignKeySourceSerializer(serializers.ModelSerializer): target = serializers.SlugRelatedField(slug_field='name', null=True) class Meta: @@ -11,6 +25,132 @@ class NullableSlugSourceSerializer(serializers.ModelSerializer): # TODO: M2M Tests, FKTests (Non-nulable), One2One +class PKForeignKeyTests(TestCase): + def setUp(self): + target = ForeignKeyTarget(name='target-1') + target.save() + new_target = ForeignKeyTarget(name='target-2') + new_target.save() + for idx in range(1, 4): + source = ForeignKeySource(name='source-%d' % idx, target=target) + source.save() + + def test_foreign_key_retrieve(self): + queryset = ForeignKeySource.objects.all() + serializer = ForeignKeySourceSerializer(queryset) + expected = [ + {'id': 1, 'name': u'source-1', 'target': 'target-1'}, + {'id': 2, 'name': u'source-2', 'target': 'target-1'}, + {'id': 3, 'name': u'source-3', 'target': 'target-1'} + ] + self.assertEquals(serializer.data, expected) + + def test_reverse_foreign_key_retrieve(self): + queryset = ForeignKeyTarget.objects.all() + serializer = ForeignKeyTargetSerializer(queryset) + expected = [ + {'id': 1, 'name': u'target-1', 'sources': ['source-1', 'source-2', 'source-3']}, + {'id': 2, 'name': u'target-2', 'sources': []}, + ] + self.assertEquals(serializer.data, expected) + + def test_foreign_key_update(self): + data = {'id': 1, 'name': u'source-1', 'target': 'target-2'} + instance = ForeignKeySource.objects.get(pk=1) + serializer = ForeignKeySourceSerializer(instance, data=data) + self.assertTrue(serializer.is_valid()) + self.assertEquals(serializer.data, data) + serializer.save() + + # Ensure source 1 is updated, and everything else is as expected + queryset = ForeignKeySource.objects.all() + serializer = ForeignKeySourceSerializer(queryset) + expected = [ + {'id': 1, 'name': u'source-1', 'target': 'target-2'}, + {'id': 2, 'name': u'source-2', 'target': 'target-1'}, + {'id': 3, 'name': u'source-3', 'target': 'target-1'} + ] + self.assertEquals(serializer.data, expected) + + def test_foreign_key_update_incorrect_type(self): + data = {'id': 1, 'name': u'source-1', 'target': 123} + instance = ForeignKeySource.objects.get(pk=1) + serializer = ForeignKeySourceSerializer(instance, data=data) + self.assertFalse(serializer.is_valid()) + self.assertEquals(serializer.errors, {'target': [u'Object with name=123 does not exist.']}) + + def test_reverse_foreign_key_update(self): + data = {'id': 2, 'name': u'target-2', 'sources': ['source-1', 'source-3']} + instance = ForeignKeyTarget.objects.get(pk=2) + serializer = ForeignKeyTargetSerializer(instance, data=data) + self.assertTrue(serializer.is_valid()) + # We shouldn't have saved anything to the db yet since save + # hasn't been called. + queryset = ForeignKeyTarget.objects.all() + new_serializer = ForeignKeyTargetSerializer(queryset) + expected = [ + {'id': 1, 'name': u'target-1', 'sources': ['source-1', 'source-2', 'source-3']}, + {'id': 2, 'name': u'target-2', 'sources': []}, + ] + self.assertEquals(new_serializer.data, expected) + + serializer.save() + self.assertEquals(serializer.data, data) + + # Ensure target 2 is update, and everything else is as expected + queryset = ForeignKeyTarget.objects.all() + serializer = ForeignKeyTargetSerializer(queryset) + expected = [ + {'id': 1, 'name': u'target-1', 'sources': ['source-2']}, + {'id': 2, 'name': u'target-2', 'sources': ['source-1', 'source-3']}, + ] + self.assertEquals(serializer.data, expected) + + def test_foreign_key_create(self): + data = {'id': 4, 'name': u'source-4', 'target': 'target-2'} + serializer = ForeignKeySourceSerializer(data=data) + serializer.is_valid() + self.assertTrue(serializer.is_valid()) + obj = serializer.save() + self.assertEquals(serializer.data, data) + self.assertEqual(obj.name, u'source-4') + + # Ensure source 4 is added, and everything else is as expected + queryset = ForeignKeySource.objects.all() + serializer = ForeignKeySourceSerializer(queryset) + expected = [ + {'id': 1, 'name': u'source-1', 'target': 'target-1'}, + {'id': 2, 'name': u'source-2', 'target': 'target-1'}, + {'id': 3, 'name': u'source-3', 'target': 'target-1'}, + {'id': 4, 'name': u'source-4', 'target': 'target-2'}, + ] + self.assertEquals(serializer.data, expected) + + def test_reverse_foreign_key_create(self): + data = {'id': 3, 'name': u'target-3', 'sources': ['source-1', 'source-3']} + serializer = ForeignKeyTargetSerializer(data=data) + self.assertTrue(serializer.is_valid()) + obj = serializer.save() + self.assertEquals(serializer.data, data) + self.assertEqual(obj.name, u'target-3') + + # Ensure target 3 is added, and everything else is as expected + queryset = ForeignKeyTarget.objects.all() + serializer = ForeignKeyTargetSerializer(queryset) + expected = [ + {'id': 1, 'name': u'target-1', 'sources': ['source-2']}, + {'id': 2, 'name': u'target-2', 'sources': []}, + {'id': 3, 'name': u'target-3', 'sources': ['source-1', 'source-3']}, + ] + self.assertEquals(serializer.data, expected) + + def test_foreign_key_update_with_invalid_null(self): + data = {'id': 1, 'name': u'source-1', 'target': None} + instance = ForeignKeySource.objects.get(pk=1) + serializer = ForeignKeySourceSerializer(instance, data=data) + self.assertFalse(serializer.is_valid()) + self.assertEquals(serializer.errors, {'target': [u'Value may not be null']}) + class SlugNullableForeignKeyTests(TestCase): def setUp(self): @@ -24,7 +164,7 @@ class SlugNullableForeignKeyTests(TestCase): def test_foreign_key_retrieve_with_null(self): queryset = NullableForeignKeySource.objects.all() - serializer = NullableSlugSourceSerializer(queryset) + serializer = NullableForeignKeySourceSerializer(queryset) expected = [ {'id': 1, 'name': u'source-1', 'target': 'target-1'}, {'id': 2, 'name': u'source-2', 'target': 'target-1'}, @@ -34,7 +174,7 @@ class SlugNullableForeignKeyTests(TestCase): def test_foreign_key_create_with_valid_null(self): data = {'id': 4, 'name': u'source-4', 'target': None} - serializer = NullableSlugSourceSerializer(data=data) + serializer = NullableForeignKeySourceSerializer(data=data) self.assertTrue(serializer.is_valid()) obj = serializer.save() self.assertEquals(serializer.data, data) @@ -42,7 +182,7 @@ class SlugNullableForeignKeyTests(TestCase): # Ensure source 4 is created, and everything else is as expected queryset = NullableForeignKeySource.objects.all() - serializer = NullableSlugSourceSerializer(queryset) + serializer = NullableForeignKeySourceSerializer(queryset) expected = [ {'id': 1, 'name': u'source-1', 'target': 'target-1'}, {'id': 2, 'name': u'source-2', 'target': 'target-1'}, @@ -58,7 +198,7 @@ class SlugNullableForeignKeyTests(TestCase): """ data = {'id': 4, 'name': u'source-4', 'target': ''} expected_data = {'id': 4, 'name': u'source-4', 'target': None} - serializer = NullableSlugSourceSerializer(data=data) + serializer = NullableForeignKeySourceSerializer(data=data) self.assertTrue(serializer.is_valid()) obj = serializer.save() self.assertEquals(serializer.data, expected_data) @@ -66,7 +206,7 @@ class SlugNullableForeignKeyTests(TestCase): # Ensure source 4 is created, and everything else is as expected queryset = NullableForeignKeySource.objects.all() - serializer = NullableSlugSourceSerializer(queryset) + serializer = NullableForeignKeySourceSerializer(queryset) expected = [ {'id': 1, 'name': u'source-1', 'target': 'target-1'}, {'id': 2, 'name': u'source-2', 'target': 'target-1'}, @@ -78,14 +218,14 @@ class SlugNullableForeignKeyTests(TestCase): def test_foreign_key_update_with_valid_null(self): data = {'id': 1, 'name': u'source-1', 'target': None} instance = NullableForeignKeySource.objects.get(pk=1) - serializer = NullableSlugSourceSerializer(instance, data=data) + serializer = NullableForeignKeySourceSerializer(instance, data=data) self.assertTrue(serializer.is_valid()) self.assertEquals(serializer.data, data) serializer.save() # Ensure source 1 is updated, and everything else is as expected queryset = NullableForeignKeySource.objects.all() - serializer = NullableSlugSourceSerializer(queryset) + serializer = NullableForeignKeySourceSerializer(queryset) expected = [ {'id': 1, 'name': u'source-1', 'target': None}, {'id': 2, 'name': u'source-2', 'target': 'target-1'}, @@ -101,14 +241,14 @@ class SlugNullableForeignKeyTests(TestCase): data = {'id': 1, 'name': u'source-1', 'target': ''} expected_data = {'id': 1, 'name': u'source-1', 'target': None} instance = NullableForeignKeySource.objects.get(pk=1) - serializer = NullableSlugSourceSerializer(instance, data=data) + serializer = NullableForeignKeySourceSerializer(instance, data=data) self.assertTrue(serializer.is_valid()) self.assertEquals(serializer.data, expected_data) serializer.save() # Ensure source 1 is updated, and everything else is as expected queryset = NullableForeignKeySource.objects.all() - serializer = NullableSlugSourceSerializer(queryset) + serializer = NullableForeignKeySourceSerializer(queryset) expected = [ {'id': 1, 'name': u'source-1', 'target': None}, {'id': 2, 'name': u'source-2', 'target': 'target-1'}, -- cgit v1.2.3 From 06724017810c84a36521762a6f025bf4d3007006 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 18 Jan 2013 22:00:59 +0000 Subject: Update release notes. --- docs/topics/release-notes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/topics/release-notes.md b/docs/topics/release-notes.md index e00a5e93..bbe11fac 100644 --- a/docs/topics/release-notes.md +++ b/docs/topics/release-notes.md @@ -19,6 +19,7 @@ Major version numbers (x.0.0) are reserved for project milestones. No major poi ### Master * Support json encoding of timedelta objects. +* Bugfix: Return proper validation errors when incorrect types supplied for relational fields. * Bugfix: Support nullable FKs with `SlugRelatedField`. ### 2.1.16 -- cgit v1.2.3 From bd089836a138bc845eac5f89a071d2768bcf2e0e Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 18 Jan 2013 22:01:33 +0000 Subject: Note on setting ContentType. Fixes #589. Refs #586. --- docs/api-guide/parsers.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/api-guide/parsers.md b/docs/api-guide/parsers.md index de968557..3a1918f4 100644 --- a/docs/api-guide/parsers.md +++ b/docs/api-guide/parsers.md @@ -14,6 +14,18 @@ REST framework includes a number of built in Parser classes, that allow you to a The set of valid parsers for a view is always defined as a list of classes. When either `request.DATA` or `request.FILES` is accessed, REST framework will examine the `Content-Type` header on the incoming request, and determine which parser to use to parse the request content. +--- + +**Note**: When developing client applications always remember to make sure you're setting the `Content-Type` header when sending data in an HTTP request. + +If you don't set the content type, most clients will default to using `'application/x-www-form-urlencoded'`, which may not be what you wanted. + +As an example, if you are sending `json` encoded data using jQuery with the [.ajax() method][jquery-ajax], you should make sure to include the `contentType: 'application/json'` setting. + +If you're working with the API using the command line tool `curl`, you can use the `-H` flag to include a `ContentType` header. For example, to set the content type to `json` use `-H 'content-type: application/json'`. + +--- + ## Setting the parsers The default set of parsers may be set globally, using the `DEFAULT_PARSER_CLASSES` setting. For example, the following settings would allow requests with `YAML` content. @@ -169,6 +181,7 @@ The following third party packages are also available. [MessagePack][messagepack] is a fast, efficient binary serialization format. [Juan Riaza][juanriaza] maintains the [djangorestframework-msgpack][djangorestframework-msgpack] package which provides MessagePack renderer and parser support for REST framework. +[jquery-ajax]: http://api.jquery.com/jQuery.ajax/ [cite]: https://groups.google.com/d/topic/django-developers/dxI4qVzrBY4/discussion [messagepack]: https://github.com/juanriaza/django-rest-framework-msgpack [juanriaza]: https://github.com/juanriaza -- cgit v1.2.3 From 15ad94c6111735044dd6a38a9b48d23a22b8b18f Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 18 Jan 2013 22:06:41 +0000 Subject: Drop the curl notes. Unnecessary. --- docs/api-guide/parsers.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/api-guide/parsers.md b/docs/api-guide/parsers.md index 3a1918f4..0cd01639 100644 --- a/docs/api-guide/parsers.md +++ b/docs/api-guide/parsers.md @@ -22,8 +22,6 @@ If you don't set the content type, most clients will default to using `'applicat As an example, if you are sending `json` encoded data using jQuery with the [.ajax() method][jquery-ajax], you should make sure to include the `contentType: 'application/json'` setting. -If you're working with the API using the command line tool `curl`, you can use the `-H` flag to include a `ContentType` header. For example, to set the content type to `json` use `-H 'content-type: application/json'`. - --- ## Setting the parsers -- cgit v1.2.3 From 73b69b9bb6f92f0d674c10420ac462b51cad233d Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 18 Jan 2013 22:26:36 +0000 Subject: Rephrasing. --- docs/api-guide/authentication.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/docs/api-guide/authentication.md b/docs/api-guide/authentication.md index 330cf7a4..c0f9c072 100644 --- a/docs/api-guide/authentication.md +++ b/docs/api-guide/authentication.md @@ -60,17 +60,15 @@ Or, if you're using the `@api_view` decorator with function based views. } return Response(content) -## Apache mod_wsgi Specific Configuration +## Apache mod_wsgi specific configuration -Unlike other HTTP headers, the authorisation header is not passed through to a WSGI application by default. This is the case as doing so could leak information about passwords through to a WSGI application which should not be able to see them when Apache is performing authentication. +Note that if deploying to [Apache using mod_wsgi][mod_wsgi_official], the authorization header is not passed through to a WSGI application by default, as it is assumed that authentication will be handled by Apache, rather than at an application level. -If it is desired that the WSGI application be responsible for handling user authentication, then it is necessary to explicitly configure mod_wsgi to pass the required headers through to the application. This can be done by specifying the WSGIPassAuthorization directive in the appropriate context and setting it to 'On'. +If you are deploying to Apache, and using any non-session based authentication, you will need to explicitly configure mod_wsgi to pass the required headers through to the application. This can be done by specifying the `WSGIPassAuthorization` directive in the appropriate context and setting it to `'On'`. # this can go in either server config, virtual host, directory or .htaccess WSGIPassAuthorization On -[Reference to official mod_wsgi documentation][mod_wsgi_official] - # API Reference ## BasicAuthentication -- cgit v1.2.3 From 4b61ead53ff3d13e55346e07317612096f704af8 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 18 Jan 2013 22:30:03 +0000 Subject: Added @nemesisdesign, for documentation on Apache mod_wsgi setup. Thanks! Refs #588. --- docs/topics/credits.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/topics/credits.md b/docs/topics/credits.md index 68d07f20..6529813f 100644 --- a/docs/topics/credits.md +++ b/docs/topics/credits.md @@ -92,6 +92,7 @@ The following people have helped make REST framework great. * Johannes Spielmann - [shezi] * James Cleveland - [radiosilence] * Steve Gregory - [steve-gregory] +* Federico Capoano - [nemesisdesign] Many thanks to everyone who's contributed to the project. @@ -219,3 +220,4 @@ You can also contact [@_tomchristie][twitter] directly on twitter. [shezi]: https://github.com/shezi [radiosilence]: https://github.com/radiosilence [steve-gregory]: https://github.com/steve-gregory +[nemesisdesign]: https://github.com/nemesisdesign -- cgit v1.2.3 From a98049c5de9a4ac9e93eac9798e00df9c93caf81 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Sat, 19 Jan 2013 15:25:32 +0000 Subject: Drop unneeded test --- rest_framework/tests/decorators.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/rest_framework/tests/decorators.py b/rest_framework/tests/decorators.py index 5e6bce4e..4012188d 100644 --- a/rest_framework/tests/decorators.py +++ b/rest_framework/tests/decorators.py @@ -28,14 +28,6 @@ class DecoratorTestCase(TestCase): response.request = request return APIView.finalize_response(self, request, response, *args, **kwargs) - def test_wrap_view(self): - - @api_view(['GET']) - def view(request): - return Response({}) - - self.assertTrue(isinstance(view.cls_instance, APIView)) - def test_calling_method(self): @api_view(['GET']) -- cgit v1.2.3 From af3fd098459fb559788735cb6b49a7108e11b18e Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Sat, 19 Jan 2013 15:31:21 +0000 Subject: Tweak imports in tutorial. Fixes #597. --- docs/tutorial/1-serialization.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/tutorial/1-serialization.md b/docs/tutorial/1-serialization.md index d3ada9e3..f5ff167f 100644 --- a/docs/tutorial/1-serialization.md +++ b/docs/tutorial/1-serialization.md @@ -109,7 +109,7 @@ The first thing we need to get started on our Web API is provide a way of serial from django.forms import widgets from rest_framework import serializers - from snippets import models + from snippets.models import Snippet class SnippetSerializer(serializers.Serializer): @@ -138,7 +138,7 @@ The first thing we need to get started on our Web API is provide a way of serial return instance # Create new instance - return models.Snippet(**attrs) + return Snippet(**attrs) The first part of serializer class defines the fields that get serialized/deserialized. The `restore_object` method defines how fully fledged instances get created when deserializing data. -- cgit v1.2.3 From 37d49429ca34eed86ea142e5dceea4cd9536df2d Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Sat, 19 Jan 2013 15:51:14 +0000 Subject: Raise assertion errors if @api_view decorator is applied incorrectly. Fixes #596. --- rest_framework/decorators.py | 9 +++++++++ rest_framework/tests/decorators.py | 22 ++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/rest_framework/decorators.py b/rest_framework/decorators.py index 1b710a03..7a4103e1 100644 --- a/rest_framework/decorators.py +++ b/rest_framework/decorators.py @@ -1,4 +1,5 @@ from rest_framework.views import APIView +import types def api_view(http_method_names): @@ -23,6 +24,14 @@ def api_view(http_method_names): # pass # WrappedAPIView.__doc__ = func.doc <--- Not possible to do this + # api_view applied without (method_names) + assert not(isinstance(http_method_names, types.FunctionType)), \ + '@api_view missing list of allowed HTTP methods' + + # api_view applied with eg. string instead of list of strings + assert isinstance(http_method_names, (list, tuple)), \ + '@api_view expected a list of strings, recieved %s' % type(http_method_names).__name__ + allowed_methods = set(http_method_names) | set(('options',)) WrappedAPIView.http_method_names = [method.lower() for method in allowed_methods] diff --git a/rest_framework/tests/decorators.py b/rest_framework/tests/decorators.py index 4012188d..82f912e9 100644 --- a/rest_framework/tests/decorators.py +++ b/rest_framework/tests/decorators.py @@ -28,6 +28,28 @@ class DecoratorTestCase(TestCase): response.request = request return APIView.finalize_response(self, request, response, *args, **kwargs) + def test_api_view_incorrect(self): + """ + If @api_view is not applied correct, we should raise an assertion. + """ + + @api_view + def view(request): + return Response() + + request = self.factory.get('/') + self.assertRaises(AssertionError, view, request) + + def test_api_view_incorrect_arguments(self): + """ + If @api_view is missing arguments, we should raise an assertion. + """ + + with self.assertRaises(AssertionError): + @api_view('GET') + def view(request): + return Response() + def test_calling_method(self): @api_view(['GET']) -- cgit v1.2.3 From 2c05faa52ae65f96fdcc73efceb6c44511698261 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Sat, 19 Jan 2013 16:56:48 +0000 Subject: `format_suffix_patterns` now support `include`-style nested URL patterns. Fixes #593 --- rest_framework/urlpatterns.py | 44 ++++++++++++++++++++++++++++++------------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/rest_framework/urlpatterns.py b/rest_framework/urlpatterns.py index 143928c9..0aaad334 100644 --- a/rest_framework/urlpatterns.py +++ b/rest_framework/urlpatterns.py @@ -1,5 +1,34 @@ -from rest_framework.compat import url +from rest_framework.compat import url, include from rest_framework.settings import api_settings +from django.core.urlresolvers import RegexURLResolver + + +def apply_suffix_patterns(urlpatterns, suffix_pattern, suffix_required): + ret = [] + for urlpattern in urlpatterns: + if not isinstance(urlpattern, RegexURLResolver): + # Regular URL pattern + + # Form our complementing '.format' urlpattern + regex = urlpattern.regex.pattern.rstrip('$') + suffix_pattern + view = urlpattern._callback or urlpattern._callback_str + kwargs = urlpattern.default_args + name = urlpattern.name + # Add in both the existing and the new urlpattern + if not suffix_required: + ret.append(urlpattern) + ret.append(url(regex, view, kwargs, name)) + else: + # Set of included URL patterns + print(type(urlpattern)) + regex = urlpattern.regex.pattern + namespace = urlpattern.namespace + app_name = urlpattern.app_name + patterns = apply_suffix_patterns(urlpattern.url_patterns, + suffix_pattern, + suffix_required) + ret.append(url(regex, include(patterns, namespace, app_name))) + return ret def format_suffix_patterns(urlpatterns, suffix_required=False, allowed=None): @@ -28,15 +57,4 @@ def format_suffix_patterns(urlpatterns, suffix_required=False, allowed=None): else: suffix_pattern = r'\.(?P<%s>[a-z]+)$' % suffix_kwarg - ret = [] - for urlpattern in urlpatterns: - # Form our complementing '.format' urlpattern - regex = urlpattern.regex.pattern.rstrip('$') + suffix_pattern - view = urlpattern._callback or urlpattern._callback_str - kwargs = urlpattern.default_args - name = urlpattern.name - # Add in both the existing and the new urlpattern - if not suffix_required: - ret.append(urlpattern) - ret.append(url(regex, view, kwargs, name)) - return ret + return apply_suffix_patterns(urlpatterns, suffix_pattern, suffix_required) -- cgit v1.2.3 From 199fa766ff7b5c7606e8f835dcf2d1d979da38b1 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Sat, 19 Jan 2013 17:00:20 +0000 Subject: Update release notes --- docs/topics/release-notes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/topics/release-notes.md b/docs/topics/release-notes.md index bbe11fac..58471a79 100644 --- a/docs/topics/release-notes.md +++ b/docs/topics/release-notes.md @@ -19,6 +19,7 @@ Major version numbers (x.0.0) are reserved for project milestones. No major poi ### Master * Support json encoding of timedelta objects. +* `format_suffix_patterns()` now supports `include` style URL patterns. * Bugfix: Return proper validation errors when incorrect types supplied for relational fields. * Bugfix: Support nullable FKs with `SlugRelatedField`. -- cgit v1.2.3 From 69083c3668b363bd9cb85674255d260808bbeeff Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Sat, 19 Jan 2013 18:36:25 +0000 Subject: Drop print statement --- rest_framework/urlpatterns.py | 1 - 1 file changed, 1 deletion(-) diff --git a/rest_framework/urlpatterns.py b/rest_framework/urlpatterns.py index 0aaad334..162f2314 100644 --- a/rest_framework/urlpatterns.py +++ b/rest_framework/urlpatterns.py @@ -20,7 +20,6 @@ def apply_suffix_patterns(urlpatterns, suffix_pattern, suffix_required): ret.append(url(regex, view, kwargs, name)) else: # Set of included URL patterns - print(type(urlpattern)) regex = urlpattern.regex.pattern namespace = urlpattern.namespace app_name = urlpattern.app_name -- cgit v1.2.3 From 771821af7d8eb6751d6ea37eabae7108cebc0df0 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Sat, 19 Jan 2013 18:39:39 +0000 Subject: Include kwargs in included URLs --- rest_framework/urlpatterns.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rest_framework/urlpatterns.py b/rest_framework/urlpatterns.py index 162f2314..0f210e66 100644 --- a/rest_framework/urlpatterns.py +++ b/rest_framework/urlpatterns.py @@ -23,10 +23,11 @@ def apply_suffix_patterns(urlpatterns, suffix_pattern, suffix_required): regex = urlpattern.regex.pattern namespace = urlpattern.namespace app_name = urlpattern.app_name + kwargs = urlpattern.default_kwargs patterns = apply_suffix_patterns(urlpattern.url_patterns, suffix_pattern, suffix_required) - ret.append(url(regex, include(patterns, namespace, app_name))) + ret.append(url(regex, include(patterns, namespace, app_name), kwargs)) return ret -- cgit v1.2.3 From 9b9b6529bcf3c3f39abf398597684962e5710e57 Mon Sep 17 00:00:00 2001 From: Bruno Renié Date: Sun, 20 Jan 2013 14:49:07 +0100 Subject: Fixed reference to authtoken in the docs --- docs/api-guide/authentication.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api-guide/authentication.md b/docs/api-guide/authentication.md index c0f9c072..1b56cf44 100644 --- a/docs/api-guide/authentication.md +++ b/docs/api-guide/authentication.md @@ -102,7 +102,7 @@ For clients to authenticate, the token key should be included in the `Authorizat If successfully authenticated, `TokenAuthentication` provides the following credentials. * `request.user` will be a Django `User` instance. -* `request.auth` will be a `rest_framework.tokenauth.models.BasicToken` instance. +* `request.auth` will be a `rest_framework.authtoken.models.BasicToken` instance. **Note:** If you use `TokenAuthentication` in production you must ensure that your API is only available over `https` only. -- cgit v1.2.3 From 42fcc3599c7d6aff2b50e534af4a5efbe3ce8c47 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Sun, 20 Jan 2013 15:50:16 +0000 Subject: Added @brutasse for docs fix #600. Thanks! --- docs/topics/credits.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/topics/credits.md b/docs/topics/credits.md index 6529813f..49050196 100644 --- a/docs/topics/credits.md +++ b/docs/topics/credits.md @@ -93,6 +93,7 @@ The following people have helped make REST framework great. * James Cleveland - [radiosilence] * Steve Gregory - [steve-gregory] * Federico Capoano - [nemesisdesign] +* Bruno Renié - [brutasse] Many thanks to everyone who's contributed to the project. @@ -221,3 +222,4 @@ You can also contact [@_tomchristie][twitter] directly on twitter. [radiosilence]: https://github.com/radiosilence [steve-gregory]: https://github.com/steve-gregory [nemesisdesign]: https://github.com/nemesisdesign +[brutasse]: https://github.com/brutasse -- cgit v1.2.3 From 2c76212e5454efa4d4d02c7051055c7957497d52 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Sun, 20 Jan 2013 16:38:32 +0000 Subject: Add missing import to tutorial. Fixes #599 --- docs/tutorial/4-authentication-and-permissions.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/tutorial/4-authentication-and-permissions.md b/docs/tutorial/4-authentication-and-permissions.md index f6daebb7..35aca8c6 100644 --- a/docs/tutorial/4-authentication-and-permissions.md +++ b/docs/tutorial/4-authentication-and-permissions.md @@ -54,6 +54,8 @@ You might also want to create a few different users, to use for testing the API. Now that we've got some users to work with, we'd better add representations of those users to our API. Creating a new serializer is easy: + from django.contrib.auth.models import User + class UserSerializer(serializers.ModelSerializer): snippets = serializers.ManyPrimaryKeyRelatedField() @@ -188,4 +190,4 @@ We've now got a fairly fine-grained set of permissions on our Web API, and end p In [part 5][tut-5] of the tutorial we'll look at how we can tie everything together by creating an HTML endpoint for our hightlighted snippets, and improve the cohesion of our API by using hyperlinking for the relationships within the system. -[tut-5]: 5-relationships-and-hyperlinked-apis.md \ No newline at end of file +[tut-5]: 5-relationships-and-hyperlinked-apis.md -- cgit v1.2.3 From 71bd2faa792569c9f4c83a06904b927616bfdbf1 Mon Sep 17 00:00:00 2001 From: Kevin Stone Date: Sun, 20 Jan 2013 12:59:27 -0800 Subject: Added test case for format_suffix_patterns to validate changes introduced with issue #593. Signed-off-by: Kevin Stone --- rest_framework/tests/urlpatterns.py | 75 +++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 rest_framework/tests/urlpatterns.py diff --git a/rest_framework/tests/urlpatterns.py b/rest_framework/tests/urlpatterns.py new file mode 100644 index 00000000..e96e7cf3 --- /dev/null +++ b/rest_framework/tests/urlpatterns.py @@ -0,0 +1,75 @@ +from collections import namedtuple + +from django.core import urlresolvers + +from django.test import TestCase +from django.test.client import RequestFactory + +from rest_framework.compat import patterns, url, include +from rest_framework.urlpatterns import format_suffix_patterns + + +# A container class for test paths for the test case +URLTestPath = namedtuple('URLTestPath', ['path', 'args', 'kwargs']) + + +def test_view(request, *args, **kwargs): + pass + + +class FormatSuffixTests(TestCase): + def _test_urlpatterns(self, urlpatterns, test_paths): + factory = RequestFactory() + try: + urlpatterns = format_suffix_patterns(urlpatterns) + except: + self.fail("Failed to apply `format_suffix_patterns` on the supplied urlpatterns") + resolver = urlresolvers.RegexURLResolver(r'^/', urlpatterns) + for test_path in test_paths: + request = factory.get(test_path.path) + try: + callback, callback_args, callback_kwargs = resolver.resolve(request.path_info) + except: + self.fail("Failed to resolve URL: %s" % request.path_info) + self.assertEquals(callback_args, test_path.args) + self.assertEquals(callback_kwargs, test_path.kwargs) + + def test_format_suffix(self): + urlpatterns = patterns( + '', + url(r'^test$', test_view), + ) + test_paths = [ + URLTestPath('/test', (), {}), + URLTestPath('/test.api', (), {'format': 'api'}), + URLTestPath('/test.asdf', (), {'format': 'asdf'}), + ] + self._test_urlpatterns(urlpatterns, test_paths) + + def test_default_args(self): + urlpatterns = patterns( + '', + url(r'^test$', test_view, {'foo': 'bar'}), + ) + test_paths = [ + URLTestPath('/test', (), {'foo': 'bar', }), + URLTestPath('/test.api', (), {'foo': 'bar', 'format': 'api'}), + URLTestPath('/test.asdf', (), {'foo': 'bar', 'format': 'asdf'}), + ] + self._test_urlpatterns(urlpatterns, test_paths) + + def test_included_urls(self): + nested_patterns = patterns( + '', + url(r'^path$', test_view) + ) + urlpatterns = patterns( + '', + url(r'^test/', include(nested_patterns), {'foo': 'bar'}), + ) + test_paths = [ + URLTestPath('/test/path', (), {'foo': 'bar', }), + URLTestPath('/test/path.api', (), {'foo': 'bar', 'format': 'api'}), + URLTestPath('/test/path.asdf', (), {'foo': 'bar', 'format': 'asdf'}), + ] + self._test_urlpatterns(urlpatterns, test_paths) -- cgit v1.2.3 From dc1c57d595c3917e3fed9076894d5fa88ec083c9 Mon Sep 17 00:00:00 2001 From: Stephan Groß Date: Mon, 21 Jan 2013 12:45:30 +0100 Subject: Add failed testcase for fieldvalidation --- rest_framework/tests/serializer.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/rest_framework/tests/serializer.py b/rest_framework/tests/serializer.py index bd96ba23..0ba4e765 100644 --- a/rest_framework/tests/serializer.py +++ b/rest_framework/tests/serializer.py @@ -241,6 +241,14 @@ class ValidationTests(TestCase): self.assertFalse(serializer.is_valid()) self.assertEquals(serializer.errors, {'content': [u'Test not in value']}) + incomplete_data = { + 'email': 'tom@example.com', + 'created': datetime.datetime(2012, 1, 1) + } + serializer = CommentSerializerWithFieldValidator(data=incomplete_data) + self.assertFalse(serializer.is_valid()) + self.assertEquals(serializer.errors, {'content': [u'This field is required.']}) + def test_bad_type_data_is_false(self): """ Data of the wrong type is not valid. -- cgit v1.2.3 From 2250ab6418d3cf99719ea7c5e3b3a861afa850bd Mon Sep 17 00:00:00 2001 From: Stephan Groß Date: Mon, 21 Jan 2013 12:50:39 +0100 Subject: Add possible solution for field validation error --- rest_framework/serializers.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index 27458f96..0c60d17f 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -227,13 +227,14 @@ class BaseSerializer(Field): Run `validate_()` and `validate()` methods on the serializer """ for field_name, field in self.fields.items(): - try: - validate_method = getattr(self, 'validate_%s' % field_name, None) - if validate_method: - source = field.source or field_name - attrs = validate_method(attrs, source) - except ValidationError as err: - self._errors[field_name] = self._errors.get(field_name, []) + list(err.messages) + if field_name not in self._errors: + try: + validate_method = getattr(self, 'validate_%s' % field_name, None) + if validate_method: + source = field.source or field_name + attrs = validate_method(attrs, source) + except ValidationError as err: + self._errors[field_name] = self._errors.get(field_name, []) + list(err.messages) # If there are already errors, we don't run .validate() because # field-validation failed and thus `attrs` may not be complete. -- cgit v1.2.3 From e7916ae0b1c4af35c55dc21e0d882f3f8ff3121e Mon Sep 17 00:00:00 2001 From: Kevin Stone Date: Mon, 21 Jan 2013 09:37:50 -0800 Subject: Tweaked some method names to be more clear and added a docstring to the test case class. Signed-off-by: Kevin Stone --- rest_framework/tests/urlpatterns.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/rest_framework/tests/urlpatterns.py b/rest_framework/tests/urlpatterns.py index e96e7cf3..43e8ef69 100644 --- a/rest_framework/tests/urlpatterns.py +++ b/rest_framework/tests/urlpatterns.py @@ -13,12 +13,15 @@ from rest_framework.urlpatterns import format_suffix_patterns URLTestPath = namedtuple('URLTestPath', ['path', 'args', 'kwargs']) -def test_view(request, *args, **kwargs): +def dummy_view(request, *args, **kwargs): pass class FormatSuffixTests(TestCase): - def _test_urlpatterns(self, urlpatterns, test_paths): + """ + Tests `format_suffix_patterns` against different URLPatterns to ensure the URLs still resolve properly, including any captured parameters. + """ + def _resolve_urlpatterns(self, urlpatterns, test_paths): factory = RequestFactory() try: urlpatterns = format_suffix_patterns(urlpatterns) @@ -37,31 +40,31 @@ class FormatSuffixTests(TestCase): def test_format_suffix(self): urlpatterns = patterns( '', - url(r'^test$', test_view), + url(r'^test$', dummy_view), ) test_paths = [ URLTestPath('/test', (), {}), URLTestPath('/test.api', (), {'format': 'api'}), URLTestPath('/test.asdf', (), {'format': 'asdf'}), ] - self._test_urlpatterns(urlpatterns, test_paths) + self._resolve_urlpatterns(urlpatterns, test_paths) def test_default_args(self): urlpatterns = patterns( '', - url(r'^test$', test_view, {'foo': 'bar'}), + url(r'^test$', dummy_view, {'foo': 'bar'}), ) test_paths = [ URLTestPath('/test', (), {'foo': 'bar', }), URLTestPath('/test.api', (), {'foo': 'bar', 'format': 'api'}), URLTestPath('/test.asdf', (), {'foo': 'bar', 'format': 'asdf'}), ] - self._test_urlpatterns(urlpatterns, test_paths) + self._resolve_urlpatterns(urlpatterns, test_paths) def test_included_urls(self): nested_patterns = patterns( '', - url(r'^path$', test_view) + url(r'^path$', dummy_view) ) urlpatterns = patterns( '', @@ -72,4 +75,4 @@ class FormatSuffixTests(TestCase): URLTestPath('/test/path.api', (), {'foo': 'bar', 'format': 'api'}), URLTestPath('/test/path.asdf', (), {'foo': 'bar', 'format': 'asdf'}), ] - self._test_urlpatterns(urlpatterns, test_paths) + self._resolve_urlpatterns(urlpatterns, test_paths) -- cgit v1.2.3 From 98bffa68e655e530c16e4622658541940b3891f0 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 21 Jan 2013 17:42:33 +0000 Subject: Don't do an inverted if test. --- rest_framework/urlpatterns.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/rest_framework/urlpatterns.py b/rest_framework/urlpatterns.py index 0f210e66..47789026 100644 --- a/rest_framework/urlpatterns.py +++ b/rest_framework/urlpatterns.py @@ -6,28 +6,29 @@ from django.core.urlresolvers import RegexURLResolver def apply_suffix_patterns(urlpatterns, suffix_pattern, suffix_required): ret = [] for urlpattern in urlpatterns: - if not isinstance(urlpattern, RegexURLResolver): - # Regular URL pattern - - # Form our complementing '.format' urlpattern - regex = urlpattern.regex.pattern.rstrip('$') + suffix_pattern - view = urlpattern._callback or urlpattern._callback_str - kwargs = urlpattern.default_args - name = urlpattern.name - # Add in both the existing and the new urlpattern - if not suffix_required: - ret.append(urlpattern) - ret.append(url(regex, view, kwargs, name)) - else: + if isinstance(urlpattern, RegexURLResolver): # Set of included URL patterns regex = urlpattern.regex.pattern namespace = urlpattern.namespace app_name = urlpattern.app_name kwargs = urlpattern.default_kwargs + # Add in the included patterns, after applying the suffixes patterns = apply_suffix_patterns(urlpattern.url_patterns, suffix_pattern, suffix_required) ret.append(url(regex, include(patterns, namespace, app_name), kwargs)) + + else: + # Regular URL pattern + regex = urlpattern.regex.pattern.rstrip('$') + suffix_pattern + view = urlpattern._callback or urlpattern._callback_str + kwargs = urlpattern.default_args + name = urlpattern.name + # Add in both the existing and the new urlpattern + if not suffix_required: + ret.append(urlpattern) + ret.append(url(regex, view, kwargs, name)) + return ret -- cgit v1.2.3 From e29ba356f054222893655901923811bd9675d4cc Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 21 Jan 2013 17:53:27 +0000 Subject: Added @kevinastone, for work on extra test cases in #602. Thanks! --- docs/topics/credits.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/topics/credits.md b/docs/topics/credits.md index 49050196..b033ecba 100644 --- a/docs/topics/credits.md +++ b/docs/topics/credits.md @@ -94,6 +94,7 @@ The following people have helped make REST framework great. * Steve Gregory - [steve-gregory] * Federico Capoano - [nemesisdesign] * Bruno Renié - [brutasse] +* Kevin Stone - [kevinastone] Many thanks to everyone who's contributed to the project. @@ -223,3 +224,4 @@ You can also contact [@_tomchristie][twitter] directly on twitter. [steve-gregory]: https://github.com/steve-gregory [nemesisdesign]: https://github.com/nemesisdesign [brutasse]: https://github.com/brutasse +[kevinastone]: https://github.com/kevinastone -- cgit v1.2.3 From 65b62d64ec54b528b62a1500b8f6ffe216d45c09 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 21 Jan 2013 21:29:49 +0000 Subject: WWW-Authenticate responses --- docs/api-guide/authentication.md | 12 +++++----- rest_framework/authentication.py | 4 ++-- rest_framework/tests/authentication.py | 41 +++++++++++++++++----------------- rest_framework/views.py | 21 ++++++++++++++++- 4 files changed, 49 insertions(+), 29 deletions(-) diff --git a/docs/api-guide/authentication.md b/docs/api-guide/authentication.md index 4dfcb0f1..59dc4a30 100644 --- a/docs/api-guide/authentication.md +++ b/docs/api-guide/authentication.md @@ -18,7 +18,9 @@ The `request.auth` property is used for any additional authentication informatio --- -**Note:** Don't forget that **authentication by itself won't allow or disallow an incoming request**, it simply identifies the credentials that the request was made with. For information on how to setup the permission polices for your API please see the [permissions documentation][permission]. +**Note:** Don't forget that **authentication by itself won't allow or disallow an incoming request**, it simply identifies the credentials that the request was made with. + +For information on how to setup the permission polices for your API please see the [permissions documentation][permission]. --- @@ -73,11 +75,11 @@ When an unauthenticated request is denied permission there are two different err * [HTTP 401 Unauthorized][http401] * [HTTP 403 Permission Denied][http403] -The kind of response that will be used depends on the type of authentication scheme in use, and the ordering of the authentication classes. +HTTP 401 responses must always include a `WWW-Authenticate` header, that instructs the client how to authenticate. HTTP 403 responses do not include the `WWW-Authenticate` header. -Although multiple authentication schemes may be in use, only one scheme may be used to determine the type of response. **The first authentication class set on the view is given priority when determining the type of response**. +The kind of response that will be used depends on the authentication scheme. Although multiple authentication schemes may be in use, only one scheme may be used to determine the type of response. **The first authentication class set on the view is used when determining the type of response**. -Note that when a *successfully authenticated* request is denied permission, a `403 Permission Denied` response will always be used, regardless of the authentication scheme. +Note that when a request may successfully authenticate, but still be denied permission to perform the request, in which case a `403 Permission Denied` response will always be used, regardless of the authentication scheme. --- @@ -126,8 +128,6 @@ Unauthenticated responses that are denied permission will result in an `HTTP 401 **Note:** If you use `TokenAuthentication` in production you must ensure that your API is only available over `https` only. -<<<<<<< HEAD - +The tutorial is fairly in-depth, so you should probably get a cookie and a cup of your favorite brew before getting started. If you just want a quick overview, you should head over to the [quickstart] documentation instead. --- -- cgit v1.2.3 From 896477f6509fb56ec0a946560748885f6ca6fe8d Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 28 Jan 2013 07:54:03 +0000 Subject: Added @mktums for docs fix in #621. Thanks! --- docs/topics/credits.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/topics/credits.md b/docs/topics/credits.md index 7cffbede..19a6397c 100644 --- a/docs/topics/credits.md +++ b/docs/topics/credits.md @@ -96,6 +96,7 @@ The following people have helped make REST framework great. * Bruno Renié - [brutasse] * Kevin Stone - [kevinastone] * Guglielmo Celata - [guglielmo] +* Mike Tums - [mktums] Many thanks to everyone who's contributed to the project. @@ -227,3 +228,4 @@ You can also contact [@_tomchristie][twitter] directly on twitter. [brutasse]: https://github.com/brutasse [kevinastone]: https://github.com/kevinastone [guglielmo]: https://github.com/guglielmo +[mktums]: https://github.com/mktums -- cgit v1.2.3 From e682bfa54efc391df4d6bb7cf78a2089213b8d6b Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 28 Jan 2013 08:01:54 +0000 Subject: Drop unneccessary `source=` argument. --- docs/api-guide/relations.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api-guide/relations.md b/docs/api-guide/relations.md index 351b5e09..9f5a04b2 100644 --- a/docs/api-guide/relations.md +++ b/docs/api-guide/relations.md @@ -67,7 +67,7 @@ For example, given the following models: And a model serializer defined like this: class BookmarkSerializer(serializers.ModelSerializer): - tags = serializers.ManyRelatedField(source='tags') + tags = serializers.ManyRelatedField() class Meta: model = Bookmark -- cgit v1.2.3 From 3bcd38b7d0ddaa2c051ad230cb0d749f9737fd82 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 28 Jan 2013 09:10:23 +0000 Subject: Notes on upgrading and versioning. Fixes #620. --- docs/topics/release-notes.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/topics/release-notes.md b/docs/topics/release-notes.md index 0c3ebca0..84b30d85 100644 --- a/docs/topics/release-notes.md +++ b/docs/topics/release-notes.md @@ -12,6 +12,16 @@ Medium version numbers (0.x.0) may include minor API changes. You should read t Major version numbers (x.0.0) are reserved for project milestones. No major point releases are currently planned. +## Upgrading + +To upgrade Django REST framework to the latest version, use pip: + + pip install -U djangorestframework + +You can determine your currently installed version using `pip freeze`: + + pip freeze | grep djangorestframework + --- ## 2.1.x series -- cgit v1.2.3