From 9ea615af14303663a47fd155ab541d8302d609e4 Mon Sep 17 00:00:00 2001 From: donewell Date: Tue, 10 Feb 2015 17:41:03 +0000 Subject: [PATCH 001/294] add message to custom permission change detail to message and update text --- docs/api-guide/permissions.md | 10 ++++ rest_framework/views.py | 8 +++- tests/test_permissions.py | 86 +++++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 1 deletion(-) diff --git a/docs/api-guide/permissions.md b/docs/api-guide/permissions.md index 743ca435c..e299b4180 100644 --- a/docs/api-guide/permissions.md +++ b/docs/api-guide/permissions.md @@ -205,6 +205,16 @@ If you need to test if a request is a read operation or a write operation, you s --- +Custom permissions will raise a `PermissionDenied` exception if the test fails. To change the error message associated with the exception, implement a `message` attribute directly on your custom permission. Otherwise the `default_detail` attribute from `PermissionDenied` will be used. + + from rest_framework import permissions + + class CustomerAccessPermission(permissions.BasePermission): + message = 'Adding customers not allowed.' + + def has_permission(self, request, view): + ... + ## Examples The following is an example of a permission class that checks the incoming request's IP address against a blacklist, and denies the request if the IP has been blacklisted. diff --git a/rest_framework/views.py b/rest_framework/views.py index bc870417f..4fa97124f 100644 --- a/rest_framework/views.py +++ b/rest_framework/views.py @@ -134,12 +134,14 @@ class APIView(View): """ raise exceptions.MethodNotAllowed(request.method) - def permission_denied(self, request): + def permission_denied(self, request, message=None): """ If request is not permitted, determine what kind of exception to raise. """ if not request.successful_authenticator: raise exceptions.NotAuthenticated() + if message is not None: + raise exceptions.PermissionDenied(message) raise exceptions.PermissionDenied() def throttled(self, request, wait): @@ -280,6 +282,8 @@ class APIView(View): """ for permission in self.get_permissions(): if not permission.has_permission(request, self): + if hasattr(permission, 'message'): + self.permission_denied(request, permission.message) self.permission_denied(request) def check_object_permissions(self, request, obj): @@ -289,6 +293,8 @@ class APIView(View): """ for permission in self.get_permissions(): if not permission.has_object_permission(request, self, obj): + if hasattr(permission, 'message'): + self.permission_denied(request, permission.message) self.permission_denied(request) def check_throttles(self, request): diff --git a/tests/test_permissions.py b/tests/test_permissions.py index 97bac33db..68d34785e 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -310,3 +310,89 @@ class ObjectPermissionsIntegrationTests(TestCase): response = object_permissions_list_view(request) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertListEqual(response.data, []) + + +class BasicPerm(permissions.BasePermission): + def has_permission(self, request, view): + return False + + +class BasicPermWithDetail(permissions.BasePermission): + message = 'Custom: You cannot post to this resource' + + def has_permission(self, request, view): + return False + + +class BasicObjectPerm(permissions.BasePermission): + def has_object_permission(self, request, view, obj): + return False + + +class BasicObjectPermWithDetail(permissions.BasePermission): + message = 'Custom: You cannot post to this resource' + + def has_object_permission(self, request, view, obj): + return False + + +class PermissionInstanceView(generics.RetrieveUpdateDestroyAPIView): + queryset = BasicModel.objects.all() + serializer_class = BasicSerializer + + +class DeniedView(PermissionInstanceView): + permission_classes = (BasicPerm,) + + +class DeniedViewWithDetail(PermissionInstanceView): + permission_classes = (BasicPermWithDetail,) + + +class DeniedObjectView(PermissionInstanceView): + permission_classes = (BasicObjectPerm,) + + +class DeniedObjectViewWithDetail(PermissionInstanceView): + permission_classes = (BasicObjectPermWithDetail,) + +denied_view = DeniedView.as_view() + +denied_view_with_detail = DeniedViewWithDetail.as_view() + +denied_object_view = DeniedObjectView.as_view() + +denied_object_view_with_detail = DeniedObjectViewWithDetail.as_view() + + +class CustomPermissionsTests(TestCase): + def setUp(self): + BasicModel(text='foo').save() + User.objects.create_user('username', 'username@example.com', 'password') + credentials = basic_auth_header('username', 'password') + self.request = factory.get('/1', format='json', HTTP_AUTHORIZATION=credentials) + self.custom_message = 'Custom: You cannot post to this resource' + + def test_permission_denied(self): + response = denied_view(self.request, pk=1) + detail = response.data.get('detail') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertNotEqual(detail, self.custom_message) + + def test_permission_denied_with_custom_detail(self): + response = denied_view_with_detail(self.request, pk=1) + detail = response.data.get('detail') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(detail, self.custom_message) + + def test_permission_denied_for_object(self): + response = denied_object_view(self.request, pk=1) + detail = response.data.get('detail') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertNotEqual(detail, self.custom_message) + + def test_permission_denied_for_object_with_custom_detail(self): + response = denied_object_view_with_detail(self.request, pk=1) + detail = response.data.get('detail') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(detail, self.custom_message) From 9a9a00bff2f61d08573f764012e389e7f8e5c6ae Mon Sep 17 00:00:00 2001 From: donewell Date: Wed, 11 Feb 2015 11:15:01 +0000 Subject: [PATCH 002/294] simplify argument handling --- rest_framework/views.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/rest_framework/views.py b/rest_framework/views.py index 4fa97124f..435796c4e 100644 --- a/rest_framework/views.py +++ b/rest_framework/views.py @@ -140,9 +140,7 @@ class APIView(View): """ if not request.successful_authenticator: raise exceptions.NotAuthenticated() - if message is not None: - raise exceptions.PermissionDenied(message) - raise exceptions.PermissionDenied() + raise exceptions.PermissionDenied(detail=message) def throttled(self, request, wait): """ @@ -282,9 +280,9 @@ class APIView(View): """ for permission in self.get_permissions(): if not permission.has_permission(request, self): - if hasattr(permission, 'message'): - self.permission_denied(request, permission.message) - self.permission_denied(request) + self.permission_denied( + request, message=getattr(permission, 'message', None) + ) def check_object_permissions(self, request, obj): """ @@ -293,9 +291,9 @@ class APIView(View): """ for permission in self.get_permissions(): if not permission.has_object_permission(request, self, obj): - if hasattr(permission, 'message'): - self.permission_denied(request, permission.message) - self.permission_denied(request) + self.permission_denied( + request, message=getattr(permission, 'message', None) + ) def check_throttles(self, request): """ From 3d25dadbf36930439f6cb5bfa81166c0ddcf0b38 Mon Sep 17 00:00:00 2001 From: donewell Date: Wed, 11 Feb 2015 11:20:03 +0000 Subject: [PATCH 003/294] change custom message for clarity --- tests/test_permissions.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_permissions.py b/tests/test_permissions.py index 68d34785e..2c0caa487 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -318,7 +318,7 @@ class BasicPerm(permissions.BasePermission): class BasicPermWithDetail(permissions.BasePermission): - message = 'Custom: You cannot post to this resource' + message = 'Custom: You cannot access this resource' def has_permission(self, request, view): return False @@ -330,7 +330,7 @@ class BasicObjectPerm(permissions.BasePermission): class BasicObjectPermWithDetail(permissions.BasePermission): - message = 'Custom: You cannot post to this resource' + message = 'Custom: You cannot access this resource' def has_object_permission(self, request, view, obj): return False @@ -371,7 +371,7 @@ class CustomPermissionsTests(TestCase): User.objects.create_user('username', 'username@example.com', 'password') credentials = basic_auth_header('username', 'password') self.request = factory.get('/1', format='json', HTTP_AUTHORIZATION=credentials) - self.custom_message = 'Custom: You cannot post to this resource' + self.custom_message = 'Custom: You cannot access this resource' def test_permission_denied(self): response = denied_view(self.request, pk=1) From 4d5eee04a0a4e126d19f31ffc7325685331c0f71 Mon Sep 17 00:00:00 2001 From: Aider Ibragimov Date: Sat, 28 Feb 2015 10:11:38 +0300 Subject: [PATCH 004/294] add IPAddressField, update docs --- docs/api-guide/fields.md | 13 ++++++++++++- rest_framework/fields.py | 31 ++++++++++++++++++++++++++++++- tests/test_fields.py | 21 +++++++++++++++++++++ 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/docs/api-guide/fields.md b/docs/api-guide/fields.md index 4d7d9eee8..9c60b3d35 100644 --- a/docs/api-guide/fields.md +++ b/docs/api-guide/fields.md @@ -188,6 +188,17 @@ A field that ensures the input is a valid UUID string. The `to_internal_value` m "de305d54-75b4-431b-adb2-eb6b9e546013" +## IPAddressField + +A field that ensures the input is a valid IPv4 or IPv6 string. + +Corresponds to `django.forms.fields.IPAddressField` and `django.forms.fields.GenericIPAddressField`. + +**Signature**: `IPAddressField(protocol='both', unpack_ipv4=False, **options)` + +- `protocol` Limits valid inputs to the specified protocol. Accepted values are 'both' (default), 'IPv4' or 'IPv6'. Matching is case insensitive. +- `unpack_ipv4` Unpacks IPv4 mapped addresses like ::ffff:192.0.2.1. If this option is enabled that address would be unpacked to 192.0.2.1. Default is disabled. Can only be used when protocol is set to 'both'. + --- # Numeric fields @@ -524,7 +535,7 @@ As an example, let's create a field that can be used represent the class name of # We pass the object instance onto `to_representation`, # not just the field attribute. return obj - + def to_representation(self, obj): """ Serialize the object's class name. diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 71a9f1938..860fd3070 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -2,12 +2,13 @@ from __future__ import unicode_literals from django.conf import settings from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ValidationError as DjangoValidationError -from django.core.validators import RegexValidator +from django.core.validators import RegexValidator, ip_address_validators from django.forms import ImageField as DjangoImageField from django.utils import six, timezone from django.utils.dateparse import parse_date, parse_datetime, parse_time from django.utils.encoding import is_protected_type, smart_text from django.utils.translation import ugettext_lazy as _ +from django.utils.ipv6 import clean_ipv6_address from rest_framework import ISO_8601 from rest_framework.compat import ( EmailValidator, MinValueValidator, MaxValueValidator, @@ -650,6 +651,34 @@ class UUIDField(Field): return str(value) +class IPAddressField(CharField): + """Support both IPAddressField and GenericIPAddressField""" + + default_error_messages = { + 'invalid': _('Enter a valid IPv4 or IPv6 address.'), + } + + def __init__(self, protocol='both', unpack_ipv4=False, **kwargs): + self.protocol = protocol + self.unpack_ipv4 = unpack_ipv4 + super(IPAddressField, self).__init__(**kwargs) + validators, error_message = ip_address_validators(protocol, unpack_ipv4) + self.validators.extend(validators) + + def to_internal_value(self, data): + if data == '' and self.allow_blank: + return '' + data = data.strip() + + if data and ':' in data: + try: + return clean_ipv6_address(data, self.unpack_ipv4) + except DjangoValidationError: + self.fail('invalid', value=data) + + return data + + # Number types... class IntegerField(Field): diff --git a/tests/test_fields.py b/tests/test_fields.py index 6744cf645..f0451d5ea 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -485,6 +485,27 @@ class TestUUIDField(FieldValues): field = serializers.UUIDField() +class TestIPAddressField(FieldValues): + """ + Valid and invalid values for `IPAddressField` + """ + valid_inputs = { + '127.0.0.1': '127.0.0.1', + '192.168.33.255': '192.168.33.255', + '2001:0db8:85a3:0042:1000:8a2e:0370:7334': '2001:db8:85a3:42:1000:8a2e:370:7334', + '2001:cdba:0:0:0:0:3257:9652': '2001:cdba::3257:9652', + '2001:cdba::3257:9652': '2001:cdba::3257:9652' + } + invalid_inputs = { + '127001': ['Enter a valid IPv4 or IPv6 address.'], + '127.122.111.2231': ['Enter a valid IPv4 or IPv6 address.'], + '2001:::9652': ['Enter a valid IPv4 or IPv6 address.'], + '2001:0db8:85a3:0042:1000:8a2e:0370:73341': ['Enter a valid IPv4 or IPv6 address.'], + } + outputs = {} + field = serializers.IPAddressField() + + # Number types... class TestIntegerField(FieldValues): From 313b3d7c3b8dfdb159e3570c3baade827bd6d687 Mon Sep 17 00:00:00 2001 From: Aider Ibragimov Date: Sat, 28 Feb 2015 10:18:47 +0300 Subject: [PATCH 005/294] Update ModelSerializer mappings --- rest_framework/serializers.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index d76658b03..aea457905 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -725,7 +725,8 @@ class ModelSerializer(Serializer): models.SmallIntegerField: IntegerField, models.TextField: CharField, models.TimeField: TimeField, - models.URLField: URLField + models.URLField: URLField, + models.GenericIPAddressField: IPAddressField, # Note: Some version-specific mappings also defined below. }) _related_class = PrimaryKeyRelatedField @@ -1137,6 +1138,10 @@ class ModelSerializer(Serializer): if hasattr(models, 'UUIDField'): ModelSerializer._field_mapping[models.UUIDField] = UUIDField +# IPAddressField is deprecated in Django +if hasattr(models, 'IPAddressField'): + ModelSerializer._field_mapping[models.IPAddressField] = IPAddressField + if postgres_fields: class CharMappingField(DictField): child = CharField() From 391b0ae21b29212764c4f3d079187d07228bb743 Mon Sep 17 00:00:00 2001 From: Tymur Maryokhin Date: Tue, 3 Mar 2015 17:02:12 +0100 Subject: [PATCH 006/294] Call default.set_context() only on create. Refs #2619. --- rest_framework/fields.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/rest_framework/fields.py b/rest_framework/fields.py index c327f11bc..a80862e8c 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -103,7 +103,7 @@ def set_value(dictionary, keys, value): dictionary[keys[-1]] = value -class CreateOnlyDefault: +class CreateOnlyDefault(object): """ This class may be used to provide default values that are only used for create operations, but that do not return any value for update @@ -114,7 +114,7 @@ class CreateOnlyDefault: def set_context(self, serializer_field): self.is_update = serializer_field.parent.instance is not None - if callable(self.default) and hasattr(self.default, 'set_context'): + if callable(self.default) and hasattr(self.default, 'set_context') and not self.is_update: self.default.set_context(serializer_field) def __call__(self): @@ -130,7 +130,7 @@ class CreateOnlyDefault: ) -class CurrentUserDefault: +class CurrentUserDefault(object): def set_context(self, serializer_field): self.user = serializer_field.context['request'].user From fdd811ec53b3bdc46a2c934422066e1aa9f9dd05 Mon Sep 17 00:00:00 2001 From: Aider Ibragimov Date: Wed, 4 Mar 2015 08:22:46 +0300 Subject: [PATCH 007/294] Allow blank/null on radio.html choices --- .../rest_framework/horizontal/radio.html | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/rest_framework/templates/rest_framework/horizontal/radio.html b/rest_framework/templates/rest_framework/horizontal/radio.html index 52238bb1a..efca2883e 100644 --- a/rest_framework/templates/rest_framework/horizontal/radio.html +++ b/rest_framework/templates/rest_framework/horizontal/radio.html @@ -1,20 +1,36 @@ +{% load i18n %} +
{% if field.label %} {% endif %}
{% if style.inline %} + {% if field.allow_null or field.allow_blank %} + + {% endif %} {% for key, text in field.choices.items %} {% endfor %} {% else %} + {% if field.allow_null or field.allow_blank %} +
+ +
+ {% endif %} {% for key, text in field.choices.items %}
From c44376c613333cce36e830eb846b4cdcecabaf6c Mon Sep 17 00:00:00 2001 From: Aider Ibragimov Date: Wed, 4 Mar 2015 14:17:58 +0300 Subject: [PATCH 008/294] remove unnecessary check --- rest_framework/fields.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/rest_framework/fields.py b/rest_framework/fields.py index d0fcd988b..d19231a2f 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -669,17 +669,13 @@ class IPAddressField(CharField): self.validators.extend(validators) def to_internal_value(self, data): - if data == '' and self.allow_blank: - return '' - data = data.strip() - if data and ':' in data: try: return clean_ipv6_address(data, self.unpack_ipv4) except DjangoValidationError: self.fail('invalid', value=data) - return data + return super(IPAddressField, self).to_internal_value(data) # Number types... From 18cc0230bff436da2f26b2b25034cece32c9f5d0 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 4 Mar 2015 15:51:00 +0000 Subject: [PATCH 009/294] Clean up pagination attributes --- docs/api-guide/generic-views.md | 7 +- .../5-relationships-and-hyperlinked-apis.md | 2 +- docs/tutorial/quickstart.md | 2 +- rest_framework/pagination.py | 73 ++++++++++++++----- rest_framework/settings.py | 11 ++- tests/test_pagination.py | 8 +- 6 files changed, 71 insertions(+), 32 deletions(-) diff --git a/docs/api-guide/generic-views.md b/docs/api-guide/generic-views.md index 61c8e8d88..39e09aaa5 100755 --- a/docs/api-guide/generic-views.md +++ b/docs/api-guide/generic-views.md @@ -84,10 +84,9 @@ The following attributes control the basic view behavior. The following attributes are used to control pagination when used with list views. -* `paginate_by` - The size of pages to use with paginated data. If set to `None` then pagination is turned off. If unset this uses the same value as the `PAGINATE_BY` setting, which defaults to `None`. -* `paginate_by_param` - The name of a query parameter, which can be used by the client to override the default page size to use for pagination. If unset this uses the same value as the `PAGINATE_BY_PARAM` setting, which defaults to `None`. -* `pagination_serializer_class` - The pagination serializer class to use when determining the style of paginated responses. Defaults to the same value as the `DEFAULT_PAGINATION_SERIALIZER_CLASS` setting. -* `page_kwarg` - The name of a URL kwarg or URL query parameter which can be used by the client to control which page is requested. Defaults to `'page'`. +* `pagination_class` - The pagination class that should be used when paginating list results. Defaults to the same value as the `DEFAULT_PAGINATION_CLASS` setting, which is `'rest_framework.pagination.PageNumberPagination'`. + +Note that usage of the `paginate_by`, `paginate_by_param` and `page_kwarg` attributes are now pending deprecation. The `pagination_serializer_class` attribute and `DEFAULT_PAGINATION_SERIALIZER_CLASS` setting have been removed completely. Pagination settings should instead be controlled by overriding a pagination class and setting any configuration attributes there. See the pagination documentation for more details. **Filtering**: diff --git a/docs/tutorial/5-relationships-and-hyperlinked-apis.md b/docs/tutorial/5-relationships-and-hyperlinked-apis.md index 740a4ce21..91cdd6f10 100644 --- a/docs/tutorial/5-relationships-and-hyperlinked-apis.md +++ b/docs/tutorial/5-relationships-and-hyperlinked-apis.md @@ -141,7 +141,7 @@ The list views for users and code snippets could end up returning quite a lot of We can change the default list style to use pagination, by modifying our `tutorial/settings.py` file slightly. Add the following setting: REST_FRAMEWORK = { - 'PAGINATE_BY': 10 + 'PAGE_SIZE': 10 } Note that settings in REST framework are all namespaced into a single dictionary setting, named 'REST_FRAMEWORK', which helps keep them well separated from your other project settings. diff --git a/docs/tutorial/quickstart.md b/docs/tutorial/quickstart.md index a4474c34e..fe0ecbc7e 100644 --- a/docs/tutorial/quickstart.md +++ b/docs/tutorial/quickstart.md @@ -123,7 +123,7 @@ We'd also like to set a few global settings. We'd like to turn on pagination, a REST_FRAMEWORK = { 'DEFAULT_PERMISSION_CLASSES': ('rest_framework.permissions.IsAdminUser',), - 'PAGINATE_BY': 10 + 'PAGE_SIZE': 10 } Okay, we're done. diff --git a/rest_framework/pagination.py b/rest_framework/pagination.py index 809858737..6a2f5b271 100644 --- a/rest_framework/pagination.py +++ b/rest_framework/pagination.py @@ -18,6 +18,7 @@ from rest_framework.settings import api_settings from rest_framework.utils.urls import ( replace_query_param, remove_query_param ) +import warnings def _positive_int(integer_string, strict=False, cutoff=None): @@ -203,18 +204,18 @@ class PageNumberPagination(BasePagination): """ # The default page size. # Defaults to `None`, meaning pagination is disabled. - paginate_by = api_settings.PAGINATE_BY + page_size = api_settings.PAGE_SIZE # Client can control the page using this query parameter. page_query_param = 'page' # Client can control the page size using this query parameter. # Default is 'None'. Set to eg 'page_size' to enable usage. - paginate_by_param = api_settings.PAGINATE_BY_PARAM + page_size_query_param = None # Set to an integer to limit the maximum page size the client may request. - # Only relevant if 'paginate_by_param' has also been set. - max_paginate_by = api_settings.MAX_PAGINATE_BY + # Only relevant if 'page_size_query_param' has also been set. + max_page_size = None last_page_strings = ('last',) @@ -228,12 +229,48 @@ class PageNumberPagination(BasePagination): attributes were set there. The attributes should now be set on the pagination class, but the old style is still pending deprecation. """ - for attr in ( - 'paginate_by', 'page_query_param', - 'paginate_by_param', 'max_paginate_by' + assert not ( + getattr(view, 'pagination_serializer_class', None) or + getattr(api_settings, 'DEFAULT_PAGINATION_SERIALIZER_CLASS', None) + ), ( + "The pagination_serializer_class attribute and " + "DEFAULT_PAGINATION_SERIALIZER_CLASS setting have been removed as " + "part of the 3.1 pagination API improvement. See the pagination " + "documentation for details on the new API." + ) + + for (settings_key, attr_name) in ( + ('PAGINATE_BY', 'page_size'), + ('PAGINATE_BY_PARAM', 'page_size_query_param'), + ('MAX_PAGINATE_BY', 'max_page_size') ): - if hasattr(view, attr): - setattr(self, attr, getattr(view, attr)) + value = getattr(api_settings, settings_key, None) + if value is not None: + setattr(self, attr_name, value) + warnings.warn( + "The `%s` settings key is pending deprecation. " + "Use the `%s` attribute on the pagination class instead." % ( + settings_key, attr_name + ), + PendingDeprecationWarning, + ) + + for (view_attr, attr_name) in ( + ('paginate_by', 'page_size'), + ('page_query_param', 'page_query_param'), + ('paginate_by_param', 'page_size_query_param'), + ('max_paginate_by', 'max_page_size') + ): + value = getattr(view, view_attr, None) + if value is not None: + setattr(self, attr_name, value) + warnings.warn( + "The `%s` view attribute is pending deprecation. " + "Use the `%s` attribute on the pagination class instead." % ( + view_attr, attr_name + ), + PendingDeprecationWarning, + ) def paginate_queryset(self, queryset, request, view=None): """ @@ -264,7 +301,7 @@ class PageNumberPagination(BasePagination): self.display_page_controls = True self.request = request - return self.page + return list(self.page) def get_paginated_response(self, data): return Response(OrderedDict([ @@ -275,17 +312,17 @@ class PageNumberPagination(BasePagination): ])) def get_page_size(self, request): - if self.paginate_by_param: + if self.page_size_query_param: try: return _positive_int( - request.query_params[self.paginate_by_param], + request.query_params[self.page_size_query_param], strict=True, - cutoff=self.max_paginate_by + cutoff=self.max_page_size ) except (KeyError, ValueError): pass - return self.paginate_by + return self.page_size def get_next_link(self): if not self.page.has_next(): @@ -336,7 +373,7 @@ class LimitOffsetPagination(BasePagination): http://api.example.org/accounts/?limit=100 http://api.example.org/accounts/?offset=400&limit=100 """ - default_limit = api_settings.PAGINATE_BY + default_limit = api_settings.PAGE_SIZE limit_query_param = 'limit' offset_query_param = 'offset' max_limit = None @@ -349,7 +386,7 @@ class LimitOffsetPagination(BasePagination): self.request = request if self.count > self.limit and self.template is not None: self.display_page_controls = True - return queryset[self.offset:self.offset + self.limit] + return list(queryset[self.offset:self.offset + self.limit]) def get_paginated_response(self, data): return Response(OrderedDict([ @@ -440,7 +477,7 @@ class CursorPagination(BasePagination): # Consider a max offset cap. # Tidy up the `get_ordering` API (eg remove queryset from it) cursor_query_param = 'cursor' - page_size = api_settings.PAGINATE_BY + page_size = api_settings.PAGE_SIZE invalid_cursor_message = _('Invalid cursor') ordering = None template = 'rest_framework/pagination/previous_and_next.html' @@ -484,7 +521,7 @@ class CursorPagination(BasePagination): # We also always fetch an extra item in order to determine if there is a # page following on from this one. results = list(queryset[offset:offset + self.page_size + 1]) - self.page = results[:self.page_size] + self.page = list(results[:self.page_size]) # Determine the position of the final item following the page. if len(results) > len(self.page): diff --git a/rest_framework/settings.py b/rest_framework/settings.py index 394b12622..a3e9f5902 100644 --- a/rest_framework/settings.py +++ b/rest_framework/settings.py @@ -61,9 +61,7 @@ DEFAULTS = { 'NUM_PROXIES': None, # Pagination - 'PAGINATE_BY': None, - 'PAGINATE_BY_PARAM': None, - 'MAX_PAGINATE_BY': None, + 'PAGE_SIZE': None, # Filtering 'SEARCH_PARAM': 'search', @@ -117,7 +115,12 @@ DEFAULTS = { 'UNICODE_JSON': True, 'COMPACT_JSON': True, 'COERCE_DECIMAL_TO_STRING': True, - 'UPLOADED_FILES_USE_URL': True + 'UPLOADED_FILES_USE_URL': True, + + # Pending deprecation: + 'PAGINATE_BY': None, + 'PAGINATE_BY_PARAM': None, + 'MAX_PAGINATE_BY': None } diff --git a/tests/test_pagination.py b/tests/test_pagination.py index 13bfb6272..6b39a6f22 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -24,9 +24,9 @@ class TestPaginationIntegration: return [item for item in queryset if item % 2 == 0] class BasicPagination(pagination.PageNumberPagination): - paginate_by = 5 - paginate_by_param = 'page_size' - max_paginate_by = 20 + page_size = 5 + page_size_query_param = 'page_size' + max_page_size = 20 self.view = generics.ListAPIView.as_view( serializer_class=PassThroughSerializer, @@ -185,7 +185,7 @@ class TestPageNumberPagination: def setup(self): class ExamplePagination(pagination.PageNumberPagination): - paginate_by = 5 + page_size = 5 self.pagination = ExamplePagination() self.queryset = range(1, 101) From efb42ff7d048d165b151e3b75553ef720dc49cd3 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 4 Mar 2015 16:17:30 +0000 Subject: [PATCH 010/294] Update docs --- docs/api-guide/pagination.md | 26 +++++++++++++++++++++++++- docs/topics/3.1-announcement.md | 9 +++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/docs/api-guide/pagination.md b/docs/api-guide/pagination.md index 697ba38d5..13bd57aef 100644 --- a/docs/api-guide/pagination.md +++ b/docs/api-guide/pagination.md @@ -208,6 +208,30 @@ Note that the `paginate_queryset` method may set state on the pagination instanc ## Example +Suppose we want to replace the default pagination output style with a modified format that includes the next and previous links under in a nested 'links' key. We could specify a custom pagination class like so: + + class CustomPagination(pagination.PageNumberPagination): + def get_paginated_response(self, data): + return Response({ + 'links': { + 'next': self.get_next_link(), + 'previous': self.get_previous_link() + }, + 'count': self.page.paginator.count, + 'results': data + }) + +We'd then need to setup the custom class in our configuration: + + REST_FRAMEWORK = { + 'DEFAULT_PAGINATION_CLASS': 'my_project.apps.core.pagination.CustomPagination', + 'PAGE_SIZE': 100 + } + +Note that if you care about how the ordering of keys is displayed in responses in the browsable API you might choose to use an `OrderedDict` when constructing the body of paginated responses, but this is optional. + +## Header based pagination + Let's modify the built-in `PageNumberPagination` style, so that instead of include the pagination links in the body of the response, we'll instead include a `Link` header, in a [similar style to the GitHub API][github-link-pagination]. class LinkHeaderPagination(pagination.PageNumberPagination): @@ -234,7 +258,7 @@ To have your custom pagination class be used by default, use the `DEFAULT_PAGINA REST_FRAMEWORK = { 'DEFAULT_PAGINATION_CLASS': 'my_project.apps.core.pagination.LinkHeaderPagination', - 'DEFAULT_PAGE_SIZE': 10 + 'PAGE_SIZE': 100 } API responses for list endpoints will now include a `Link` header, instead of including the pagination links as part of the body of the response, for example: diff --git a/docs/topics/3.1-announcement.md b/docs/topics/3.1-announcement.md index f500101c5..ecbc9a380 100644 --- a/docs/topics/3.1-announcement.md +++ b/docs/topics/3.1-announcement.md @@ -17,6 +17,15 @@ Some highlights include: The pagination API has been improved, making it both easier to use, and more powerful. +A guide to the headline features follows. For full details, see [the pagination documentation][pagination]. + +Note that as a result of this work a number of settings keys and generic view attributes are now moved to pending deprecation. Controlling pagination styles is now largely handled by overriding a pagination class and modifying its configuration attributes. + +* The `PAGINATE_BY` settings key will continue to work but is now pending deprecation. The more obviously named `PAGE_SIZE` settings key should now be used instead. +* The `PAGINATE_BY_PARAM`, `MAX_PAGINATE_BY` settings keys will continue to work but are now pending deprecation, in favor of setting configuration attributes on the configured pagination class. +* The `paginate_by`, `page_query_param`, `paginate_by_param` and `max_paginate_by` generic view attributes will continue to work but are now pending deprecation, in favor of setting configuration attributes on the configured pagination class. +* The `pagination_serializer_class` view attribute and `DEFAULT_PAGINATION_SERIALIZER_CLASS` settings key **are no longer valid**. The pagination API does not use serializers to determine the output format, and you'll need to instead override the `get_paginated_response` method on a pagination class in order to specify how the output format is controlled. + #### New pagination schemes. Until now, there has only been a single built-in pagination style in REST framework. We now have page, limit/offset and cursor based schemes included by default. From ce31e369734bd6db1a5a1a94bb1679e6bbbf34b3 Mon Sep 17 00:00:00 2001 From: Egor Yurtaev Date: Thu, 5 Mar 2015 18:34:42 +0600 Subject: [PATCH 011/294] Remove `MergeDict` The class MergeDict is deprecated and will be removed in Django 1.9 --- rest_framework/request.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/rest_framework/request.py b/rest_framework/request.py index fd4f6a3e2..e4b5bc263 100644 --- a/rest_framework/request.py +++ b/rest_framework/request.py @@ -14,7 +14,6 @@ from django.http import QueryDict from django.http.multipartparser import parse_header from django.utils import six from django.utils.datastructures import MultiValueDict -from django.utils.datastructures import MergeDict as DjangoMergeDict from rest_framework import HTTP_HEADER_ENCODING from rest_framework import exceptions from rest_framework.settings import api_settings @@ -61,15 +60,6 @@ class override_method(object): self.view.action = self.action -class MergeDict(DjangoMergeDict, dict): - """ - Using this as a workaround until the parsers API is properly - addressed in 3.1. - """ - def __init__(self, *dicts): - self.dicts = dicts - - class Empty(object): """ Placeholder for unset attributes. @@ -328,7 +318,8 @@ class Request(object): if not _hasattr(self, '_data'): self._data, self._files = self._parse() if self._files: - self._full_data = MergeDict(self._data, self._files) + self._full_data = self._data.copy() + self._full_data.update(self._files) else: self._full_data = self._data @@ -392,7 +383,8 @@ class Request(object): # At this point we're committed to parsing the request as form data. self._data = self._request.POST self._files = self._request.FILES - self._full_data = MergeDict(self._data, self._files) + self._full_data = self._data.copy() + self._full_data.update(self._files) # Method overloading - change the method and remove the param from the content. if ( From 58dfde7fcd9c29530d0613161dda0cf30c56a0a4 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 6 Mar 2015 10:22:32 +0000 Subject: [PATCH 012/294] Tweaks for cursor pagination and docs --- docs/api-guide/pagination.md | 12 +++++++++--- docs/topics/3.1-announcement.md | 1 + docs/topics/release-notes.md | 5 +++++ rest_framework/pagination.py | 28 ++++++++++++++++++---------- 4 files changed, 33 insertions(+), 13 deletions(-) diff --git a/docs/api-guide/pagination.md b/docs/api-guide/pagination.md index 13bd57aef..14c0b7f27 100644 --- a/docs/api-guide/pagination.md +++ b/docs/api-guide/pagination.md @@ -51,7 +51,8 @@ You can then apply your new style to a view using the `.pagination_class` attrib Or apply the style globally, using the `DEFAULT_PAGINATION_CLASS` settings key. For example: REST_FRAMEWORK = { - 'DEFAULT_PAGINATION_CLASS': 'apps.core.pagination.StandardResultsSetPagination' } + 'DEFAULT_PAGINATION_CLASS': 'apps.core.pagination.StandardResultsSetPagination' + } --- @@ -163,6 +164,10 @@ Cursor based pagination is more complex than other schemes. It also requires tha #### Details and limitations +Cursor based pagination requires a specified ordering to be applied to the queryset. This will default to `'-created'`, which requires the model being paged against to have a `'created'` field. + + + This implementation of cursor pagination uses a smart "position plus offset" style that allows it to properly support not-strictly-unique values as the ordering. It should be noted that using non-unique values the ordering does introduce the possibility of paging artifacts, where pagination consistency is no longer 100% guaranteed. @@ -192,7 +197,7 @@ To set these attributes you should override the `CursorPagination` class, and th * `page_size` = A numeric value indicating the page size. If set, this overrides the `DEFAULT_PAGE_SIZE` setting. Defaults to the same value as the `DEFAULT_PAGE_SIZE` settings key. * `cursor_query_param` = A string value indicating the name of the "cursor" query parameter. Defaults to `'cursor'`. -* `ordering` = This should be a string, or list of strings, indicating the field against which the cursor based pagination will be applied. For example: `ordering = 'created'`. Any filters on the view which define a `get_ordering` will override this attribute. Defaults to `None`. +* `ordering` = This should be a string, or list of strings, indicating the field against which the cursor based pagination will be applied. For example: `ordering = '-created'`. Any filters on the view which define a `get_ordering` will override this attribute. Defaults to `None`. * `template` = The name of a template to use when rendering pagination controls in the browsable API. May be overridden to modify the rendering style, or set to `None` to disable HTML pagination controls completely. Defaults to `"rest_framework/pagination/previous_and_next.html"`. --- @@ -236,7 +241,8 @@ Let's modify the built-in `PageNumberPagination` style, so that instead of inclu class LinkHeaderPagination(pagination.PageNumberPagination): def get_paginated_response(self, data): - next_url = self.get_next_link() previous_url = self.get_previous_link() + next_url = self.get_next_link() + previous_url = self.get_previous_link() if next_url is not None and previous_url is not None: link = '<{next_url}; rel="next">, <{previous_url}; rel="prev">' diff --git a/docs/topics/3.1-announcement.md b/docs/topics/3.1-announcement.md index ecbc9a380..6606d8431 100644 --- a/docs/topics/3.1-announcement.md +++ b/docs/topics/3.1-announcement.md @@ -203,3 +203,4 @@ The next focus will be on HTML renderings of API output and will include: This will either be made as a single 3.2 release, or split across two separate releases, with the HTML forms and filter controls coming in 3.2, and the admin-style interface coming in a 3.3 release. [custom-exception-handler]: ../api-guide/exceptions.md#custom-exception-handling +[pagination]: ../api-guide/pagination.md diff --git a/docs/topics/release-notes.md b/docs/topics/release-notes.md index 51eb45c37..35592febe 100644 --- a/docs/topics/release-notes.md +++ b/docs/topics/release-notes.md @@ -40,6 +40,11 @@ You can determine your currently installed version using `pip freeze`: ## 3.0.x series +### 3.1.0 + +**Date**: [5th March 2015][3.1.0-milestone]. + +For full details see the [3.1 release announcement](3.1-announcement.md). ### 3.0.5 diff --git a/rest_framework/pagination.py b/rest_framework/pagination.py index 6a2f5b271..f41a9ae1a 100644 --- a/rest_framework/pagination.py +++ b/rest_framework/pagination.py @@ -131,12 +131,19 @@ def _decode_cursor(encoded): """ Given a string representing an encoded cursor, return a `Cursor` instance. """ + + # The offset in the cursor is used in situations where we have a + # nearly-unique index. (Eg millisecond precision creation timestamps) + # We guard against malicious users attempting to cause expensive database + # queries, by having a hard cap on the maximum possible size of the offset. + OFFSET_CUTOFF = 1000 + try: querystring = b64decode(encoded.encode('ascii')).decode('ascii') tokens = urlparse.parse_qs(querystring, keep_blank_values=True) offset = tokens.get('o', ['0'])[0] - offset = _positive_int(offset) + offset = _positive_int(offset, cutoff=OFFSET_CUTOFF) reverse = tokens.get('r', ['0'])[0] reverse = bool(int(reverse)) @@ -472,14 +479,15 @@ class LimitOffsetPagination(BasePagination): class CursorPagination(BasePagination): - # Determine how/if True, False and None positions work - do the string - # encodings work with Django queryset filters? - # Consider a max offset cap. - # Tidy up the `get_ordering` API (eg remove queryset from it) + """ + The cursor pagination implementation is neccessarily complex. + For an overview of the position/offset style we use, see this post: + http://cramer.io/2011/03/08/building-cursors-for-the-disqus-api/ + """ cursor_query_param = 'cursor' page_size = api_settings.PAGE_SIZE invalid_cursor_message = _('Invalid cursor') - ordering = None + ordering = '-created' template = 'rest_framework/pagination/previous_and_next.html' def paginate_queryset(self, queryset, request, view=None): @@ -680,12 +688,12 @@ class CursorPagination(BasePagination): ) ) else: - # The default case is to check for an `ordering` attribute, - # first on the view instance, and then on this pagination instance. - ordering = getattr(view, 'ordering', getattr(self, 'ordering', None)) + # The default case is to check for an `ordering` attribute + # on this pagination instance. + ordering = self.ordering assert ordering is not None, ( 'Using cursor pagination, but no ordering attribute was declared ' - 'on the view or on the pagination class.' + 'on the pagination class.' ) assert isinstance(ordering, (six.string_types, list, tuple)), ( From c511342047f9eea01335fec0ac9ff7f4c823b696 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 6 Mar 2015 11:32:03 +0000 Subject: [PATCH 013/294] More docs on cursor pagination --- docs/api-guide/pagination.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/api-guide/pagination.md b/docs/api-guide/pagination.md index 14c0b7f27..bc65267fb 100644 --- a/docs/api-guide/pagination.md +++ b/docs/api-guide/pagination.md @@ -159,22 +159,23 @@ Cursor based pagination requires that there is a unique, unchanging ordering of Cursor based pagination is more complex than other schemes. It also requires that the result set presents a fixed ordering, and does not allow the client to arbitrarily index into the result set. However it does provide the following benefits: -* Provides a consistent pagination view. When used properly `CursorPagination` ensures that the client will never see the same item twice when paging through records. +* Provides a consistent pagination view. When used properly `CursorPagination` ensures that the client will never see the same item twice when paging through records, even when new items are being inserted by other clients during the pagination process. * Supports usage with very large datasets. With extremely large datasets pagination using offset-based pagination styles may become inefficient or unusable. Cursor based pagination schemes instead have fixed-time properties, and do not slow down as the dataset size increases. #### Details and limitations -Cursor based pagination requires a specified ordering to be applied to the queryset. This will default to `'-created'`, which requires the model being paged against to have a `'created'` field. +Proper use of cursor based pagination a little attention to detail. You'll need to think about what ordering you want the scheme to be applied against. The default is to order by `"-created"`. This assumes that **there must be a 'created' timestamp field** on the model instances, and will present a "timeline" style paginated view, with the most recently added items first. +You can modify the ordering by overriding the `'ordering'` attribute on the pagination class, or by using the `OrderingFilter` filter class together with `CursorPagination`. When used with `OrderingFilter` you should strongly consider restricting the fields that the user may order by. +Proper usage of cursor pagination should have an ordering field that satisfies the following: -This implementation of cursor pagination uses a smart "position plus offset" style that allows it to properly support not-strictly-unique values as the ordering. +* Should be an unchanging value, such as a timestamp, slug, or other field that is only set once, on creation. +* Should be unique, or nearly unique. Millisecond precision timestamps are a good example. This implementation of cursor pagination uses a smart "position plus offset" style that allows it to properly support not-strictly-unique values as the ordering. +* Should be a non-nullable value that can be coerced to a string. +* The field should have a database index. -It should be noted that using non-unique values the ordering does introduce the possibility of paging artifacts, where pagination consistency is no longer 100% guaranteed. - -**TODO**: Notes on `None`. - -The implementation also supports both forward and reverse pagination, which is often not supported in other implementations. +Using an ordering field that does not satisfy these constraints will generally still work, but you'll be loosing some of the benefits of cursor pagination. For more technical details on the implementation we use for cursor pagination, the ["Building cursors for the Disqus API"][disqus-cursor-api] blog post gives a good overview of the basic approach. @@ -197,7 +198,7 @@ To set these attributes you should override the `CursorPagination` class, and th * `page_size` = A numeric value indicating the page size. If set, this overrides the `DEFAULT_PAGE_SIZE` setting. Defaults to the same value as the `DEFAULT_PAGE_SIZE` settings key. * `cursor_query_param` = A string value indicating the name of the "cursor" query parameter. Defaults to `'cursor'`. -* `ordering` = This should be a string, or list of strings, indicating the field against which the cursor based pagination will be applied. For example: `ordering = '-created'`. Any filters on the view which define a `get_ordering` will override this attribute. Defaults to `None`. +* `ordering` = This should be a string, or list of strings, indicating the field against which the cursor based pagination will be applied. For example: `ordering = 'slug'`. Defaults to `-created`. This value may also be overridden by using `OrderingFilter` on the view. * `template` = The name of a template to use when rendering pagination controls in the browsable API. May be overridden to modify the rendering style, or set to `None` to disable HTML pagination controls completely. Defaults to `"rest_framework/pagination/previous_and_next.html"`. --- From 7efb2fd9ed03a23b0bcf8d9fa20034bf9ef884f8 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 6 Mar 2015 12:26:09 +0000 Subject: [PATCH 014/294] Better docs linking --- docs/topics/3.1-announcement.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/topics/3.1-announcement.md b/docs/topics/3.1-announcement.md index 6606d8431..9cad88e07 100644 --- a/docs/topics/3.1-announcement.md +++ b/docs/topics/3.1-announcement.md @@ -52,7 +52,7 @@ For more information, see the [custom pagination styles](../api-guide/pagination ## Versioning -We've made it easier to build versioned APIs. Built-in schemes for versioning include both URL based and Accept header based variations. +We've made it [easier to build versioned APIs][versioning]. Built-in schemes for versioning include both URL based and Accept header based variations. When using a URL based scheme, hyperlinked serializers will resolve relationships to the same API version as used on the incoming request. @@ -80,7 +80,7 @@ The output representation would match the version used on the incoming request. ## Internationalization -REST framework now includes a built-in set of translations, and supports internationalized error responses. This allows you to either change the default language, or to allow clients to specify the language via the `Accept-Language` header. +REST framework now includes a built-in set of translations, and [supports internationalized error responses][internationalization]. This allows you to either change the default language, or to allow clients to specify the language via the `Accept-Language` header. You can change the default language by using the standard Django `LANGUAGE_CODE` setting: @@ -204,3 +204,5 @@ This will either be made as a single 3.2 release, or split across two separate r [custom-exception-handler]: ../api-guide/exceptions.md#custom-exception-handling [pagination]: ../api-guide/pagination.md +[versioning]: ../api-guide/versioning.md +[internationalization]: internationalization.md From d2181cc74ccfc59edc0477017d95e8edcbecf8a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Padilla?= Date: Fri, 6 Mar 2015 09:41:34 -0400 Subject: [PATCH 015/294] Fix customizing field mappings link --- docs/topics/3.1-announcement.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/topics/3.1-announcement.md b/docs/topics/3.1-announcement.md index 9cad88e07..6eb3681ff 100644 --- a/docs/topics/3.1-announcement.md +++ b/docs/topics/3.1-announcement.md @@ -145,7 +145,7 @@ If you're building a new 1.8 project, then you should probably consider using `U The serializer redesign in 3.0 did not include any public API for modifying how ModelSerializer classes automatically generate a set of fields from a given mode class. We've now re-introduced an API for this, allowing you to create new ModelSerializer base classes that behave differently, such as using a different default style for relationships. -For more information, see the documentation on [customizing field mappings](../api-guide/serializers/#customizing-field-mappings) for ModelSerializer classes. +For more information, see the documentation on [customizing field mappings][customizing-field-mappings] for ModelSerializer classes. --- @@ -206,3 +206,4 @@ This will either be made as a single 3.2 release, or split across two separate r [pagination]: ../api-guide/pagination.md [versioning]: ../api-guide/versioning.md [internationalization]: internationalization.md +[customizing-field-mappings]: ../api-guide/serializers.md/#customizing-field-mappings From fb58ef043cc39d900bb8389855f07087cb0d7920 Mon Sep 17 00:00:00 2001 From: Matt d'Entremont Date: Wed, 4 Mar 2015 17:36:03 -0400 Subject: [PATCH 016/294] Add support for serializing models with m2m related fields - In both ManyRelatedField, provide an empty return when trying to access a relation field if the instance in question has no PK (so likely hasn't been inserted yet) - Add relevant tests - Without these changes, exceptions would be raised when trying to serialize the uncreated models as it is impossible to query relations without a PK - Add test to ensure RelatedField does not regress as currently supports being serialized with and unsaved model --- rest_framework/relations.py | 4 ++++ tests/test_relations_pk.py | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/rest_framework/relations.py b/rest_framework/relations.py index 0b7c9d864..3a966c5bf 100644 --- a/rest_framework/relations.py +++ b/rest_framework/relations.py @@ -360,6 +360,10 @@ class ManyRelatedField(Field): ] def get_attribute(self, instance): + # Can't have any relationships if not created + if not instance.pk: + return [] + relationship = get_attribute(instance, self.source_attrs) return relationship.all() if (hasattr(relationship, 'all')) else relationship diff --git a/tests/test_relations_pk.py b/tests/test_relations_pk.py index f872a8dc5..ca43272b0 100644 --- a/tests/test_relations_pk.py +++ b/tests/test_relations_pk.py @@ -143,6 +143,16 @@ class PKManyToManyTests(TestCase): ] self.assertEqual(serializer.data, expected) + def test_many_to_many_unsaved(self): + source = ManyToManySource(name='source-unsaved') + + serializer = ManyToManySourceSerializer(source) + + expected = {'id': None, 'name': 'source-unsaved', 'targets': []} + # no query if source hasn't been created yet + with self.assertNumQueries(0): + self.assertEqual(serializer.data, expected) + def test_reverse_many_to_many_create(self): data = {'id': 4, 'name': 'target-4', 'sources': [1, 3]} serializer = ManyToManyTargetSerializer(data=data) @@ -296,6 +306,16 @@ class PKForeignKeyTests(TestCase): self.assertFalse(serializer.is_valid()) self.assertEqual(serializer.errors, {'target': ['This field may not be null.']}) + def test_foreign_key_with_unsaved(self): + source = ForeignKeySource(name='source-unsaved') + expected = {'id': None, 'name': 'source-unsaved', 'target': None} + + serializer = ForeignKeySourceSerializer(source) + + # no query if source hasn't been created yet + with self.assertNumQueries(0): + self.assertEqual(serializer.data, expected) + def test_foreign_key_with_empty(self): """ Regression test for #1072 From 7159b31023640b8821131e39a7f9eaadfacb2f07 Mon Sep 17 00:00:00 2001 From: Aider Ibragimov Date: Sat, 7 Mar 2015 07:17:22 +0300 Subject: [PATCH 017/294] update vertical and inline layouts for radio choices --- .../rest_framework/horizontal/radio.html | 5 +++-- .../templates/rest_framework/inline/radio.html | 11 +++++++++++ .../rest_framework/vertical/radio.html | 17 +++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/rest_framework/templates/rest_framework/horizontal/radio.html b/rest_framework/templates/rest_framework/horizontal/radio.html index efca2883e..cabd09d2b 100644 --- a/rest_framework/templates/rest_framework/horizontal/radio.html +++ b/rest_framework/templates/rest_framework/horizontal/radio.html @@ -1,4 +1,5 @@ {% load i18n %} +{% trans "None" as none_choice %}
{% if field.label %} @@ -9,7 +10,7 @@ {% if field.allow_null or field.allow_blank %} {% endif %} {% for key, text in field.choices.items %} @@ -23,7 +24,7 @@
{% endif %} diff --git a/rest_framework/templates/rest_framework/inline/radio.html b/rest_framework/templates/rest_framework/inline/radio.html index 1915f4f84..b65016715 100644 --- a/rest_framework/templates/rest_framework/inline/radio.html +++ b/rest_framework/templates/rest_framework/inline/radio.html @@ -1,7 +1,18 @@ +{% load i18n %} +{% trans "None" as none_choice %} +
{% if field.label %} {% endif %} + {% if field.allow_null or field.allow_blank %} +
+ +
+ {% endif %} {% for key, text in field.choices.items %}