diff --git a/.gitignore b/.gitignore index 25113d09d..2255cd9aa 100644 --- a/.gitignore +++ b/.gitignore @@ -10,5 +10,10 @@ dist/ *.egg-info/ MANIFEST +bin/ +include/ +lib/ +local/ + !.gitignore !.travis.yml diff --git a/README.md b/README.md index 1d49d3be6..13a78724f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ **A toolkit for building well-connected, self-describing web APIs.** -**Author:** Tom Christie. [Follow me on Twitter][twitter] +**Author:** Tom Christie. [Follow me on Twitter][twitter]. + +**Support:** [REST framework discussion group][group]. [![build-status-image]][travis] @@ -58,6 +60,20 @@ To run the tests. # Changelog +### 2.1.12 + +**Date**: 21st Dec 2012 + +* Bugfix: Fix bug that could occur using ChoiceField. +* Bugfix: Fix exception in browseable API on DELETE. +* Bugfix: Fix issue where pk was was being set to a string if set by URL kwarg. + +## 2.1.11 + +**Date**: 17th Dec 2012 + +* Bugfix: Fix issue with M2M fields in browseable API. + ## 2.1.10 **Date**: 17th Dec 2012 @@ -205,6 +221,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. [build-status-image]: https://secure.travis-ci.org/tomchristie/django-rest-framework.png?branch=restframework2 [travis]: http://travis-ci.org/tomchristie/django-rest-framework?branch=master [twitter]: https://twitter.com/_tomchristie +[group]: https://groups.google.com/forum/?fromgroups#!forum/django-rest-framework [0.4]: https://github.com/tomchristie/django-rest-framework/tree/0.4.X [sandbox]: http://restframework.herokuapp.com/ [rest-framework-2-announcement]: http://django-rest-framework.org/topics/rest-framework-2-announcement.html diff --git a/docs/topics/credits.md b/docs/topics/credits.md index ba37ce119..8b8cac1a9 100644 --- a/docs/topics/credits.md +++ b/docs/topics/credits.md @@ -79,6 +79,8 @@ The following people have helped make REST framework great. * Colin Murtaugh - [cmurtaugh] * Simon Pantzare - [pilt] * Szymon Teżewski - [sunscrapers] +* Joel Marcotte - [joual] +* Trey Hunner - [treyhunner] Many thanks to everyone who's contributed to the project. @@ -98,10 +100,9 @@ Development of REST framework 2.0 was sponsored by [DabApps]. ## Contact -To contact the author directly: +For usage questions please see the [REST framework discussion group][group]. -* twitter: [@_tomchristie][twitter] -* email: [tom@tomchristie.com][email] +You can also contact [@_tomchristie][twitter] directly on twitter. [email]: mailto:tom@tomchristie.com [twitter]: http://twitter.com/_tomchristie @@ -115,6 +116,7 @@ To contact the author directly: [dabapps]: http://lab.dabapps.com [sandbox]: http://restframework.herokuapp.com/ [heroku]: http://www.heroku.com/ +[group]: https://groups.google.com/forum/?fromgroups#!forum/django-rest-framework [tomchristie]: https://github.com/tomchristie [markotibold]: https://github.com/markotibold @@ -193,3 +195,5 @@ To contact the author directly: [cmurtaugh]: https://github.com/cmurtaugh [pilt]: https://github.com/pilt [sunscrapers]: https://github.com/sunscrapers +[joual]: https://github.com/joual +[treyhunner]: https://github.com/treyhunner diff --git a/docs/topics/release-notes.md b/docs/topics/release-notes.md index 66d6f7f38..6dedc3d22 100644 --- a/docs/topics/release-notes.md +++ b/docs/topics/release-notes.md @@ -6,6 +6,20 @@ ## 2.1.x series +### 2.1.12 + +**Date**: 21st Dec 2012 + +* Bugfix: Fix bug that could occur using ChoiceField. +* Bugfix: Fix exception in browseable API on DELETE. +* Bugfix: Fix issue where pk was was being set to a string if set by URL kwarg. + +### 2.1.11 + +**Date**: 17th Dec 2012 + +* Bugfix: Fix issue with M2M fields in browseable API. + ### 2.1.10 **Date**: 17th Dec 2012 diff --git a/docs/tutorial/5-relationships-and-hyperlinked-apis.md b/docs/tutorial/5-relationships-and-hyperlinked-apis.md index b5d378751..216ca4338 100644 --- a/docs/tutorial/5-relationships-and-hyperlinked-apis.md +++ b/docs/tutorial/5-relationships-and-hyperlinked-apis.md @@ -163,9 +163,9 @@ You can review the final [tutorial code][repo] on GitHub, or try out a live exam We've reached the end of our tutorial. If you want to get more involved in the REST framework project, here's a few places you can start: -* Contribute on [GitHub][github] by reviewing and subitting issues, and making pull requests. +* Contribute on [GitHub][github] by reviewing and submitting issues, and making pull requests. * Join the [REST framework discussion group][group], and help build the community. -* Follow the author [on Twitter][twitter] and say hi. +* [Follow the author on Twitter][twitter] and say hi. **Now go build awesome things.** diff --git a/rest_framework/__init__.py b/rest_framework/__init__.py index d61632bcf..02bc6fc1d 100644 --- a/rest_framework/__init__.py +++ b/rest_framework/__init__.py @@ -1,3 +1,3 @@ -__version__ = '2.1.10' +__version__ = '2.1.12' VERSION = __version__ # synonym diff --git a/rest_framework/compat.py b/rest_framework/compat.py index d4901437d..86952fb8d 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -5,6 +5,12 @@ versions of django/python, and compatibility wrappers around optional packages. # flake8: noqa import django +# location of patterns, url, include changes in 1.4 onwards +try: + from django.conf.urls import patterns, url, include +except: + from django.conf.urls.defaults import patterns, url, include + # django-filter is optional try: import django_filters diff --git a/rest_framework/fields.py b/rest_framework/fields.py index d3470854a..398d473f2 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -390,6 +390,7 @@ class ManyRelatedMixin(object): else: if value == ['']: value = [] + into[field_name] = [self.from_native(item) for item in value] @@ -801,7 +802,7 @@ class ChoiceField(WritableField): if value == smart_unicode(k2): return True else: - if value == smart_unicode(k): + if value == smart_unicode(k) or value == k: return True return False diff --git a/rest_framework/mixins.py b/rest_framework/mixins.py index 1edcfa5c9..2700606d0 100644 --- a/rest_framework/mixins.py +++ b/rest_framework/mixins.py @@ -113,6 +113,10 @@ class UpdateModelMixin(object): slug_field = self.get_slug_field() setattr(obj, slug_field, slug) + # Ensure we clean the attributes so that we don't eg return integer + # pk using a string representation, as provided by the url conf kwarg. + obj.full_clean() + class DestroyModelMixin(object): """ @@ -120,6 +124,6 @@ class DestroyModelMixin(object): Should be mixed in with `SingleObjectBaseView`. """ def destroy(self, request, *args, **kwargs): - self.object = self.get_object() - self.object.delete() + obj = self.get_object() + obj.delete() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/rest_framework/renderers.py b/rest_framework/renderers.py index 1220bca10..a4ae717db 100644 --- a/rest_framework/renderers.py +++ b/rest_framework/renderers.py @@ -20,7 +20,7 @@ from rest_framework.utils import dict2xml from rest_framework.utils import encoders from rest_framework.utils.breadcrumbs import get_breadcrumbs from rest_framework import VERSION, status -from rest_framework import serializers, parsers +from rest_framework import parsers class BaseRenderer(object): diff --git a/rest_framework/request.py b/rest_framework/request.py index 39c643219..b7133608a 100644 --- a/rest_framework/request.py +++ b/rest_framework/request.py @@ -188,6 +188,14 @@ class Request(object): self._user, self._auth = self._authenticate() return self._auth + @auth.setter + def auth(self, value): + """ + Sets any non-user authentication information associated with the + request, such as an authentication token. + """ + self._auth = value + def _load_data_and_files(self): """ Parses the request content into self.DATA and self.FILES. diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index ed6835fa8..e9bc25e45 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -160,6 +160,9 @@ class BaseSerializer(Field): for key in self.opts.exclude: ret.pop(key, None) + for key, field in ret.items(): + field.initialize(parent=self, field_name=key) + return ret ##### @@ -174,13 +177,6 @@ class BaseSerializer(Field): if parent.opts.depth: self.opts.depth = parent.opts.depth - 1 - # We need to call initialize here to ensure any nested - # serializers that will have already called initialize on their - # descendants get updated with *their* parent. - # We could be a bit more smart about this, but it'll do for now. - for key, field in self.fields.items(): - field.initialize(parent=self, field_name=key) - ##### # Methods to convert or revert from objects <--> primitive representations. @@ -507,25 +503,27 @@ class ModelSerializer(Serializer): if instance is not None: for key, val in attrs.items(): setattr(instance, key, val) - return instance - # Reverse relations - for (obj, model) in self.opts.model._meta.get_all_related_m2m_objects_with_model(): - field_name = obj.field.related_query_name() - if field_name in attrs: - self.m2m_data[field_name] = attrs.pop(field_name) + else: + # Reverse relations + for (obj, model) in self.opts.model._meta.get_all_related_m2m_objects_with_model(): + field_name = obj.field.related_query_name() + if field_name in attrs: + self.m2m_data[field_name] = attrs.pop(field_name) - # Forward relations - for field in self.opts.model._meta.many_to_many: - if field.name in attrs: - self.m2m_data[field.name] = attrs.pop(field.name) + # Forward relations + for field in self.opts.model._meta.many_to_many: + if field.name in attrs: + self.m2m_data[field.name] = attrs.pop(field.name) + + instance = self.opts.model(**attrs) - instance = self.opts.model(**attrs) try: instance.full_clean(exclude=self.get_validation_exclusions()) except ValidationError, err: self._errors = err.message_dict return None + return instance def save(self, save_m2m=True): diff --git a/rest_framework/tests/authentication.py b/rest_framework/tests/authentication.py index d498ae3e0..838e081bb 100644 --- a/rest_framework/tests/authentication.py +++ b/rest_framework/tests/authentication.py @@ -1,15 +1,13 @@ -from django.conf.urls.defaults import patterns from django.contrib.auth.models import User -from django.test import Client, TestCase - -from django.utils import simplejson as json from django.http import HttpResponse +from django.test import Client, TestCase +from django.utils import simplejson as json -from rest_framework.views import APIView from rest_framework import permissions - from rest_framework.authtoken.models import Token from rest_framework.authentication import TokenAuthentication +from rest_framework.compat import patterns +from rest_framework.views import APIView import base64 diff --git a/rest_framework/tests/breadcrumbs.py b/rest_framework/tests/breadcrumbs.py index 647ab96d0..df8916832 100644 --- a/rest_framework/tests/breadcrumbs.py +++ b/rest_framework/tests/breadcrumbs.py @@ -1,5 +1,5 @@ -from django.conf.urls.defaults import patterns, url from django.test import TestCase +from rest_framework.compat import patterns, url from rest_framework.utils.breadcrumbs import get_breadcrumbs from rest_framework.views import APIView diff --git a/rest_framework/tests/decorators.py b/rest_framework/tests/decorators.py index 41864d71e..8079c8cb3 100644 --- a/rest_framework/tests/decorators.py +++ b/rest_framework/tests/decorators.py @@ -1,7 +1,7 @@ from django.test import TestCase +from django.test.client import RequestFactory from rest_framework import status from rest_framework.response import Response -from django.test.client import RequestFactory from rest_framework.renderers import JSONRenderer from rest_framework.parsers import JSONParser from rest_framework.authentication import BasicAuthentication diff --git a/rest_framework/tests/generics.py b/rest_framework/tests/generics.py index a8279ef2b..7c24d84ea 100644 --- a/rest_framework/tests/generics.py +++ b/rest_framework/tests/generics.py @@ -1,3 +1,4 @@ +from django.db import models from django.test import TestCase from django.test.client import RequestFactory from django.utils import simplejson as json @@ -174,7 +175,7 @@ class TestInstanceView(TestCase): content = {'text': 'foobar'} request = factory.put('/1', json.dumps(content), content_type='application/json') - response = self.view(request, pk=1).render() + response = self.view(request, pk='1').render() self.assertEquals(response.status_code, status.HTTP_200_OK) self.assertEquals(response.data, {'id': 1, 'text': 'foobar'}) updated = self.objects.get(id=1) @@ -301,3 +302,36 @@ class TestCreateModelWithAutoNowAddField(TestCase): self.assertEquals(response.status_code, status.HTTP_201_CREATED) created = self.objects.get(id=1) self.assertEquals(created.content, 'foobar') + + +# Test for particularly ugly reression with m2m in browseable API +class ClassB(models.Model): + name = models.CharField(max_length=255) + + +class ClassA(models.Model): + name = models.CharField(max_length=255) + childs = models.ManyToManyField(ClassB, blank=True, null=True) + + +class ClassASerializer(serializers.ModelSerializer): + childs = serializers.ManyPrimaryKeyRelatedField(source='childs') + + class Meta: + model = ClassA + + +class ExampleView(generics.ListCreateAPIView): + serializer_class = ClassASerializer + model = ClassA + + +class TestM2MBrowseableAPI(TestCase): + def test_m2m_in_browseable_api(self): + """ + Test for particularly ugly reression with m2m in browseable API + """ + request = factory.get('/', HTTP_ACCEPT='text/html') + view = ExampleView().as_view() + response = view(request).render() + self.assertEquals(response.status_code, status.HTTP_200_OK) diff --git a/rest_framework/tests/htmlrenderer.py b/rest_framework/tests/htmlrenderer.py index 4caed59ee..54096206d 100644 --- a/rest_framework/tests/htmlrenderer.py +++ b/rest_framework/tests/htmlrenderer.py @@ -1,9 +1,9 @@ from django.core.exceptions import PermissionDenied -from django.conf.urls.defaults import patterns, url from django.http import Http404 from django.test import TestCase from django.template import TemplateDoesNotExist, Template import django.template.loader +from rest_framework.compat import patterns, url from rest_framework.decorators import api_view, renderer_classes from rest_framework.renderers import TemplateHTMLRenderer from rest_framework.response import Response diff --git a/rest_framework/tests/hyperlinkedserializers.py b/rest_framework/tests/hyperlinkedserializers.py index 24bf61bf8..ee4d8e577 100644 --- a/rest_framework/tests/hyperlinkedserializers.py +++ b/rest_framework/tests/hyperlinkedserializers.py @@ -1,8 +1,8 @@ -from django.conf.urls.defaults import patterns, url from django.test import TestCase from django.test.client import RequestFactory from django.utils import simplejson as json from rest_framework import generics, status, serializers +from rest_framework.compat import patterns, url from rest_framework.tests.models import Anchor, BasicModel, ManyToManyModel, BlogPost, BlogPostComment, Album, Photo, OptionalRelationModel factory = RequestFactory() diff --git a/rest_framework/tests/models.py b/rest_framework/tests/models.py index 5cc7eb9ae..69fd0b302 100644 --- a/rest_framework/tests/models.py +++ b/rest_framework/tests/models.py @@ -52,6 +52,11 @@ class RESTFrameworkModel(models.Model): abstract = True +class HasPositiveIntegerAsChoice(RESTFrameworkModel): + some_choices = ((1, 'A'), (2, 'B'), (3, 'C')) + some_integer = models.PositiveIntegerField(choices=some_choices) + + class Anchor(RESTFrameworkModel): text = models.CharField(max_length=100, default='anchor') @@ -161,7 +166,7 @@ class Photo(RESTFrameworkModel): # Model for issue #324 class BlankFieldModel(RESTFrameworkModel): - title = models.CharField(max_length=100, blank=True, null=True) + title = models.CharField(max_length=100, blank=True, null=False) # Model for issue #380 diff --git a/rest_framework/tests/relations_hyperlink.py b/rest_framework/tests/relations_hyperlink.py new file mode 100644 index 000000000..53ce00744 --- /dev/null +++ b/rest_framework/tests/relations_hyperlink.py @@ -0,0 +1,358 @@ +from django.db import models +from django.test import TestCase +from rest_framework import serializers +from rest_framework.compat import patterns, url + + +def dummy_view(request, pk): + pass + +urlpatterns = patterns('', + url(r'^manytomanysource/(?P[0-9]+)/$', dummy_view, name='manytomanysource-detail'), + url(r'^manytomanytarget/(?P[0-9]+)/$', dummy_view, name='manytomanytarget-detail'), + url(r'^foreignkeysource/(?P[0-9]+)/$', dummy_view, name='foreignkeysource-detail'), + url(r'^foreignkeytarget/(?P[0-9]+)/$', dummy_view, name='foreignkeytarget-detail'), + url(r'^nullableforeignkeysource/(?P[0-9]+)/$', dummy_view, name='nullableforeignkeysource-detail'), +) + + +# ManyToMany + +class ManyToManyTarget(models.Model): + name = models.CharField(max_length=100) + + +class ManyToManySource(models.Model): + name = models.CharField(max_length=100) + targets = models.ManyToManyField(ManyToManyTarget, related_name='sources') + + +class ManyToManyTargetSerializer(serializers.HyperlinkedModelSerializer): + sources = serializers.ManyHyperlinkedRelatedField(view_name='manytomanysource-detail') + + class Meta: + model = ManyToManyTarget + + +class ManyToManySourceSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = ManyToManySource + + +# ForeignKey + +class ForeignKeyTarget(models.Model): + name = models.CharField(max_length=100) + + +class ForeignKeySource(models.Model): + name = models.CharField(max_length=100) + target = models.ForeignKey(ForeignKeyTarget, related_name='sources') + + +class ForeignKeyTargetSerializer(serializers.HyperlinkedModelSerializer): + sources = serializers.ManyHyperlinkedRelatedField(view_name='foreignkeysource-detail', read_only=True) + + class Meta: + model = ForeignKeyTarget + + +class ForeignKeySourceSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = ForeignKeySource + + +# Nullable ForeignKey + +class NullableForeignKeySource(models.Model): + name = models.CharField(max_length=100) + target = models.ForeignKey(ForeignKeyTarget, null=True, blank=True, + related_name='nullable_sources') + + +class NullableForeignKeySourceSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = NullableForeignKeySource + + +# TODO: Add test that .data cannot be accessed prior to .is_valid + +class HyperlinkedManyToManyTests(TestCase): + urls = 'rest_framework.tests.relations_hyperlink' + + def setUp(self): + for idx in range(1, 4): + target = ManyToManyTarget(name='target-%d' % idx) + target.save() + source = ManyToManySource(name='source-%d' % idx) + source.save() + for target in ManyToManyTarget.objects.all(): + source.targets.add(target) + + def test_many_to_many_retrieve(self): + queryset = ManyToManySource.objects.all() + serializer = ManyToManySourceSerializer(queryset) + expected = [ + {'url': '/manytomanysource/1/', 'name': u'source-1', 'targets': ['/manytomanytarget/1/']}, + {'url': '/manytomanysource/2/', 'name': u'source-2', 'targets': ['/manytomanytarget/1/', '/manytomanytarget/2/']}, + {'url': '/manytomanysource/3/', 'name': u'source-3', 'targets': ['/manytomanytarget/1/', '/manytomanytarget/2/', '/manytomanytarget/3/']} + ] + self.assertEquals(serializer.data, expected) + + def test_reverse_many_to_many_retrieve(self): + queryset = ManyToManyTarget.objects.all() + serializer = ManyToManyTargetSerializer(queryset) + expected = [ + {'url': '/manytomanytarget/1/', 'name': u'target-1', 'sources': ['/manytomanysource/1/', '/manytomanysource/2/', '/manytomanysource/3/']}, + {'url': '/manytomanytarget/2/', 'name': u'target-2', 'sources': ['/manytomanysource/2/', '/manytomanysource/3/']}, + {'url': '/manytomanytarget/3/', 'name': u'target-3', 'sources': ['/manytomanysource/3/']} + ] + self.assertEquals(serializer.data, expected) + + def test_many_to_many_update(self): + data = {'url': '/manytomanysource/1/', 'name': u'source-1', 'targets': ['/manytomanytarget/1/', '/manytomanytarget/2/', '/manytomanytarget/3/']} + instance = ManyToManySource.objects.get(pk=1) + serializer = ManyToManySourceSerializer(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 = ManyToManySource.objects.all() + serializer = ManyToManySourceSerializer(queryset) + expected = [ + {'url': '/manytomanysource/1/', 'name': u'source-1', 'targets': ['/manytomanytarget/1/', '/manytomanytarget/2/', '/manytomanytarget/3/']}, + {'url': '/manytomanysource/2/', 'name': u'source-2', 'targets': ['/manytomanytarget/1/', '/manytomanytarget/2/']}, + {'url': '/manytomanysource/3/', 'name': u'source-3', 'targets': ['/manytomanytarget/1/', '/manytomanytarget/2/', '/manytomanytarget/3/']} + ] + self.assertEquals(serializer.data, expected) + + def test_reverse_many_to_many_update(self): + data = {'url': '/manytomanytarget/1/', 'name': u'target-1', 'sources': ['/manytomanysource/1/']} + instance = ManyToManyTarget.objects.get(pk=1) + serializer = ManyToManyTargetSerializer(instance, data=data) + self.assertTrue(serializer.is_valid()) + self.assertEquals(serializer.data, data) + serializer.save() + + # Ensure target 1 is updated, and everything else is as expected + queryset = ManyToManyTarget.objects.all() + serializer = ManyToManyTargetSerializer(queryset) + expected = [ + {'url': '/manytomanytarget/1/', 'name': u'target-1', 'sources': ['/manytomanysource/1/']}, + {'url': '/manytomanytarget/2/', 'name': u'target-2', 'sources': ['/manytomanysource/2/', '/manytomanysource/3/']}, + {'url': '/manytomanytarget/3/', 'name': u'target-3', 'sources': ['/manytomanysource/3/']} + + ] + self.assertEquals(serializer.data, expected) + + def test_many_to_many_create(self): + data = {'url': '/manytomanysource/4/', 'name': u'source-4', 'targets': ['/manytomanytarget/1/', '/manytomanytarget/3/']} + serializer = ManyToManySourceSerializer(data=data) + 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 = ManyToManySource.objects.all() + serializer = ManyToManySourceSerializer(queryset) + expected = [ + {'url': '/manytomanysource/1/', 'name': u'source-1', 'targets': ['/manytomanytarget/1/']}, + {'url': '/manytomanysource/2/', 'name': u'source-2', 'targets': ['/manytomanytarget/1/', '/manytomanytarget/2/']}, + {'url': '/manytomanysource/3/', 'name': u'source-3', 'targets': ['/manytomanytarget/1/', '/manytomanytarget/2/', '/manytomanytarget/3/']}, + {'url': '/manytomanysource/4/', 'name': u'source-4', 'targets': ['/manytomanytarget/1/', '/manytomanytarget/3/']} + ] + self.assertEquals(serializer.data, expected) + + def test_reverse_many_to_many_create(self): + data = {'url': '/manytomanytarget/4/', 'name': u'target-4', 'sources': ['/manytomanysource/1/', '/manytomanysource/3/']} + serializer = ManyToManyTargetSerializer(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 is added, and everything else is as expected + queryset = ManyToManyTarget.objects.all() + serializer = ManyToManyTargetSerializer(queryset) + expected = [ + {'url': '/manytomanytarget/1/', 'name': u'target-1', 'sources': ['/manytomanysource/1/', '/manytomanysource/2/', '/manytomanysource/3/']}, + {'url': '/manytomanytarget/2/', 'name': u'target-2', 'sources': ['/manytomanysource/2/', '/manytomanysource/3/']}, + {'url': '/manytomanytarget/3/', 'name': u'target-3', 'sources': ['/manytomanysource/3/']}, + {'url': '/manytomanytarget/4/', 'name': u'target-4', 'sources': ['/manytomanysource/1/', '/manytomanysource/3/']} + ] + self.assertEquals(serializer.data, expected) + + +class HyperlinkedForeignKeyTests(TestCase): + urls = 'rest_framework.tests.relations_hyperlink' + + 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 = [ + {'url': '/foreignkeysource/1/', 'name': u'source-1', 'target': '/foreignkeytarget/1/'}, + {'url': '/foreignkeysource/2/', 'name': u'source-2', 'target': '/foreignkeytarget/1/'}, + {'url': '/foreignkeysource/3/', 'name': u'source-3', 'target': '/foreignkeytarget/1/'} + ] + self.assertEquals(serializer.data, expected) + + def test_reverse_foreign_key_retrieve(self): + queryset = ForeignKeyTarget.objects.all() + serializer = ForeignKeyTargetSerializer(queryset) + 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(serializer.data, expected) + + def test_foreign_key_update(self): + data = {'url': '/foreignkeysource/1/', 'name': u'source-1', 'target': '/foreignkeytarget/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 = [ + {'url': '/foreignkeysource/1/', 'name': u'source-1', 'target': '/foreignkeytarget/2/'}, + {'url': '/foreignkeysource/2/', 'name': u'source-2', 'target': '/foreignkeytarget/1/'}, + {'url': '/foreignkeysource/3/', 'name': u'source-3', 'target': '/foreignkeytarget/1/'} + ] + self.assertEquals(serializer.data, expected) + + def test_foreign_key_update_with_invalid_null(self): + data = {'url': '/foreignkeysource/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 HyperlinkedNullableForeignKeyTests(TestCase): + urls = 'rest_framework.tests.relations_hyperlink' + + def setUp(self): + target = ForeignKeyTarget(name='target-1') + target.save() + for idx in range(1, 4): + source = NullableForeignKeySource(name='source-%d' % idx, target=target) + source.save() + + def test_foreign_key_create_with_valid_null(self): + data = {'url': '/nullableforeignkeysource/4/', 'name': u'source-4', 'target': None} + serializer = NullableForeignKeySourceSerializer(data=data) + self.assertTrue(serializer.is_valid()) + obj = serializer.save() + self.assertEquals(serializer.data, data) + self.assertEqual(obj.name, u'source-4') + + # Ensure source 4 is created, and everything else is as expected + queryset = NullableForeignKeySource.objects.all() + serializer = NullableForeignKeySourceSerializer(queryset) + expected = [ + {'url': '/nullableforeignkeysource/1/', 'name': u'source-1', 'target': '/foreignkeytarget/1/'}, + {'url': '/nullableforeignkeysource/2/', 'name': u'source-2', 'target': '/foreignkeytarget/1/'}, + {'url': '/nullableforeignkeysource/3/', 'name': u'source-3', 'target': '/foreignkeytarget/1/'}, + {'url': '/nullableforeignkeysource/4/', 'name': u'source-4', 'target': None} + ] + self.assertEquals(serializer.data, expected) + + def test_foreign_key_create_with_valid_emptystring(self): + """ + The emptystring should be interpreted as null in the context + of relationships. + """ + data = {'url': '/nullableforeignkeysource/4/', 'name': u'source-4', 'target': ''} + expected_data = {'url': '/nullableforeignkeysource/4/', 'name': u'source-4', 'target': None} + serializer = NullableForeignKeySourceSerializer(data=data) + self.assertTrue(serializer.is_valid()) + obj = serializer.save() + self.assertEquals(serializer.data, expected_data) + self.assertEqual(obj.name, u'source-4') + + # Ensure source 4 is created, and everything else is as expected + queryset = NullableForeignKeySource.objects.all() + serializer = NullableForeignKeySourceSerializer(queryset) + expected = [ + {'url': '/nullableforeignkeysource/1/', 'name': u'source-1', 'target': '/foreignkeytarget/1/'}, + {'url': '/nullableforeignkeysource/2/', 'name': u'source-2', 'target': '/foreignkeytarget/1/'}, + {'url': '/nullableforeignkeysource/3/', 'name': u'source-3', 'target': '/foreignkeytarget/1/'}, + {'url': '/nullableforeignkeysource/4/', 'name': u'source-4', 'target': None} + ] + self.assertEquals(serializer.data, expected) + + def test_foreign_key_update_with_valid_null(self): + data = {'url': '/nullableforeignkeysource/1/', 'name': u'source-1', 'target': None} + instance = NullableForeignKeySource.objects.get(pk=1) + 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 = NullableForeignKeySourceSerializer(queryset) + expected = [ + {'url': '/nullableforeignkeysource/1/', 'name': u'source-1', 'target': None}, + {'url': '/nullableforeignkeysource/2/', 'name': u'source-2', 'target': '/foreignkeytarget/1/'}, + {'url': '/nullableforeignkeysource/3/', 'name': u'source-3', 'target': '/foreignkeytarget/1/'}, + ] + self.assertEquals(serializer.data, expected) + + def test_foreign_key_update_with_valid_emptystring(self): + """ + The emptystring should be interpreted as null in the context + of relationships. + """ + data = {'url': '/nullableforeignkeysource/1/', 'name': u'source-1', 'target': ''} + expected_data = {'url': '/nullableforeignkeysource/1/', 'name': u'source-1', 'target': None} + instance = NullableForeignKeySource.objects.get(pk=1) + 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 = NullableForeignKeySourceSerializer(queryset) + expected = [ + {'url': '/nullableforeignkeysource/1/', 'name': u'source-1', 'target': None}, + {'url': '/nullableforeignkeysource/2/', 'name': u'source-2', 'target': '/foreignkeytarget/1/'}, + {'url': '/nullableforeignkeysource/3/', 'name': u'source-3', 'target': '/foreignkeytarget/1/'}, + ] + self.assertEquals(serializer.data, expected) + + # reverse foreign keys MUST be read_only + # In the general case they do not provide .remove() or .clear() + # and cannot be arbitrarily set. + + # def test_reverse_foreign_key_update(self): + # data = {'id': 1, 'name': u'target-1', 'sources': [1]} + # instance = ForeignKeyTarget.objects.get(pk=1) + # serializer = ForeignKeyTargetSerializer(instance, data=data) + # self.assertTrue(serializer.is_valid()) + # self.assertEquals(serializer.data, data) + # serializer.save() + + # # Ensure target 1 is updated, and everything else is as expected + # queryset = ForeignKeyTarget.objects.all() + # serializer = ForeignKeyTargetSerializer(queryset) + # expected = [ + # {'id': 1, 'name': u'target-1', 'sources': [1]}, + # {'id': 2, 'name': u'target-2', 'sources': []}, + # ] + # self.assertEquals(serializer.data, expected) diff --git a/rest_framework/tests/relations_nested.py b/rest_framework/tests/relations_nested.py new file mode 100644 index 000000000..3482c2522 --- /dev/null +++ b/rest_framework/tests/relations_nested.py @@ -0,0 +1,51 @@ +from django.db import models +from django.test import TestCase +from rest_framework import serializers + + +# ForeignKey + +class ForeignKeyTarget(models.Model): + name = models.CharField(max_length=100) + + +class ForeignKeySource(models.Model): + name = models.CharField(max_length=100) + target = models.ForeignKey(ForeignKeyTarget, related_name='sources') + + +class ForeignKeySourceSerializer(serializers.ModelSerializer): + class Meta: + model = ForeignKeySource + + +class ForeignKeyTargetSerializer(serializers.ModelSerializer): + sources = ForeignKeySourceSerializer() + + class Meta: + model = ForeignKeyTarget + + +class ReverseForeignKeyTests(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_reverse_foreign_key_retrieve(self): + queryset = ForeignKeyTarget.objects.all() + serializer = ForeignKeyTargetSerializer(queryset) + expected = [ + {'id': 1, 'name': u'target-1', 'sources': [ + {'id': 1, 'name': u'source-1', 'target': 1}, + {'id': 2, 'name': u'source-2', 'target': 1}, + {'id': 3, 'name': u'source-3', 'target': 1}, + ]}, + {'id': 2, 'name': u'target-2', 'sources': [ + ]} + ] + self.assertEquals(serializer.data, expected) diff --git a/rest_framework/tests/pk_relations.py b/rest_framework/tests/relations_pk.py similarity index 100% rename from rest_framework/tests/pk_relations.py rename to rest_framework/tests/relations_pk.py diff --git a/rest_framework/tests/renderers.py b/rest_framework/tests/renderers.py index 9be4b1146..c1b4e624b 100644 --- a/rest_framework/tests/renderers.py +++ b/rest_framework/tests/renderers.py @@ -1,13 +1,12 @@ import pickle import re -from django.conf.urls.defaults import patterns, url, include from django.core.cache import cache from django.test import TestCase from django.test.client import RequestFactory from rest_framework import status, permissions -from rest_framework.compat import yaml +from rest_framework.compat import yaml, patterns, url, include from rest_framework.response import Response from rest_framework.views import APIView from rest_framework.renderers import BaseRenderer, JSONRenderer, YAMLRenderer, \ @@ -444,19 +443,19 @@ class CacheRenderTest(TestCase): return if state == None: return - if isinstance(state,tuple): - if not isinstance(state[0],dict): - state=state[1] + if isinstance(state, tuple): + if not isinstance(state[0], dict): + state = state[1] else: - state=state[0].update(state[1]) + state = state[0].update(state[1]) result = {} for i in state: try: - pickle.dumps(state[i],protocol=2) + pickle.dumps(state[i], protocol=2) except pickle.PicklingError: if not state[i] in seen: seen.append(state[i]) - result[i] = cls._get_pickling_errors(state[i],seen) + result[i] = cls._get_pickling_errors(state[i], seen) return result def http_resp(self, http_method, url): diff --git a/rest_framework/tests/request.py b/rest_framework/tests/request.py index 2850992d2..1f05ff8ff 100644 --- a/rest_framework/tests/request.py +++ b/rest_framework/tests/request.py @@ -1,16 +1,15 @@ """ Tests for content parsing, and form-overloaded content parsing. """ -from django.conf.urls.defaults import patterns from django.contrib.auth.models import User from django.contrib.auth import authenticate, login, logout from django.contrib.sessions.middleware import SessionMiddleware from django.test import TestCase, Client +from django.test.client import RequestFactory from django.utils import simplejson as json - from rest_framework import status from rest_framework.authentication import SessionAuthentication -from django.test.client import RequestFactory +from rest_framework.compat import patterns from rest_framework.parsers import ( BaseParser, FormParser, @@ -304,3 +303,11 @@ class TestUserSetter(TestCase): self.assertFalse(self.request.user.is_anonymous()) logout(self.request) self.assertTrue(self.request.user.is_anonymous()) + + +class TestAuthSetter(TestCase): + + def test_auth_can_be_set(self): + request = Request(factory.get('/')) + request.auth = 'DUMMY' + self.assertEqual(request.auth, 'DUMMY') diff --git a/rest_framework/tests/response.py b/rest_framework/tests/response.py index d7b75450c..875f4d422 100644 --- a/rest_framework/tests/response.py +++ b/rest_framework/tests/response.py @@ -1,8 +1,5 @@ -import unittest - -from django.conf.urls.defaults import patterns, url, include from django.test import TestCase - +from rest_framework.compat import patterns, url, include from rest_framework.response import Response from rest_framework.views import APIView from rest_framework import status diff --git a/rest_framework/tests/reverse.py b/rest_framework/tests/reverse.py index fd9a7d64e..8c86e1fbe 100644 --- a/rest_framework/tests/reverse.py +++ b/rest_framework/tests/reverse.py @@ -1,6 +1,6 @@ -from django.conf.urls.defaults import patterns, url from django.test import TestCase from django.test.client import RequestFactory +from rest_framework.compat import patterns, url from rest_framework.reverse import reverse factory = RequestFactory() diff --git a/rest_framework/tests/serializer.py b/rest_framework/tests/serializer.py index 4cfb0aae8..c2a41f881 100644 --- a/rest_framework/tests/serializer.py +++ b/rest_framework/tests/serializer.py @@ -2,7 +2,7 @@ import datetime import pickle from django.test import TestCase from rest_framework import serializers, fields -from rest_framework.tests.models import (Album, ActionItem, Anchor, BasicModel, +from rest_framework.tests.models import (HasPositiveIntegerAsChoice, Album, ActionItem, Anchor, BasicModel, BlankFieldModel, BlogPost, Book, CallableDefaultValueModel, DefaultValueModel, ManyToManyModel, Person, ReadOnlyManyToManyModel, Photo) @@ -69,6 +69,11 @@ class AlbumsSerializer(serializers.ModelSerializer): model = Album fields = ['title'] # lists are also valid options +class PositiveIntegerAsChoiceSerializer(serializers.ModelSerializer): + class Meta: + model = HasPositiveIntegerAsChoice + fields = ['some_integer'] + class BasicTests(TestCase): def setUp(self): @@ -285,6 +290,12 @@ class ValidationTests(TestCase): self.assertEquals(serializer.errors, {'info': [u'Ensure this value has at most 12 characters (it has 13).']}) +class PositiveIntegerAsChoiceTests(TestCase): + def test_positive_integer_in_json_is_correctly_parsed(self): + data = {'some_integer':1} + serializer = PositiveIntegerAsChoiceSerializer(data=data) + self.assertEquals(serializer.is_valid(), True) + class ModelValidationTests(TestCase): def test_validate_unique(self): """ @@ -688,6 +699,10 @@ class BlankFieldTests(TestCase): serializer = self.model_serializer_class(data=self.data) self.assertEquals(serializer.is_valid(), True) + def test_create_model_null_field(self): + serializer = self.model_serializer_class(data={'title': None}) + self.assertEquals(serializer.is_valid(), True) + def test_create_not_blank_field(self): """ Test to ensure blank data in a field not marked as blank=True @@ -817,6 +832,7 @@ class NestedSerializerContextTests(TestCase): # This will raise RuntimeError if context doesn't get passed correctly to the nested Serializers AlbumCollectionSerializer(album_collection, context={'context_item': 'album context'}).data + # Test for issue #467 class FieldLabelTest(TestCase): def setUp(self): diff --git a/rest_framework/tests/testcases.py b/rest_framework/tests/testcases.py index c90224aa6..97f492ff4 100644 --- a/rest_framework/tests/testcases.py +++ b/rest_framework/tests/testcases.py @@ -6,6 +6,7 @@ from django.test import TestCase NO_SETTING = ('!', None) + class TestSettingsManager(object): """ A class which can modify some Django settings temporarily for a @@ -19,7 +20,7 @@ class TestSettingsManager(object): self._original_settings = {} def set(self, **kwargs): - for k,v in kwargs.iteritems(): + for k, v in kwargs.iteritems(): self._original_settings.setdefault(k, getattr(settings, k, NO_SETTING)) setattr(settings, k, v) @@ -31,7 +32,7 @@ class TestSettingsManager(object): call_command('syncdb', verbosity=0) def revert(self): - for k,v in self._original_settings.iteritems(): + for k, v in self._original_settings.iteritems(): if v == NO_SETTING: delattr(settings, k) else: @@ -57,6 +58,7 @@ class SettingsTestCase(TestCase): def tearDown(self): self.settings_manager.revert() + class TestModelsTestCase(SettingsTestCase): def setUp(self, *args, **kwargs): installed_apps = tuple(settings.INSTALLED_APPS) + ('rest_framework.tests',) diff --git a/rest_framework/urls.py b/rest_framework/urls.py index bcdc23e74..fbe4bc07e 100644 --- a/rest_framework/urls.py +++ b/rest_framework/urls.py @@ -12,7 +12,7 @@ your authentication settings include `SessionAuthentication`. url(r'^auth', include('rest_framework.urls', namespace='rest_framework')) ) """ -from django.conf.urls.defaults import patterns, url +from rest_framework.compat import patterns, url template_name = {'template_name': 'rest_framework/login.html'} diff --git a/tox.ini b/tox.ini index 69eb38237..22c85e493 100644 --- a/tox.ini +++ b/tox.ini @@ -12,12 +12,12 @@ deps = https://github.com/django/django/zipball/master [testenv:py2.7-django1.4] basepython = python2.7 -deps = django==1.4.1 +deps = django==1.4.3 django-filter==0.5.4 [testenv:py2.7-django1.3] basepython = python2.7 -deps = django==1.3.3 +deps = django==1.3.5 django-filter==0.5.4 [testenv:py2.6-django1.5] @@ -27,10 +27,10 @@ deps = https://github.com/django/django/zipball/master [testenv:py2.6-django1.4] basepython = python2.6 -deps = django==1.4.1 +deps = django==1.4.3 django-filter==0.5.4 [testenv:py2.6-django1.3] basepython = python2.6 -deps = django==1.3.3 +deps = django==1.3.5 django-filter==0.5.4