diff --git a/.travis.yml b/.travis.yml index 87db5e0465..4a7fdd42f2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -18,23 +18,16 @@ env: - TOX_ENV=py32-django16 - TOX_ENV=py27-django16 - TOX_ENV=py26-django16 - - TOX_ENV=py34-django15 - - TOX_ENV=py33-django15 - - TOX_ENV=py32-django15 - - TOX_ENV=py27-django15 - - TOX_ENV=py26-django15 - TOX_ENV=py27-djangomaster - - TOX_ENV=py32-djangomaster - - TOX_ENV=py33-djangomaster - TOX_ENV=py34-djangomaster + - TOX_ENV=py35-djangomaster matrix: fast_finish: true allow_failures: - env: TOX_ENV=py27-djangomaster - - env: TOX_ENV=py32-djangomaster - - env: TOX_ENV=py33-djangomaster - env: TOX_ENV=py34-djangomaster + - env: TOX_ENV=py35-djangomaster install: - pip install tox diff --git a/README.md b/README.md index b183e14df0..c2a686f4ce 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ There is a live example API for testing purposes, [available here][sandbox]. # Requirements * Python (2.6.5+, 2.7, 3.2, 3.3, 3.4) -* Django (1.5.6+, 1.6.3+, 1.7, 1.8) +* Django (1.6.3+, 1.7, 1.8) # Installation diff --git a/docs/api-guide/serializers.md b/docs/api-guide/serializers.md index ca540a13b5..63189b9663 100644 --- a/docs/api-guide/serializers.md +++ b/docs/api-guide/serializers.md @@ -438,6 +438,7 @@ Declaring a `ModelSerializer` looks like this: class AccountSerializer(serializers.ModelSerializer): class Meta: model = Account + fields = ('id', 'account_name', 'users', 'created') By default, all the model fields on the class will be mapped to a corresponding serializer fields. @@ -459,7 +460,7 @@ To do so, open the Django shell, using `python manage.py shell`, then import the ## Specifying which fields to include -If you only want a subset of the default fields to be used in a model serializer, you can do so using `fields` or `exclude` options, just as you would with a `ModelForm`. +If you only want a subset of the default fields to be used in a model serializer, you can do so using `fields` or `exclude` options, just as you would with a `ModelForm`. It is strongly recommended that you explicitly set all fields that should be serialized using the `fields` attribute. This will make it less likely to result in unintentionally exposing data when your models change. For example: @@ -468,7 +469,27 @@ For example: model = Account fields = ('id', 'account_name', 'users', 'created') -The names in the `fields` option will normally map to model fields on the model class. +You can also set the `fields` attribute to the special value `'__all__'` to indicate that all fields in the model should be used. + +For example: + + class AccountSerializer(serializers.ModelSerializer): + class Meta: + model = Account + fields = '__all__' + +You can set the `exclude` attribute of the to a list of fields to be excluded from the serializer. + +For example: + + class AccountSerializer(serializers.ModelSerializer): + class Meta: + model = Account + exclude = ('users',) + +In the example above, if the `Account` model had 3 fields `account_name`, `users`, and `created`, this will result in the fields `account_name` and `created` to be serialized. + +The names in the `fields` and `exclude` attributes will normally map to model fields on the model class. Alternatively names in the `fields` options can map to properties or methods which take no arguments that exist on the model class. diff --git a/docs/topics/release-notes.md b/docs/topics/release-notes.md index 260dc476c3..93778f74dd 100644 --- a/docs/topics/release-notes.md +++ b/docs/topics/release-notes.md @@ -38,6 +38,14 @@ You can determine your currently installed version using `pip freeze`: --- +## 3.3.x series + +### 3.3.0 + +**Date**: NOT YET RELEASED + +* Removed support for Django Version 1.5 ([#3421][gh3421]) + ## 3.2.x series ### 3.2.4 @@ -533,3 +541,6 @@ For older release notes, [please see the version 2.x documentation][old-release- [gh3361]: https://github.com/tomchristie/django-rest-framework/issues/3361 [gh3364]: https://github.com/tomchristie/django-rest-framework/issues/3364 [gh3415]: https://github.com/tomchristie/django-rest-framework/issues/3415 + + +[gh3421]: https://github.com/tomchristie/django-rest-framework/pulls/3421 diff --git a/rest_framework/compat.py b/rest_framework/compat.py index 460795f658..fcca2dcbf8 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -67,6 +67,14 @@ def distinct(queryset, base): from django.utils.datastructures import SortedDict as OrderedDict +# unittest.SkipUnless only available in Python 2.7. +try: + import unittest + unittest.skipUnless +except (ImportError, AttributeError): + from django.utils import unittest + + # contrib.postgres only supported from 1.8 onwards. try: from django.contrib.postgres import fields as postgres_fields @@ -74,23 +82,30 @@ def distinct(queryset, base): postgres_fields = None +# Apps only exists from 1.7 onwards. +try: + from django.apps import apps + get_model = apps.get_model +except ImportError: + from django.db.models import get_model + + +# Import path changes from 1.7 onwards. +try: + from django.contrib.contenttypes.fields import ( + GenericForeignKey, GenericRelation + ) +except ImportError: + from django.contrib.contenttypes.generic import ( + GenericForeignKey, GenericRelation + ) + # django-filter is optional try: import django_filters except ImportError: django_filters = None -if django.VERSION >= (1, 6): - def clean_manytomany_helptext(text): - return text -else: - # Up to version 1.5 many to many fields automatically suffix - # the `help_text` attribute with hardcoded text. - def clean_manytomany_helptext(text): - if text.endswith(' Hold down "Control", or "Command" on a Mac, to select more than one.'): - text = text[:-69] - return text - # Django-guardian is optional. Import only if guardian is in INSTALLED_APPS # Fixes (#1712). We keep the try/except for the test suite. guardian = None @@ -101,14 +116,6 @@ def clean_manytomany_helptext(text): pass -def get_model_name(model_cls): - try: - return model_cls._meta.model_name - except AttributeError: - # < 1.6 used module_name instead of model_name - return model_cls._meta.module_name - - # MinValueValidator, MaxValueValidator et al. only accept `message` in 1.8+ if django.VERSION >= (1, 8): from django.core.validators import MinValueValidator, MaxValueValidator @@ -144,32 +151,6 @@ def __init__(self, *args, **kwargs): super(MaxLengthValidator, self).__init__(*args, **kwargs) -# URLValidator only accepts `message` in 1.6+ -if django.VERSION >= (1, 6): - from django.core.validators import URLValidator -else: - from django.core.validators import URLValidator as DjangoURLValidator - - - class URLValidator(DjangoURLValidator): - def __init__(self, *args, **kwargs): - self.message = kwargs.pop('message', self.message) - super(URLValidator, self).__init__(*args, **kwargs) - - -# EmailValidator requires explicit regex prior to 1.6+ -if django.VERSION >= (1, 6): - from django.core.validators import EmailValidator -else: - from django.core.validators import EmailValidator as DjangoEmailValidator - from django.core.validators import email_re - - - class EmailValidator(DjangoEmailValidator): - def __init__(self, *args, **kwargs): - super(EmailValidator, self).__init__(email_re, *args, **kwargs) - - # PATCH method is not implemented by Django if 'patch' not in View.http_method_names: View.http_method_names = View.http_method_names + ['patch'] diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 86e86f86ef..09284dd554 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -11,7 +11,9 @@ from django.conf import settings from django.core.exceptions import ValidationError as DjangoValidationError from django.core.exceptions import ObjectDoesNotExist -from django.core.validators import RegexValidator, ip_address_validators +from django.core.validators import ( + EmailValidator, RegexValidator, URLValidator, ip_address_validators +) from django.forms import FilePathField as DjangoFilePathField from django.forms import ImageField as DjangoImageField from django.utils import six, timezone @@ -23,9 +25,9 @@ from rest_framework import ISO_8601 from rest_framework.compat import ( - EmailValidator, MaxLengthValidator, MaxValueValidator, MinLengthValidator, - MinValueValidator, OrderedDict, URLValidator, duration_string, - parse_duration, unicode_repr, unicode_to_repr + MaxLengthValidator, MaxValueValidator, MinLengthValidator, + MinValueValidator, OrderedDict, duration_string, parse_duration, + unicode_repr, unicode_to_repr ) from rest_framework.exceptions import ValidationError from rest_framework.settings import api_settings diff --git a/rest_framework/filters.py b/rest_framework/filters.py index 90c19aba08..c1e0ae054a 100644 --- a/rest_framework/filters.py +++ b/rest_framework/filters.py @@ -11,9 +11,7 @@ from django.db import models from django.utils import six -from rest_framework.compat import ( - distinct, django_filters, get_model_name, guardian -) +from rest_framework.compat import distinct, django_filters, guardian from rest_framework.settings import api_settings FilterSet = django_filters and django_filters.FilterSet or None @@ -202,7 +200,7 @@ def filter_queryset(self, request, queryset, view): model_cls = queryset.model kwargs = { 'app_label': model_cls._meta.app_label, - 'model_name': get_model_name(model_cls) + 'model_name': model_cls._meta.model_name } permission = self.perm_format % kwargs if guardian.VERSION >= (1, 3): diff --git a/rest_framework/permissions.py b/rest_framework/permissions.py index 628538903a..292952cfab 100644 --- a/rest_framework/permissions.py +++ b/rest_framework/permissions.py @@ -5,8 +5,6 @@ from django.http import Http404 -from rest_framework.compat import get_model_name - SAFE_METHODS = ('GET', 'HEAD', 'OPTIONS') @@ -104,7 +102,7 @@ def get_required_permissions(self, method, model_cls): """ kwargs = { 'app_label': model_cls._meta.app_label, - 'model_name': get_model_name(model_cls) + 'model_name': model_cls._meta.model_name } return [perm % kwargs for perm in self.perms_map[method]] @@ -166,7 +164,7 @@ class DjangoObjectPermissions(DjangoModelPermissions): def get_required_object_permissions(self, method, model_cls): kwargs = { 'app_label': model_cls._meta.app_label, - 'model_name': get_model_name(model_cls) + 'model_name': model_cls._meta.model_name } return [perm % kwargs for perm in self.perms_map[method]] diff --git a/rest_framework/response.py b/rest_framework/response.py index 3f038e3b5f..0e97668eb4 100644 --- a/rest_framework/response.py +++ b/rest_framework/response.py @@ -98,7 +98,7 @@ def __getstate__(self): state = super(Response, self).__getstate__() for key in ( 'accepted_renderer', 'renderer_context', 'resolver_match', - 'client', 'request', 'wsgi_request' + 'client', 'request', 'json', 'wsgi_request' ): if key in state: del state[key] diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index fc1fe468d1..0b0b1aa623 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -12,6 +12,8 @@ """ from __future__ import unicode_literals +import warnings + from django.db import models from django.db.models.fields import Field as DjangoModelField from django.db.models.fields import FieldDoesNotExist @@ -51,6 +53,8 @@ 'instance', 'data', 'partial', 'context', 'allow_null' ) +ALL_FIELDS = '__all__' + # BaseSerializer # -------------- @@ -957,10 +961,10 @@ def get_field_names(self, declared_fields, info): fields = getattr(self.Meta, 'fields', None) exclude = getattr(self.Meta, 'exclude', None) - if fields and not isinstance(fields, (list, tuple)): + if fields and fields != ALL_FIELDS and not isinstance(fields, (list, tuple)): raise TypeError( - 'The `fields` option must be a list or tuple. Got %s.' % - type(fields).__name__ + 'The `fields` option must be a list or tuple or "__all__". ' + 'Got %s.' % type(fields).__name__ ) if exclude and not isinstance(exclude, (list, tuple)): @@ -976,6 +980,20 @@ def get_field_names(self, declared_fields, info): ) ) + if fields is None and exclude is None: + warnings.warn( + "Creating a ModelSerializer without either the 'fields' " + "attribute or the 'exclude' attribute is pending deprecation " + "since 3.3.0. Add an explicit fields = '__all__' to the " + "{serializer_class} serializer.".format( + serializer_class=self.__class__.__name__ + ), + PendingDeprecationWarning + ) + + if fields == ALL_FIELDS: + fields = None + if fields is not None: # Ensure that all declared fields have also been included in the # `Meta.fields` option. diff --git a/rest_framework/templatetags/rest_framework.py b/rest_framework/templatetags/rest_framework.py index 0069d9a5e2..08acecef77 100644 --- a/rest_framework/templatetags/rest_framework.py +++ b/rest_framework/templatetags/rest_framework.py @@ -41,8 +41,9 @@ def optional_login(request): except NoReverseMatch: return '' - snippet = "
  • Log in
  • ".format(href=login_url, next=escape(request.path)) - return snippet + snippet = "
  • Log in
  • " + snippet = snippet.format(href=login_url, next=escape(request.path)) + return mark_safe(snippet) @register.simple_tag @@ -64,8 +65,8 @@ def optional_logout(request, user):
  • Log out
  • """ - - return snippet.format(user=user, href=logout_url, next=escape(request.path)) + snippet = snippet.format(user=escape(user), href=logout_url, next=escape(request.path)) + return mark_safe(snippet) @register.simple_tag diff --git a/rest_framework/utils/field_mapping.py b/rest_framework/utils/field_mapping.py index 48c584b91f..fef9f5d3df 100644 --- a/rest_framework/utils/field_mapping.py +++ b/rest_framework/utils/field_mapping.py @@ -8,7 +8,6 @@ from django.db import models from django.utils.text import capfirst -from rest_framework.compat import clean_manytomany_helptext from rest_framework.validators import UniqueValidator NUMERIC_FIELD_TYPES = ( @@ -230,7 +229,7 @@ def get_relation_kwargs(field_name, relation_info): if model_field: if model_field.verbose_name and needs_label(model_field, field_name): kwargs['label'] = capfirst(model_field.verbose_name) - help_text = clean_manytomany_helptext(model_field.help_text) + help_text = model_field.help_text if help_text: kwargs['help_text'] = help_text if not model_field.editable: diff --git a/rest_framework/utils/model_meta.py b/rest_framework/utils/model_meta.py index 3bcd9049c4..9718647695 100644 --- a/rest_framework/utils/model_meta.py +++ b/rest_framework/utils/model_meta.py @@ -12,7 +12,7 @@ from django.db import models from django.utils import six -from rest_framework.compat import OrderedDict +from rest_framework.compat import OrderedDict, get_model FieldInfo = namedtuple('FieldResult', [ 'pk', # Model field instance @@ -45,7 +45,7 @@ def _resolve_model(obj): """ if isinstance(obj, six.string_types) and len(obj.split('.')) == 2: app_name, model_name = obj.split('.') - resolved_model = models.get_model(app_name, model_name) + resolved_model = get_model(app_name, model_name) if resolved_model is None: msg = "Django did not return a model for {0}.{1}" raise ImproperlyConfigured(msg.format(app_name, model_name)) diff --git a/tests/test_atomic_requests.py b/tests/test_atomic_requests.py index 9c30c2f8bd..5920caccb6 100644 --- a/tests/test_atomic_requests.py +++ b/tests/test_atomic_requests.py @@ -1,13 +1,13 @@ from __future__ import unicode_literals -from django.conf.urls import patterns, url +from django.conf.urls import url from django.db import connection, connections, transaction from django.http import Http404 from django.test import TestCase, TransactionTestCase from django.utils.decorators import method_decorator -from django.utils.unittest import skipUnless from rest_framework import status +from rest_framework.compat import unittest from rest_framework.exceptions import APIException from rest_framework.response import Response from rest_framework.test import APIRequestFactory @@ -35,8 +35,10 @@ def post(self, request, *args, **kwargs): raise APIException -@skipUnless(connection.features.uses_savepoints, - "'atomic' requires transactions and savepoints.") +@unittest.skipUnless( + connection.features.uses_savepoints, + "'atomic' requires transactions and savepoints." +) class DBTransactionTests(TestCase): def setUp(self): self.view = BasicView.as_view() @@ -55,8 +57,10 @@ def test_no_exception_commit_transaction(self): assert BasicModel.objects.count() == 1 -@skipUnless(connection.features.uses_savepoints, - "'atomic' requires transactions and savepoints.") +@unittest.skipUnless( + connection.features.uses_savepoints, + "'atomic' requires transactions and savepoints." +) class DBTransactionErrorTests(TestCase): def setUp(self): self.view = ErrorView.as_view() @@ -83,8 +87,10 @@ def test_generic_exception_delegate_transaction_management(self): assert BasicModel.objects.count() == 1 -@skipUnless(connection.features.uses_savepoints, - "'atomic' requires transactions and savepoints.") +@unittest.skipUnless( + connection.features.uses_savepoints, + "'atomic' requires transactions and savepoints." +) class DBTransactionAPIExceptionTests(TestCase): def setUp(self): self.view = APIExceptionView.as_view() @@ -113,8 +119,10 @@ def test_api_exception_rollback_transaction(self): assert BasicModel.objects.count() == 0 -@skipUnless(connection.features.uses_savepoints, - "'atomic' requires transactions and savepoints.") +@unittest.skipUnless( + connection.features.uses_savepoints, + "'atomic' requires transactions and savepoints." +) class NonAtomicDBTransactionAPIExceptionTests(TransactionTestCase): @property def urls(self): @@ -127,9 +135,8 @@ def get(self, request, *args, **kwargs): BasicModel.objects.all() raise Http404 - return patterns( - '', - url(r'^$', NonAtomicAPIExceptionView.as_view()) + return ( + url(r'^$', NonAtomicAPIExceptionView.as_view()), ) def setUp(self): diff --git a/tests/test_filters.py b/tests/test_filters.py index 0610b08557..bce6e08faf 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -8,12 +8,11 @@ from django.db import models from django.test import TestCase from django.test.utils import override_settings -from django.utils import unittest from django.utils.dateparse import parse_date from django.utils.six.moves import reload_module from rest_framework import filters, generics, serializers, status -from rest_framework.compat import django_filters +from rest_framework.compat import django_filters, unittest from rest_framework.test import APIRequestFactory from .models import BaseFilterableItem, BasicModel, FilterableItem diff --git a/tests/test_model_serializer.py b/tests/test_model_serializer.py index 777b956c4b..89557fa1dd 100644 --- a/tests/test_model_serializer.py +++ b/tests/test_model_serializer.py @@ -321,6 +321,21 @@ class Meta: ExampleSerializer() + def test_fields_and_exclude_behavior(self): + class ImplicitFieldsSerializer(serializers.ModelSerializer): + class Meta: + model = RegularFieldsModel + + class ExplicitFieldsSerializer(serializers.ModelSerializer): + class Meta: + model = RegularFieldsModel + fields = '__all__' + + implicit = ImplicitFieldsSerializer() + explicit = ExplicitFieldsSerializer() + + assert implicit.data == explicit.data + @pytest.mark.skipif(django.VERSION < (1, 8), reason='DurationField is only available for django1.8+') diff --git a/tests/test_permissions.py b/tests/test_permissions.py index 3980200020..f0d77e957b 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -6,13 +6,12 @@ from django.core.urlresolvers import ResolverMatch from django.db import models from django.test import TestCase -from django.utils import unittest from rest_framework import ( HTTP_HEADER_ENCODING, authentication, generics, permissions, serializers, status ) -from rest_framework.compat import get_model_name, guardian +from rest_framework.compat import guardian, unittest from rest_framework.filters import DjangoObjectPermissionsFilter from rest_framework.routers import DefaultRouter from rest_framework.test import APIRequestFactory @@ -279,7 +278,7 @@ def setUp(self): # give everyone model level permissions, as we are not testing those everyone = Group.objects.create(name='everyone') - model_name = get_model_name(BasicPermModel) + model_name = BasicPermModel._meta.model_name app_label = BasicPermModel._meta.app_label f = '{0}_{1}'.format perms = { diff --git a/tests/test_relations_generic.py b/tests/test_relations_generic.py index 962857365f..5cb2dfc05b 100644 --- a/tests/test_relations_generic.py +++ b/tests/test_relations_generic.py @@ -1,14 +1,12 @@ from __future__ import unicode_literals -from django.contrib.contenttypes.generic import ( - GenericForeignKey, GenericRelation -) from django.contrib.contenttypes.models import ContentType from django.db import models from django.test import TestCase from django.utils.encoding import python_2_unicode_compatible from rest_framework import serializers +from rest_framework.compat import GenericForeignKey, GenericRelation @python_2_unicode_compatible diff --git a/tests/test_utils.py b/tests/test_utils.py index 062f78e11a..4c9fd03c85 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -150,12 +150,12 @@ class ResolveModelWithPatchedDjangoTests(TestCase): def setUp(self): """Monkeypatch get_model.""" - self.get_model = rest_framework.utils.model_meta.models.get_model + self.get_model = rest_framework.utils.model_meta.get_model def get_model(app_label, model_name): return None - rest_framework.utils.model_meta.models.get_model = get_model + rest_framework.utils.model_meta.get_model = get_model def tearDown(self): """Revert monkeypatching.""" diff --git a/tox.ini b/tox.ini index ef505248bb..204a68457f 100644 --- a/tox.ini +++ b/tox.ini @@ -4,15 +4,23 @@ addopts=--tb=short [tox] envlist = py27-{lint,docs}, - {py26,py27,py32,py33,py34}-django{15,16}, - {py27,py32,py33,py34}-django{17,18,master} + {py26,py27,py32,py33,py34}-django16, + {py27,py32,py33,py34}-django{17,18}, + {py27,py34,py35}-django{master} [testenv] +basepython = + py26: python2.6 + py27: python2.7 + py32: python3.2 + py33: python3.3 + py34: python3.4 + py35: python3.5 + commands = ./runtests.py --fast {posargs} --coverage setenv = PYTHONDONTWRITEBYTECODE=1 deps = - django15: Django==1.5.6 # Should track minimum supported django16: Django==1.6.3 # Should track minimum supported django17: Django==1.7.10 # Should track maximum supported django18: Django==1.8.4 # Should track maximum supported @@ -31,3 +39,25 @@ commands = mkdocs build deps = -rrequirements/requirements-testing.txt -rrequirements/requirements-documentation.txt + +# Specify explicitly to exclude Django Guardian against Django master (various Pythons) +[testenv:py27-djangomaster] +deps = + https://github.com/django/django/archive/master.tar.gz + -rrequirements/requirements-testing.txt + markdown==2.5.2 + django-filter==0.10.0 +[testenv:py34-djangomaster] +deps = + https://github.com/django/django/archive/master.tar.gz + -rrequirements/requirements-testing.txt + markdown==2.5.2 + django-filter==0.10.0 + +[testenv:py35-djangomaster] +deps = + https://github.com/django/django/archive/master.tar.gz + -rrequirements/requirements-testing.txt + markdown==2.5.2 + django-filter==0.10.0 +