From 9ae0ca1caeb7d195719b9544da2a3a7c4fc85b26 Mon Sep 17 00:00:00 2001 From: "Michal Dvorak (cen38289)" Date: Mon, 3 Dec 2012 17:26:01 +0100 Subject: [PATCH 001/197] #467 Added label and help_text to Field --- rest_framework/fields.py | 23 +++++++++++++---------- rest_framework/serializers.py | 6 ++++++ 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 482a3d485..907bab749 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -28,7 +28,7 @@ def is_simple_callable(obj): return ( (inspect.isfunction(obj) and not inspect.getargspec(obj)[0]) or (inspect.ismethod(obj) and len(inspect.getargspec(obj)[0]) <= 1) - ) + ) class Field(object): @@ -38,13 +38,15 @@ class Field(object): _use_files = None form_field_class = forms.CharField - def __init__(self, source=None): + def __init__(self, source=None, label=None, help_text=None): self.parent = None self.creation_counter = Field.creation_counter Field.creation_counter += 1 self.source = source + self.label = label + self.help_text = help_text def initialize(self, parent, field_name): """ @@ -123,11 +125,11 @@ class WritableField(Field): widget = widgets.TextInput default = None - def __init__(self, source=None, read_only=False, required=None, + def __init__(self, source=None, label=None, help_text=None, + read_only=False, required=None, validators=[], error_messages=None, widget=None, default=None, blank=None): - - super(WritableField, self).__init__(source=source) + super(WritableField, self).__init__(source=source, label=label, help_text=help_text) self.read_only = read_only if required is None: @@ -215,6 +217,7 @@ class ModelField(WritableField): """ A generic field that can be used against an arbitrary model field. """ + def __init__(self, *args, **kwargs): try: self.model_field = kwargs.pop('model_field') @@ -222,9 +225,9 @@ class ModelField(WritableField): raise ValueError("ModelField requires 'model_field' kwarg") self.min_length = kwargs.pop('min_length', - getattr(self.model_field, 'min_length', None)) + getattr(self.model_field, 'min_length', None)) self.max_length = kwargs.pop('max_length', - getattr(self.model_field, 'max_length', None)) + getattr(self.model_field, 'max_length', None)) super(ModelField, self).__init__(*args, **kwargs) @@ -434,7 +437,7 @@ class PrimaryKeyRelatedField(RelatedField): # RelatedObject (reverse relationship) obj = getattr(obj, self.source or field_name) return self.to_native(obj.pk) - # Forward relationship + # Forward relationship return self.to_native(pk) @@ -469,7 +472,7 @@ class ManyPrimaryKeyRelatedField(ManyRelatedField): # RelatedManager (reverse relationship) queryset = getattr(obj, self.source or field_name) return [self.to_native(item.pk) for item in queryset.all()] - # Forward relationship + # Forward relationship return [self.to_native(item.pk) for item in queryset.all()] def from_native(self, data): @@ -913,7 +916,7 @@ class DateTimeField(WritableField): # call stack. warnings.warn(u"DateTimeField received a naive datetime (%s)" u" while time zone support is active." % value, - RuntimeWarning) + RuntimeWarning) default_timezone = timezone.get_default_timezone() value = timezone.make_aware(value, default_timezone) return value diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index 4519ab053..2dab79149 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -428,6 +428,12 @@ class ModelSerializer(Serializer): if max_length: kwargs['max_length'] = max_length + if model_field.verbose_name: + kwargs['label'] = model_field.verbose_name + + if model_field.help_text: + kwargs['help_text'] = model_field.help_text + field_mapping = { models.FloatField: FloatField, models.IntegerField: IntegerField, From ad01fa0eae990ca1607d44cbabba5425c9d0b9a4 Mon Sep 17 00:00:00 2001 From: Michal Dvorak Date: Mon, 3 Dec 2012 19:07:07 +0100 Subject: [PATCH 002/197] #467 Added unit test --- rest_framework/serializers.py | 8 ++++---- rest_framework/tests/models.py | 3 ++- rest_framework/tests/serializer.py | 27 +++++++++++++++++++++++++-- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index 2dab79149..e4fcbd670 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -428,11 +428,11 @@ class ModelSerializer(Serializer): if max_length: kwargs['max_length'] = max_length - if model_field.verbose_name: - kwargs['label'] = model_field.verbose_name + if model_field.verbose_name is not None: + kwargs['label'] = smart_unicode(model_field.verbose_name) - if model_field.help_text: - kwargs['help_text'] = model_field.help_text + if model_field.help_text is not None: + kwargs['help_text'] = smart_unicode(model_field.help_text) field_mapping = { models.FloatField: FloatField, diff --git a/rest_framework/tests/models.py b/rest_framework/tests/models.py index c35861c6c..a13f1ef34 100644 --- a/rest_framework/tests/models.py +++ b/rest_framework/tests/models.py @@ -1,6 +1,7 @@ from django.db import models from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.generic import GenericForeignKey, GenericRelation +from django.utils.translation import ugettext_lazy as _ # from django.contrib.auth.models import Group @@ -56,7 +57,7 @@ class Anchor(RESTFrameworkModel): class BasicModel(RESTFrameworkModel): - text = models.CharField(max_length=100) + text = models.CharField(max_length=100, verbose_name=_("Text"), help_text=_("Text description.")) class SlugBasedModel(RESTFrameworkModel): diff --git a/rest_framework/tests/serializer.py b/rest_framework/tests/serializer.py index 61a05da18..cc83a7402 100644 --- a/rest_framework/tests/serializer.py +++ b/rest_framework/tests/serializer.py @@ -1,6 +1,6 @@ import datetime from django.test import TestCase -from rest_framework import serializers +from rest_framework import serializers, fields from rest_framework.tests.models import (ActionItem, Anchor, BasicModel, BlankFieldModel, BlogPost, Book, CallableDefaultValueModel, DefaultValueModel, ManyToManyModel, Person, ReadOnlyManyToManyModel) @@ -48,7 +48,7 @@ class BookSerializer(serializers.ModelSerializer): class ActionItemSerializer(serializers.ModelSerializer): - + class Meta: model = ActionItem @@ -641,3 +641,26 @@ class BlankFieldTests(TestCase): """ serializer = self.not_blank_model_serializer_class(data=self.data) self.assertEquals(serializer.is_valid(), False) + + +# Test for issue #467 +class FieldLabelTest(TestCase): + def setUp(self): + class LabelModelSerializer(serializers.ModelSerializer): + # This is check that ctor supports both fields + additional = fields.CharField(label='Label', help_text='Help') + + class Meta: + model = BasicModel + + self.serializer_class = LabelModelSerializer + + def test_label_from_model(self): + """ + Validates that label and help_text are correctly copied from the model class. + """ + serializer = self.serializer_class() + text_field = serializer.fields['text'] + + self.assertEquals('Text', text_field.label) + self.assertEquals('Text description.', text_field.help_text) From dea0f9129c770b6a9ccebce7296235b529fa59e7 Mon Sep 17 00:00:00 2001 From: Michal Dvorak Date: Mon, 3 Dec 2012 19:10:57 +0100 Subject: [PATCH 003/197] Fixed screwed formatting --- rest_framework/fields.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 907bab749..74b4cb7c2 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -28,7 +28,7 @@ def is_simple_callable(obj): return ( (inspect.isfunction(obj) and not inspect.getargspec(obj)[0]) or (inspect.ismethod(obj) and len(inspect.getargspec(obj)[0]) <= 1) - ) + ) class Field(object): @@ -217,7 +217,6 @@ class ModelField(WritableField): """ A generic field that can be used against an arbitrary model field. """ - def __init__(self, *args, **kwargs): try: self.model_field = kwargs.pop('model_field') @@ -225,9 +224,9 @@ class ModelField(WritableField): raise ValueError("ModelField requires 'model_field' kwarg") self.min_length = kwargs.pop('min_length', - getattr(self.model_field, 'min_length', None)) + getattr(self.model_field, 'min_length', None)) self.max_length = kwargs.pop('max_length', - getattr(self.model_field, 'max_length', None)) + getattr(self.model_field, 'max_length', None)) super(ModelField, self).__init__(*args, **kwargs) @@ -437,7 +436,7 @@ class PrimaryKeyRelatedField(RelatedField): # RelatedObject (reverse relationship) obj = getattr(obj, self.source or field_name) return self.to_native(obj.pk) - # Forward relationship + # Forward relationship return self.to_native(pk) @@ -472,7 +471,7 @@ class ManyPrimaryKeyRelatedField(ManyRelatedField): # RelatedManager (reverse relationship) queryset = getattr(obj, self.source or field_name) return [self.to_native(item.pk) for item in queryset.all()] - # Forward relationship + # Forward relationship return [self.to_native(item.pk) for item in queryset.all()] def from_native(self, data): @@ -916,7 +915,7 @@ class DateTimeField(WritableField): # call stack. warnings.warn(u"DateTimeField received a naive datetime (%s)" u" while time zone support is active." % value, - RuntimeWarning) + RuntimeWarning) default_timezone = timezone.get_default_timezone() value = timezone.make_aware(value, default_timezone) return value From a7849157bcfb8eb07b0ac934ae7c49a965bf6875 Mon Sep 17 00:00:00 2001 From: "Michal Dvorak (cen38289)" Date: Tue, 4 Dec 2012 10:00:14 +0100 Subject: [PATCH 004/197] Moved ctor test to separate unit test --- rest_framework/tests/serializer.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/rest_framework/tests/serializer.py b/rest_framework/tests/serializer.py index cc83a7402..76c9c465a 100644 --- a/rest_framework/tests/serializer.py +++ b/rest_framework/tests/serializer.py @@ -647,9 +647,6 @@ class BlankFieldTests(TestCase): class FieldLabelTest(TestCase): def setUp(self): class LabelModelSerializer(serializers.ModelSerializer): - # This is check that ctor supports both fields - additional = fields.CharField(label='Label', help_text='Help') - class Meta: model = BasicModel @@ -664,3 +661,11 @@ class FieldLabelTest(TestCase): self.assertEquals('Text', text_field.label) self.assertEquals('Text description.', text_field.help_text) + + def test_field_ctor(self): + """ + This is check that ctor supports both label and help_text. + """ + fields.Field(label='Label', help_text='Help') + fields.CharField(label='Label', help_text='Help') + fields.ManyHyperlinkedRelatedField(view_name='fake', label='Label', help_text='Help') From 2a82b64963792b353a7a2634c003692bd4957c9f Mon Sep 17 00:00:00 2001 From: "Michal Dvorak (cen38289)" Date: Tue, 4 Dec 2012 14:16:45 +0100 Subject: [PATCH 005/197] Moved smart_unicode to Field ctor, to mimic Django Forms behavior. --- rest_framework/fields.py | 8 ++++++-- rest_framework/serializers.py | 4 ++-- rest_framework/tests/serializer.py | 10 +++++----- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 74b4cb7c2..f57dc57ff 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -45,8 +45,12 @@ class Field(object): Field.creation_counter += 1 self.source = source - self.label = label - self.help_text = help_text + + if label is not None: + self.label = smart_unicode(label) + + if help_text is not None: + self.help_text = smart_unicode(help_text) def initialize(self, parent, field_name): """ diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index e4fcbd670..37496be35 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -429,10 +429,10 @@ class ModelSerializer(Serializer): kwargs['max_length'] = max_length if model_field.verbose_name is not None: - kwargs['label'] = smart_unicode(model_field.verbose_name) + kwargs['label'] = model_field.verbose_name if model_field.help_text is not None: - kwargs['help_text'] = smart_unicode(model_field.help_text) + kwargs['help_text'] = model_field.help_text field_mapping = { models.FloatField: FloatField, diff --git a/rest_framework/tests/serializer.py b/rest_framework/tests/serializer.py index 76c9c465a..44adf92eb 100644 --- a/rest_framework/tests/serializer.py +++ b/rest_framework/tests/serializer.py @@ -659,13 +659,13 @@ class FieldLabelTest(TestCase): serializer = self.serializer_class() text_field = serializer.fields['text'] - self.assertEquals('Text', text_field.label) - self.assertEquals('Text description.', text_field.help_text) + self.assertEquals(u'Text', text_field.label) + self.assertEquals(u'Text description.', text_field.help_text) def test_field_ctor(self): """ This is check that ctor supports both label and help_text. """ - fields.Field(label='Label', help_text='Help') - fields.CharField(label='Label', help_text='Help') - fields.ManyHyperlinkedRelatedField(view_name='fake', label='Label', help_text='Help') + self.assertEquals(u'Label', fields.Field(label='Label', help_text='Help').label) + self.assertEquals(u'Help', fields.CharField(label='Label', help_text='Help').help_text) + self.assertEquals(u'Label', fields.ManyHyperlinkedRelatedField(view_name='fake', label='Label', help_text='Help').label) From 84be169353f0dd2ceb06fe459b72aa2452fcbeb5 Mon Sep 17 00:00:00 2001 From: Craig de Stigter Date: Fri, 1 Mar 2013 16:13:04 +1300 Subject: [PATCH 006/197] fix function names and dotted lookups for use in PrimaryKeyRelatedField.field_to_native (they work in RelatedField.field_to_native already) --- rest_framework/relations.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/rest_framework/relations.py b/rest_framework/relations.py index 0c108717f..ef465b3c0 100644 --- a/rest_framework/relations.py +++ b/rest_framework/relations.py @@ -215,12 +215,20 @@ class PrimaryKeyRelatedField(RelatedField): def field_to_native(self, obj, field_name): if self.many: # To-many relationship - try: + + queryset = None + if not self.source: # Prefer obj.serializable_value for performance reasons - queryset = obj.serializable_value(self.source or field_name) - except AttributeError: + try: + queryset = obj.serializable_value(field_name) + except AttributeError: + pass + if queryset is None: # RelatedManager (reverse relationship) - queryset = getattr(obj, self.source or field_name) + source = self.source or field_name + queryset = obj + for component in source.split('.'): + queryset = get_component(queryset, component) # Forward relationship return [self.to_native(item.pk) for item in queryset.all()] From 0081d744b9f530b2418d1e82d7ad94a2ebc31c9c Mon Sep 17 00:00:00 2001 From: Matteo Suppo Date: Sat, 23 Mar 2013 14:18:11 +0100 Subject: [PATCH 007/197] Added tests for issue 747 in serializer.py --- rest_framework/tests/serializer.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/rest_framework/tests/serializer.py b/rest_framework/tests/serializer.py index 05217f35a..0386ca76e 100644 --- a/rest_framework/tests/serializer.py +++ b/rest_framework/tests/serializer.py @@ -1082,3 +1082,32 @@ class DeserializeListTestCase(TestCase): self.assertFalse(serializer.is_valid()) expected = [{}, {'email': ['This field is required.']}, {}] self.assertEqual(serializer.errors, expected) + + +# test for issue 747 + +class LazyStringModel(object): + def __init__(self, lazystring): + self.lazystring = lazystring + + +class LazyStringSerializer(serializers.Serializer): + lazystring = serializers.Field() + + def restore_object(self, attrs, instance=None): + if instance is not None: + instance.lazystring = attrs.get('lazystring', instance.lazystring) + return instance + return Comment(**attrs) + + +class LazyStringsTestCase(TestCase): + + def setUp(self): + from django.utils.translation import ugettext_lazy as _ + + self.model = LazyStringModel(lazystring=_("lazystring")) + + def test_lazy_strings_are_translated(self): + serializer = LazyStringSerializer(self.model) + self.assertEqual(type(serializer.data['lazystring']), type("lazystring")) From b5640bb77843c50f42a649982b9b9592113c6f59 Mon Sep 17 00:00:00 2001 From: Matteo Suppo Date: Sat, 23 Mar 2013 14:18:55 +0100 Subject: [PATCH 008/197] Forcing translations of lazy translatable strings in Field to_native method --- rest_framework/fields.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rest_framework/fields.py b/rest_framework/fields.py index f3496b53e..09f076ab7 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -18,7 +18,7 @@ from rest_framework import ISO_8601 from rest_framework.compat import timezone, parse_date, parse_datetime, parse_time from rest_framework.compat import BytesIO from rest_framework.compat import six -from rest_framework.compat import smart_text +from rest_framework.compat import smart_text, force_text from rest_framework.settings import api_settings @@ -165,7 +165,7 @@ class Field(object): return [self.to_native(item) for item in value] elif isinstance(value, dict): return dict(map(self.to_native, (k, v)) for k, v in value.items()) - return smart_text(value) + return force_text(value) def attributes(self): """ From 3737e17d7c29fbc958d6e56c29a641dd6ec26af8 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 9 May 2013 13:10:45 +0100 Subject: [PATCH 009/197] Added 'Customizing the generic views' section. Closes #816 --- docs/api-guide/generic-views.md | 120 +++++++++++++++++++++----------- 1 file changed, 78 insertions(+), 42 deletions(-) diff --git a/docs/api-guide/generic-views.md b/docs/api-guide/generic-views.md index a30bfb21d..80df2f400 100755 --- a/docs/api-guide/generic-views.md +++ b/docs/api-guide/generic-views.md @@ -164,6 +164,52 @@ You won't typically need to override the following methods, although you might n --- +# Mixins + +The mixin classes provide the actions that are used to provide the basic view behavior. Note that the mixin classes provide action methods rather than defining the handler methods such as `.get()` and `.post()` directly. This allows for more flexible composition of behavior. + +## ListModelMixin + +Provides a `.list(request, *args, **kwargs)` method, that implements listing a queryset. + +If the queryset is populated, this returns a `200 OK` response, with a serialized representation of the queryset as the body of the response. The response data may optionally be paginated. + +If the queryset is empty this returns a `200 OK` response, unless the `.allow_empty` attribute on the view is set to `False`, in which case it will return a `404 Not Found`. + +## CreateModelMixin + +Provides a `.create(request, *args, **kwargs)` method, that implements creating and saving a new model instance. + +If an object is created this returns a `201 Created` response, with a serialized representation of the object as the body of the response. If the representation contains a key named `url`, then the `Location` header of the response will be populated with that value. + +If the request data provided for creating the object was invalid, a `400 Bad Request` response will be returned, with the error details as the body of the response. + +## RetrieveModelMixin + +Provides a `.retrieve(request, *args, **kwargs)` method, that implements returning an existing model instance in a response. + +If an object can be retrieved this returns a `200 OK` response, with a serialized representation of the object as the body of the response. Otherwise it will return a `404 Not Found`. + +## UpdateModelMixin + +Provides a `.update(request, *args, **kwargs)` method, that implements updating and saving an existing model instance. + +Also provides a `.partial_update(request, *args, **kwargs)` method, which is similar to the `update` method, except that all fields for the update will be optional. This allows support for HTTP `PATCH` requests. + +If an object is updated this returns a `200 OK` response, with a serialized representation of the object as the body of the response. + +If an object is created, for example when making a `DELETE` request followed by a `PUT` request to the same URL, this returns a `201 Created` response, with a serialized representation of the object as the body of the response. + +If the request data provided for updating the object was invalid, a `400 Bad Request` response will be returned, with the error details as the body of the response. + +## DestroyModelMixin + +Provides a `.destroy(request, *args, **kwargs)` method, that implements deletion of an existing model instance. + +If an object is deleted this returns a `204 No Content` response, otherwise it will return a `404 Not Found`. + +--- + # Concrete View Classes The following classes are the concrete generic views. If you're using generic views this is normally the level you'll be working at unless you need heavily customized behavior. @@ -242,59 +288,49 @@ Extends: [GenericAPIView], [RetrieveModelMixin], [UpdateModelMixin], [DestroyMod --- -# Mixins +# Customizing the generic views -The mixin classes provide the actions that are used to provide the basic view behavior. Note that the mixin classes provide action methods rather than defining the handler methods such as `.get()` and `.post()` directly. This allows for more flexible composition of behavior. +Often you'll want to use the existing generic views, but use some slightly customized behavior. If you find yourself reusing some bit of customized behavior in multiple places, you might want to refactor the behavior into a mixin class that you can then just apply to any view or viewset as needed. -## ListModelMixin +## Creating custom mixins -Provides a `.list(request, *args, **kwargs)` method, that implements listing a queryset. +For example, if you need to lookup objects based on multiple fields in the URL conf, you could create a mixin class like the following: -If the queryset is populated, this returns a `200 OK` response, with a serialized representation of the queryset as the body of the response. The response data may optionally be paginated. + class MultipleFieldLookupMixin(object): + """ + Apply this mixin to any view or viewset to get multiple field filtering + based on a `lookup_fields` attribute, instead of the default single field filtering. + """ + def get_object(self): + queryset = self.get_queryset() # Get the base queryset + queryset = self.filter_queryset(queryset) # Apply any filter backends + filter = {} + for field in self.lookup_fields: + filter[field] = self.kwargs[field] + return get_object_or_404(queryset, **filter) # Lookup the object -If the queryset is empty this returns a `200 OK` response, unless the `.allow_empty` attribute on the view is set to `False`, in which case it will return a `404 Not Found`. +You can then simply apply this mixin to a view or viewset anytime you need to apply the custom behavior. -Should be mixed in with [MultipleObjectAPIView]. + class RetrieveUserView(MultipleFieldLookupMixin, generics.RetrieveAPIView): + queryset = User.objects.all() + serializer_class = UserSerializer + lookup_fields = ('account', 'username') -## CreateModelMixin +Using custom mixins is a good option if you have custom behavior that needs to be used -Provides a `.create(request, *args, **kwargs)` method, that implements creating and saving a new model instance. +## Creating custom base classes -If an object is created this returns a `201 Created` response, with a serialized representation of the object as the body of the response. If the representation contains a key named `url`, then the `Location` header of the response will be populated with that value. +If you are using a mixin across multiple views, you can take this a step further and create your own set of base views that can then be used throughout your project. For example: -If the request data provided for creating the object was invalid, a `400 Bad Request` response will be returned, with the error details as the body of the response. + class BaseRetrieveView(MultipleFieldLookupMixin, + generics.RetrieveAPIView): + pass + + class BaseRetrieveUpdateDestroyView(MultipleFieldLookupMixin, + generics.RetrieveUpdateDestroyAPIView): + pass -Should be mixed in with any [GenericAPIView]. - -## RetrieveModelMixin - -Provides a `.retrieve(request, *args, **kwargs)` method, that implements returning an existing model instance in a response. - -If an object can be retrieved this returns a `200 OK` response, with a serialized representation of the object as the body of the response. Otherwise it will return a `404 Not Found`. - -Should be mixed in with [SingleObjectAPIView]. - -## UpdateModelMixin - -Provides a `.update(request, *args, **kwargs)` method, that implements updating and saving an existing model instance. - -Also provides a `.partial_update(request, *args, **kwargs)` method, which is similar to the `update` method, except that all fields for the update will be optional. This allows support for HTTP `PATCH` requests. - -If an object is updated this returns a `200 OK` response, with a serialized representation of the object as the body of the response. - -If an object is created, for example when making a `DELETE` request followed by a `PUT` request to the same URL, this returns a `201 Created` response, with a serialized representation of the object as the body of the response. - -If the request data provided for updating the object was invalid, a `400 Bad Request` response will be returned, with the error details as the body of the response. - -Should be mixed in with [SingleObjectAPIView]. - -## DestroyModelMixin - -Provides a `.destroy(request, *args, **kwargs)` method, that implements deletion of an existing model instance. - -If an object is deleted this returns a `204 No Content` response, otherwise it will return a `404 Not Found`. - -Should be mixed in with [SingleObjectAPIView]. +Using custom base classes is a good option if you have custom behavior that consistently needs to be repeated across a large number of views throughout your project. [cite]: https://docs.djangoproject.com/en/dev/ref/class-based-views/#base-vs-generic-views From f2466418dd325ed1353d4e0056411c16e96c2073 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 9 May 2013 13:14:20 +0100 Subject: [PATCH 010/197] Tweak doc text slightly --- docs/api-guide/generic-views.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api-guide/generic-views.md b/docs/api-guide/generic-views.md index 80df2f400..3651dc40c 100755 --- a/docs/api-guide/generic-views.md +++ b/docs/api-guide/generic-views.md @@ -290,7 +290,7 @@ Extends: [GenericAPIView], [RetrieveModelMixin], [UpdateModelMixin], [DestroyMod # Customizing the generic views -Often you'll want to use the existing generic views, but use some slightly customized behavior. If you find yourself reusing some bit of customized behavior in multiple places, you might want to refactor the behavior into a mixin class that you can then just apply to any view or viewset as needed. +Often you'll want to use the existing generic views, but use some slightly customized behavior. If you find yourself reusing some bit of customized behavior in multiple places, you might want to refactor the behavior into a common class that you can then just apply to any view or viewset as needed. ## Creating custom mixins From 31f94ab409f1d5f41982a5946b980cf3ad8e3ba9 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 9 May 2013 13:31:42 +0100 Subject: [PATCH 011/197] Added GenericViewSet and docs tweaking --- docs/api-guide/viewsets.md | 25 ++++++++++++++++--------- rest_framework/viewsets.py | 9 +++++++++ 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/docs/api-guide/viewsets.md b/docs/api-guide/viewsets.md index d98f37d84..e354a43ad 100644 --- a/docs/api-guide/viewsets.md +++ b/docs/api-guide/viewsets.md @@ -136,9 +136,15 @@ The `ViewSet` class inherits from `APIView`. You can use any of the standard at The `ViewSet` class does not provide any implementations of actions. In order to use a `ViewSet` class you'll override the class and define the action implementations explicitly. +## GenericViewSet + +The `GenericViewSet` class inherits from `GenericAPIView`, and provides the default set of `get_object`, `get_queryset` methods and other generic view base behavior, but does not include any actions by default. + +In order to use a `GenericViewSet` class you'll override the class and either mixin the required mixin classes, or define the action implementations explicitly. + ## ModelViewSet -The `ModelViewSet` class inherits from `GenericAPIView` and includes implementations for various actions, by mixing in the behavior of the +The `ModelViewSet` class inherits from `GenericAPIView` and includes implementations for various actions, by mixing in the behavior of the various mixin classes. The actions provided by the `ModelViewSet` class are `.list()`, `.retrieve()`, `.create()`, `.update()`, and `.destroy()`. @@ -188,17 +194,18 @@ Again, as with `ModelViewSet`, you can use any of the standard attributes and me # Custom ViewSet base classes -Any standard `View` class can be turned into a `ViewSet` class by mixing in `ViewSetMixin`. You can use this to define your own base classes. +You may need to provide custom `ViewSet` classes that do not have the full set of `ModelViewSet` actions, or that customize the behavior in some other way. ## Example -For example, we can create a base viewset class that provides `retrieve`, `update` and `list` operations: +To create a base viewset class that provides `create`, `list` and `retrieve` operations, inherit from `GenericViewSet`, and mixin the required actions: + + class CreateListRetrieveViewSet(mixins.CreateMixin, + mixins.ListMixin, + mixins.RetrieveMixin, + viewsets.GenericViewSet): + pass - class RetrieveUpdateListViewSet(mixins.RetrieveModelMixin, - mixins.UpdateModelMixin, - mixins.ListModelMixin, - viewsets.ViewSetMixin, - generics.GenericAPIView): """ A viewset that provides `retrieve`, `update`, and `list` actions. @@ -207,6 +214,6 @@ For example, we can create a base viewset class that provides `retrieve`, `updat """ pass -By creating your own base `ViewSet` classes, you can provide common behavior that can be reused in multiple views across your API. +By creating your own base `ViewSet` classes, you can provide common behavior that can be reused in multiple viewsets across your API. [cite]: http://guides.rubyonrails.org/routing.html diff --git a/rest_framework/viewsets.py b/rest_framework/viewsets.py index 0eb3e86dc..7c820091a 100644 --- a/rest_framework/viewsets.py +++ b/rest_framework/viewsets.py @@ -108,6 +108,15 @@ class ViewSet(ViewSetMixin, views.APIView): pass +class GenericViewSet(ViewSetMixin, generics.GenericAPIView): + """ + The GenericViewSet class does not provide any actions by default, + but does include the base set of generic view behavior, such as + the `get_object` and `get_queryset` methods. + """ + pass + + class ReadOnlyModelViewSet(mixins.RetrieveModelMixin, mixins.ListModelMixin, ViewSetMixin, From 939cc5adba6f5a95aac317134eb841838a0bff3f Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 9 May 2013 13:35:01 +0100 Subject: [PATCH 012/197] Tweak inheritance --- rest_framework/viewsets.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/rest_framework/viewsets.py b/rest_framework/viewsets.py index 7c820091a..d91323f22 100644 --- a/rest_framework/viewsets.py +++ b/rest_framework/viewsets.py @@ -119,8 +119,7 @@ class GenericViewSet(ViewSetMixin, generics.GenericAPIView): class ReadOnlyModelViewSet(mixins.RetrieveModelMixin, mixins.ListModelMixin, - ViewSetMixin, - generics.GenericAPIView): + GenericViewSet): """ A viewset that provides default `list()` and `retrieve()` actions. """ @@ -132,8 +131,7 @@ class ModelViewSet(mixins.CreateModelMixin, mixins.UpdateModelMixin, mixins.DestroyModelMixin, mixins.ListModelMixin, - ViewSetMixin, - generics.GenericAPIView): + GenericViewSet): """ A viewset that provides default `create()`, `retrieve()`, `update()`, `partial_update()`, `destroy()` and `list()` actions. From 0176a5391b5d0c5c5dd61133f17b9b68840d6e1a Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 9 May 2013 17:09:40 +0100 Subject: [PATCH 013/197] Fix HyperlinkedModelSerializer not respecting lookup_fields --- rest_framework/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index ea5175e28..d7a4c9ef9 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -827,7 +827,7 @@ class HyperlinkedModelSerializerOptions(ModelSerializerOptions): def __init__(self, meta): super(HyperlinkedModelSerializerOptions, self).__init__(meta) self.view_name = getattr(meta, 'view_name', None) - self.lookup_field = getattr(meta, 'slug_field', None) + self.lookup_field = getattr(meta, 'lookup_field', None) class HyperlinkedModelSerializer(ModelSerializer): From 5c8356d51d48f758136f5019bfbbef3858c6f5fe Mon Sep 17 00:00:00 2001 From: Hamish Campbell Date: Fri, 10 May 2013 13:28:50 +1200 Subject: [PATCH 014/197] Fix minor code error in Generic Views documentation - missing `if` statement. --- docs/api-guide/generic-views.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api-guide/generic-views.md b/docs/api-guide/generic-views.md index 3651dc40c..1a060a324 100755 --- a/docs/api-guide/generic-views.md +++ b/docs/api-guide/generic-views.md @@ -34,7 +34,7 @@ For more complex cases you might also want to override various methods on the vi """ Use smaller pagination for HTML representations. """ - self.request.accepted_renderer.format == 'html': + if self.request.accepted_renderer.format == 'html': return 20 return 100 From 2e3032ff8cf1fe172e5ac38dc4320f1191fba340 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 10 May 2013 14:14:42 +0200 Subject: [PATCH 015/197] Added @hamishcampbell for docs fix #818. Thanks! --- docs/topics/credits.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/topics/credits.md b/docs/topics/credits.md index 13f673c98..9871c64e5 100644 --- a/docs/topics/credits.md +++ b/docs/topics/credits.md @@ -120,6 +120,7 @@ The following people have helped make REST framework great. * Jerome Chen - [chenjyw] * Andrew Hughes - [eyepulp] * Daniel Hepper - [dhepper] +* Hamish Campbell - [hamishcampbell] Many thanks to everyone who's contributed to the project. @@ -275,3 +276,4 @@ You can also contact [@_tomchristie][twitter] directly on twitter. [chenjyw]: https://github.com/chenjyw [eyepulp]: https://github.com/eyepulp [dhepper]: https://github.com/dhepper +[hamishcampbell]: https://github.com/hamishcampbell From fd84cf7f10bf703c5daae4a5f6a7dee0c22471dd Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 10 May 2013 21:56:33 +0100 Subject: [PATCH 016/197] Docs tweaks --- docs/topics/2.3-announcement.md | 4 ++-- docs/tutorial/2-requests-and-responses.md | 2 +- docs/tutorial/6-viewsets-and-routers.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/topics/2.3-announcement.md b/docs/topics/2.3-announcement.md index 6677c800f..4df9c819c 100644 --- a/docs/topics/2.3-announcement.md +++ b/docs/topics/2.3-announcement.md @@ -30,8 +30,8 @@ As an example of just how simple REST framework APIs can now be, here's an API w # Routers provide an easy way of automatically determining the URL conf router = routers.DefaultRouter() - router.register(r'users', views.UserViewSet) - router.register(r'groups', views.GroupViewSet) + router.register(r'users', UserViewSet) + router.register(r'groups', GroupViewSet) # Wire up our API using automatic URL routing. diff --git a/docs/tutorial/2-requests-and-responses.md b/docs/tutorial/2-requests-and-responses.md index 3a002cb00..260c4d832 100644 --- a/docs/tutorial/2-requests-and-responses.md +++ b/docs/tutorial/2-requests-and-responses.md @@ -8,7 +8,7 @@ Let's introduce a couple of essential building blocks. REST framework introduces a `Request` object that extends the regular `HttpRequest`, and provides more flexible request parsing. The core functionality of the `Request` object is the `request.DATA` attribute, which is similar to `request.POST`, but more useful for working with Web APIs. request.POST # Only handles form data. Only works for 'POST' method. - request.DATA # Handles arbitrary data. Works any HTTP request with content. + request.DATA # Handles arbitrary data. Works for 'POST', 'PUT' and 'PATCH' methods. ## Response objects diff --git a/docs/tutorial/6-viewsets-and-routers.md b/docs/tutorial/6-viewsets-and-routers.md index 4b01d3e00..277804e24 100644 --- a/docs/tutorial/6-viewsets-and-routers.md +++ b/docs/tutorial/6-viewsets-and-routers.md @@ -119,7 +119,7 @@ Registering the viewsets with the router is similar to providing a urlpattern. The `DefaultRouter` class we're using also automatically creates the API root view for us, so we can now delete the `api_root` method from our `views` module. -## Trade-offs between views vs viewsets. +## Trade-offs between views vs viewsets Using viewsets can be a really useful abstraction. It helps ensure that URL conventions will be consistent across your API, minimizes the amount of code you need to write, and allows you to concentrate on the interactions and representations your API provides rather than the specifics of the URL conf. From 773a92eab3ac4b635511483ef906b3b8de9dedc9 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 10 May 2013 21:57:05 +0100 Subject: [PATCH 017/197] Move models into test modules, out of models module --- rest_framework/tests/models.py | 7 ------- rest_framework/tests/pagination.py | 10 ++++++++-- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/rest_framework/tests/models.py b/rest_framework/tests/models.py index f2117538c..40e41a644 100644 --- a/rest_framework/tests/models.py +++ b/rest_framework/tests/models.py @@ -58,13 +58,6 @@ class ReadOnlyManyToManyModel(RESTFrameworkModel): rel = models.ManyToManyField(Anchor) -# Model to test filtering. -class FilterableItem(RESTFrameworkModel): - text = models.CharField(max_length=100) - decimal = models.DecimalField(max_digits=4, decimal_places=2) - date = models.DateField() - - # Model for regression test for #285 class Comment(RESTFrameworkModel): diff --git a/rest_framework/tests/pagination.py b/rest_framework/tests/pagination.py index 6b8ef02f0..894d53d6a 100644 --- a/rest_framework/tests/pagination.py +++ b/rest_framework/tests/pagination.py @@ -1,18 +1,24 @@ from __future__ import unicode_literals import datetime from decimal import Decimal -import django +from django.db import models from django.core.paginator import Paginator from django.test import TestCase from django.test.client import RequestFactory from django.utils import unittest from rest_framework import generics, status, pagination, filters, serializers from rest_framework.compat import django_filters -from rest_framework.tests.models import BasicModel, FilterableItem +from rest_framework.tests.models import BasicModel factory = RequestFactory() +class FilterableItem(models.Model): + text = models.CharField(max_length=100) + decimal = models.DecimalField(max_digits=4, decimal_places=2) + date = models.DateField() + + class RootView(generics.ListCreateAPIView): """ Example description for OPTIONS. From 8ce36d2bf1a899683208dc7de425a238ab27d0b3 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 10 May 2013 21:57:20 +0100 Subject: [PATCH 018/197] SearchFilter and tests --- rest_framework/filters.py | 9 +++- rest_framework/tests/filterset.py | 81 ++++++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/rest_framework/filters.py b/rest_framework/filters.py index f2163f6fb..54cbbde31 100644 --- a/rest_framework/filters.py +++ b/rest_framework/filters.py @@ -74,6 +74,8 @@ class DjangoFilterBackend(BaseFilterBackend): class SearchFilter(BaseFilterBackend): + search_param = 'search' + def construct_search(self, field_name): if field_name.startswith('^'): return "%s__istartswith" % field_name[1:] @@ -90,10 +92,13 @@ class SearchFilter(BaseFilterBackend): if not search_fields: return None + search_terms = request.QUERY_PARAMS.get(self.search_param) orm_lookups = [self.construct_search(str(search_field)) - for search_field in self.search_fields] - for bit in self.query.split(): + for search_field in search_fields] + + for bit in search_terms.split(): or_queries = [models.Q(**{orm_lookup: bit}) for orm_lookup in orm_lookups] queryset = queryset.filter(reduce(operator.or_, or_queries)) + return queryset diff --git a/rest_framework/tests/filterset.py b/rest_framework/tests/filterset.py index 023bd0166..7865fedd7 100644 --- a/rest_framework/tests/filterset.py +++ b/rest_framework/tests/filterset.py @@ -1,17 +1,24 @@ from __future__ import unicode_literals import datetime from decimal import Decimal +from django.db import models from django.core.urlresolvers import reverse from django.test import TestCase from django.test.client import RequestFactory from django.utils import unittest from rest_framework import generics, serializers, status, filters from rest_framework.compat import django_filters, patterns, url -from rest_framework.tests.models import FilterableItem, BasicModel +from rest_framework.tests.models import BasicModel factory = RequestFactory() +class FilterableItem(models.Model): + text = models.CharField(max_length=100) + decimal = models.DecimalField(max_digits=4, decimal_places=2) + date = models.DateField() + + if django_filters: # Basic filter on a list view. class FilterFieldsRootView(generics.ListCreateAPIView): @@ -256,3 +263,75 @@ class IntegrationTestDetailFiltering(CommonFilteringTestCase): response = self.client.get('{url}?decimal={decimal}&date={date}'.format(url=self._get_url(valid_item), decimal=search_decimal, date=search_date)) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data, valid_item_data) + + +class SearchFilterModel(models.Model): + title = models.CharField(max_length=20) + text = models.CharField(max_length=100) + + +class SearchFilterTests(TestCase): + def setUp(self): + # Sequence of title/text is: + # + # z abc + # zz bcd + # zzz cde + # ... + for idx in range(10): + title = 'z' * (idx + 1) + text = ( + chr(idx + ord('a')) + + chr(idx + ord('b')) + + chr(idx + ord('c')) + ) + SearchFilterModel(title=title, text=text).save() + + def test_search(self): + class SearchListView(generics.ListAPIView): + model = SearchFilterModel + filter_backends = (filters.SearchFilter,) + search_fields = ('title', 'text') + + view = SearchListView.as_view() + request = factory.get('?search=b') + response = view(request) + self.assertEqual( + response.data, + [ + {u'id': 1, 'title': u'z', 'text': u'abc'}, + {u'id': 2, 'title': u'zz', 'text': u'bcd'} + ] + ) + + def test_exact_search(self): + class SearchListView(generics.ListAPIView): + model = SearchFilterModel + filter_backends = (filters.SearchFilter,) + search_fields = ('=title', 'text') + + view = SearchListView.as_view() + request = factory.get('?search=zzz') + response = view(request) + self.assertEqual( + response.data, + [ + {u'id': 3, 'title': u'zzz', 'text': u'cde'} + ] + ) + + def test_startswith_search(self): + class SearchListView(generics.ListAPIView): + model = SearchFilterModel + filter_backends = (filters.SearchFilter,) + search_fields = ('title', '^text') + + view = SearchListView.as_view() + request = factory.get('?search=b') + response = view(request) + self.assertEqual( + response.data, + [ + {u'id': 2, 'title': u'zz', 'text': u'bcd'} + ] + ) From 293dc3e6d8071fb464a63593831309468e457d6b Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 10 May 2013 22:33:11 +0100 Subject: [PATCH 019/197] Added SearchFilter --- docs/api-guide/filtering.md | 94 +++++++++++++++++++++++++++---------- rest_framework/filters.py | 7 +-- 2 files changed, 72 insertions(+), 29 deletions(-) diff --git a/docs/api-guide/filtering.md b/docs/api-guide/filtering.md index 50bc6f054..7d8c39e29 100644 --- a/docs/api-guide/filtering.md +++ b/docs/api-guide/filtering.md @@ -77,20 +77,61 @@ We can override `.get_queryset()` to deal with URLs such as `http://example.com/ # Generic Filtering -As well as being able to override the default queryset, REST framework also includes support for generic filtering backends that allow you to easily construct complex filters that can be specified by the client using query parameters. +As well as being able to override the default queryset, REST framework also includes support for generic filtering backends that allow you to easily construct complex searches and filters. + +## Setting filter backends + +The default filter backends may be set globally, using the `DEFAULT_FILTER_BACKENDS` setting. For example. + + REST_FRAMEWORK = { + 'DEFAULT_FILTER_BACKENDS': ('rest_framework.filters.DjangoFilterBackend',) + } + +You can also set the authentication policy on a per-view, or per-viewset basis, +using the `GenericAPIView` class based views. + + class UserListView(generics.ListAPIView): + queryset = User.objects.all() + serializer = UserSerializer + filter_backends = (filters.DjangoFilterBackend,) + +## Filtering and object lookups + +Note that if a filter backend is configured for a view, then as well as being used to filter list views, it will also be used to filter the querysets used for returning a single object. + +For instance, given the previous example, and a product with an id of `4675`, the following URL would either return the corresponding object, or return a 404 response, depending on if the filtering conditions were met by the given product instance: + + http://example.com/api/products/4675/?category=clothing&max_price=10.00 + +## Overriding the initial queryset + +Note that you can use both an overridden `.get_queryset()` and generic filtering together, and everything will work as expected. For example, if `Product` had a many-to-many relationship with `User`, named `purchase`, you might want to write a view like this: + + class PurchasedProductsList(generics.ListAPIView): + """ + Return a list of all the products that the authenticated + user has ever purchased, with optional filtering. + """ + model = Product + serializer_class = ProductSerializer + filter_class = ProductFilter + + def get_queryset(self): + user = self.request.user + return user.purchase_set.all() + +--- + +# API Guide ## DjangoFilterBackend +The `DjangoFilterBackend` class supports highly customizable field filtering, using the [django-filter package][django-filter]. + To use REST framework's `DjangoFilterBackend`, first install `django-filter`. pip install django-filter -You must also set the filter backend to `DjangoFilterBackend` in your settings: - - REST_FRAMEWORK = { - 'DEFAULT_FILTER_BACKENDS': ['rest_framework.filters.DjangoFilterBackend'] - } - #### Specifying filter fields @@ -137,30 +178,30 @@ For more details on using filter sets see the [django-filter documentation][djan --- -## Filtering and object lookups +## SearchFilter -Note that if a filter backend is configured for a view, then as well as being used to filter list views, it will also be used to filter the querysets used for returning a single object. +The `SearchFilter` class supports simple single query parameter based searching, and is based on the [Django admin's search functionality][search-django-admin]. -For instance, given the previous example, and a product with an id of `4675`, the following URL would either return the corresponding object, or return a 404 response, depending on if the filtering conditions were met by the given product instance: +The `SearchFilter` class will only be applied if the view has a `search_fields` attribute set. The `search_fields` attribute should be a list of names of text fields on the model. - http://example.com/api/products/4675/?category=clothing&max_price=10.00 + class UserListView(generics.ListAPIView): + queryset = User.objects.all() + serializer = UserSerializer + filter_backends = (filters.SearchFilter,) + search_fields = ('username', 'email') -## Overriding the initial queryset - -Note that you can use both an overridden `.get_queryset()` and generic filtering together, and everything will work as expected. For example, if `Product` had a many-to-many relationship with `User`, named `purchase`, you might want to write a view like this: +This will allow the client to filter the itemss in the list by making queries such as: + + http://example.com/api/users?search=russell + +You can also perform a related lookup on a ForeignKey or ManyToManyField with the lookup API double-underscore notation: + + search_fields = ('username', 'email', 'profile__profession') + +By default, searches will use case-insensitive partial matches. If the search parameter contains multiple whitespace seperated words, then objects will be returned in the list only if all the provided words are matched. + +For more details, see the [Django documentation][search-django-admin]. - class PurchasedProductsList(generics.ListAPIView): - """ - Return a list of all the products that the authenticated - user has ever purchased, with optional filtering. - """ - model = Product - serializer_class = ProductSerializer - filter_class = ProductFilter - - def get_queryset(self): - user = self.request.user - return user.purchase_set.all() --- # Custom generic filtering @@ -181,3 +222,4 @@ For example: [django-filter]: https://github.com/alex/django-filter [django-filter-docs]: https://django-filter.readthedocs.org/en/latest/index.html [nullbooleanselect]: https://github.com/django/django/blob/master/django/forms/widgets.py +[search-django-admin]: https://docs.djangoproject.com/en/dev/ref/contrib/admin/#django.contrib.admin.ModelAdmin.search_fields diff --git a/rest_framework/filters.py b/rest_framework/filters.py index 54cbbde31..3edef30d3 100644 --- a/rest_framework/filters.py +++ b/rest_framework/filters.py @@ -74,7 +74,8 @@ class DjangoFilterBackend(BaseFilterBackend): class SearchFilter(BaseFilterBackend): - search_param = 'search' + search_param = 'search' # The URL query parameter used for the search. + delimiter = None # For example, set to ',' for comma delimited searchs. def construct_search(self, field_name): if field_name.startswith('^'): @@ -96,8 +97,8 @@ class SearchFilter(BaseFilterBackend): orm_lookups = [self.construct_search(str(search_field)) for search_field in search_fields] - for bit in search_terms.split(): - or_queries = [models.Q(**{orm_lookup: bit}) + for search_term in search_terms.split(self.delimiter): + or_queries = [models.Q(**{orm_lookup: search_term}) for orm_lookup in orm_lookups] queryset = queryset.filter(reduce(operator.or_, or_queries)) From 260a8125c58d76c947f864e738b0a8c35da02d9d Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 10 May 2013 22:56:23 +0100 Subject: [PATCH 020/197] Improve custom filtering example --- docs/api-guide/filtering.md | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/docs/api-guide/filtering.md b/docs/api-guide/filtering.md index 7d8c39e29..b5dfc68ec 100644 --- a/docs/api-guide/filtering.md +++ b/docs/api-guide/filtering.md @@ -180,9 +180,9 @@ For more details on using filter sets see the [django-filter documentation][djan ## SearchFilter -The `SearchFilter` class supports simple single query parameter based searching, and is based on the [Django admin's search functionality][search-django-admin]. +The `SearchFilterBackend` class supports simple single query parameter based searching, and is based on the [Django admin's search functionality][search-django-admin]. -The `SearchFilter` class will only be applied if the view has a `search_fields` attribute set. The `search_fields` attribute should be a list of names of text fields on the model. +The `SearchFilterBackend` class will only be applied if the view has a `search_fields` attribute set. The `search_fields` attribute should be a list of names of text fields on the model. class UserListView(generics.ListAPIView): queryset = User.objects.all() @@ -210,13 +210,20 @@ You can also provide your own generic filtering backend, or write an installable To do so override `BaseFilterBackend`, and override the `.filter_queryset(self, request, queryset, view)` method. The method should return a new, filtered queryset. -To install the filter backend, set the `'DEFAULT_FILTER_BACKENDS'` key in your `'REST_FRAMEWORK'` setting, using the dotted import path of the filter backend class. +As well as allowing clients to perform searches and filtering, generic filter backends can be useful for restricting which objects should be visible to any given request or user. -For example: +## Example - REST_FRAMEWORK = { - 'DEFAULT_FILTER_BACKENDS': ['custom_filters.CustomFilterBackend'] - } +For example, you might need to restrict users to only being able to see objects they created. + + class IsOwnerFilterBackend(filters.BaseFilterBackend): + """ + Filter that only allows users to see their own objects. + """ + def filter_queryset(self, request, queryset, view): + return queryset.filter(owner=request.user) + +We could do the same thing by overriding `get_queryset` on the views, but using a filter backend allows you to more easily add this restriction to multiple views, or to apply it across the entire API. [cite]: https://docs.djangoproject.com/en/dev/topics/db/queries/#retrieving-specific-objects-with-filters [django-filter]: https://github.com/alex/django-filter From dd51d369c8228f3add37cc639702097b0df9cd90 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 10 May 2013 23:02:24 +0100 Subject: [PATCH 021/197] Unicode string fix --- rest_framework/tests/filterset.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/rest_framework/tests/filterset.py b/rest_framework/tests/filterset.py index 7865fedd7..e5414232f 100644 --- a/rest_framework/tests/filterset.py +++ b/rest_framework/tests/filterset.py @@ -299,8 +299,8 @@ class SearchFilterTests(TestCase): self.assertEqual( response.data, [ - {u'id': 1, 'title': u'z', 'text': u'abc'}, - {u'id': 2, 'title': u'zz', 'text': u'bcd'} + {'id': 1, 'title': 'z', 'text': 'abc'}, + {'id': 2, 'title': 'zz', 'text': 'bcd'} ] ) @@ -316,7 +316,7 @@ class SearchFilterTests(TestCase): self.assertEqual( response.data, [ - {u'id': 3, 'title': u'zzz', 'text': u'cde'} + {'id': 3, 'title': 'zzz', 'text': 'cde'} ] ) @@ -332,6 +332,6 @@ class SearchFilterTests(TestCase): self.assertEqual( response.data, [ - {u'id': 2, 'title': u'zz', 'text': u'bcd'} + {'id': 2, 'title': 'zz', 'text': 'bcd'} ] ) From fd4a66cfc7888775d20b18665d63156cf3dae13a Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 10 May 2013 23:06:42 +0100 Subject: [PATCH 022/197] Fix py3k compat with functools.reduce --- rest_framework/filters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rest_framework/filters.py b/rest_framework/filters.py index 3edef30d3..57f0f7c83 100644 --- a/rest_framework/filters.py +++ b/rest_framework/filters.py @@ -3,9 +3,9 @@ Provides generic filtering backends that can be used to filter the results returned by list views. """ from __future__ import unicode_literals - from django.db import models from rest_framework.compat import django_filters +from functools import reduce import operator FilterSet = django_filters and django_filters.FilterSet or None From 9d2580dccfe23e113221c7e150bddebb95d98214 Mon Sep 17 00:00:00 2001 From: Marlon Bailey Date: Sat, 11 May 2013 22:26:34 -0400 Subject: [PATCH 023/197] added support for multiple @action and @link decorators on a viewset, along with a router testcase illustrating the failure against the master code base --- rest_framework/routers.py | 6 ++--- rest_framework/tests/routers.py | 46 +++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 rest_framework/tests/routers.py diff --git a/rest_framework/routers.py b/rest_framework/routers.py index 0707635a4..ebdf2b2ab 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -127,18 +127,18 @@ class SimpleRouter(BaseRouter): """ # Determine any `@action` or `@link` decorated methods on the viewset - dynamic_routes = {} + dynamic_routes = [] for methodname in dir(viewset): attr = getattr(viewset, methodname) httpmethod = getattr(attr, 'bind_to_method', None) if httpmethod: - dynamic_routes[httpmethod] = methodname + dynamic_routes.append((httpmethod, methodname)) ret = [] for route in self.routes: if route.mapping == {'{httpmethod}': '{methodname}'}: # Dynamic routes (@link or @action decorator) - for httpmethod, methodname in dynamic_routes.items(): + for httpmethod, methodname in dynamic_routes: initkwargs = route.initkwargs.copy() initkwargs.update(getattr(viewset, methodname).kwargs) ret.append(Route( diff --git a/rest_framework/tests/routers.py b/rest_framework/tests/routers.py new file mode 100644 index 000000000..138d13d79 --- /dev/null +++ b/rest_framework/tests/routers.py @@ -0,0 +1,46 @@ +from __future__ import unicode_literals +from django.test import TestCase +from django.test.client import RequestFactory +from rest_framework import status +from rest_framework.response import Response +from rest_framework import viewsets +from rest_framework.decorators import link, action +from rest_framework.routers import SimpleRouter +import copy + +factory = RequestFactory() + + +class BasicViewSet(viewsets.ViewSet): + def list(self, request, *args, **kwargs): + return Response({'method': 'list'}) + + @action() + def action1(self, request, *args, **kwargs): + return Response({'method': 'action1'}) + + @action() + def action2(self, request, *args, **kwargs): + return Response({'method': 'action2'}) + + @link() + def link1(self, request, *args, **kwargs): + return Response({'method': 'link1'}) + + @link() + def link2(self, request, *args, **kwargs): + return Response({'method': 'link2'}) + + +class TestSimpleRouter(TestCase): + def setUp(self): + self.router = SimpleRouter() + + def test_link_and_action_decorator(self): + routes = self.router.get_routes(BasicViewSet) + # Should be 2 by default, and then four from the @action and @link combined + #self.assertEqual(len(routes), 6) + # + decorator_routes = routes[2:] + for i, method in enumerate(['action1', 'action2', 'link1', 'link2']): + self.assertEqual(decorator_routes[i].mapping.values()[0], method) From 5e2d8052d4bf87c81cc9807c96c933ca975cc483 Mon Sep 17 00:00:00 2001 From: Marlon Bailey Date: Sun, 12 May 2013 09:22:14 -0400 Subject: [PATCH 024/197] fix test case to work with Python 3 and make it more explicit --- rest_framework/tests/routers.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/rest_framework/tests/routers.py b/rest_framework/tests/routers.py index 138d13d79..4e4765cb2 100644 --- a/rest_framework/tests/routers.py +++ b/rest_framework/tests/routers.py @@ -38,9 +38,18 @@ class TestSimpleRouter(TestCase): def test_link_and_action_decorator(self): routes = self.router.get_routes(BasicViewSet) - # Should be 2 by default, and then four from the @action and @link combined - #self.assertEqual(len(routes), 6) - # decorator_routes = routes[2:] - for i, method in enumerate(['action1', 'action2', 'link1', 'link2']): - self.assertEqual(decorator_routes[i].mapping.values()[0], method) + # Make sure all these endpoints exist and none have been clobbered + for i, endpoint in enumerate(['action1', 'action2', 'link1', 'link2']): + route = decorator_routes[i] + # check url listing + self.assertEqual(route.url, + '^{{prefix}}/{{lookup}}/{0}/$'.format(endpoint)) + # check method to function mapping + if endpoint.startswith('action'): + method_map = 'post' + else: + method_map = 'get' + self.assertEqual(route.mapping[method_map], endpoint) + + From 5074bbe4b21a0fc116e4288743fb78314a76a33b Mon Sep 17 00:00:00 2001 From: James Summerfield Date: Mon, 13 May 2013 07:51:23 +0200 Subject: [PATCH 025/197] Remove trailing unmatched in login_base.html template. Reformat indentation and label all closing tags for consistency. --- .../templates/rest_framework/login_base.html | 68 +++++++++---------- 1 file changed, 32 insertions(+), 36 deletions(-) diff --git a/rest_framework/templates/rest_framework/login_base.html b/rest_framework/templates/rest_framework/login_base.html index 380d58205..a3e73b6b6 100644 --- a/rest_framework/templates/rest_framework/login_base.html +++ b/rest_framework/templates/rest_framework/login_base.html @@ -12,44 +12,40 @@ -
-
- -
+
-
- {% block branding %}

Django REST framework

{% endblock %} -
-
- -
-
-
- {% csrf_token %} -
-
- - -
+
+
+
+ {% block branding %}

Django REST framework

{% endblock %}
-
-
- - -
+
+ +
+
+ + {% csrf_token %} +
+
+ + +
+
+
+
+ + +
+
+ +
+ +
+
- -
- -
- -
-
-
- -
-
- -
+
+
+
+
From 1154d873e956e89b1b994c3ac803d90cbfcedf59 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 13 May 2013 13:35:16 +0200 Subject: [PATCH 026/197] Added django-viewsets to credits. Refs #738. --- docs/topics/credits.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/topics/credits.md b/docs/topics/credits.md index 9871c64e5..4eb78d300 100644 --- a/docs/topics/credits.md +++ b/docs/topics/credits.md @@ -134,7 +134,7 @@ Continuous integration testing is managed with [Travis CI][travis-ci]. The [live sandbox][sandbox] is hosted on [Heroku]. -Various inspiration taken from the [Rails], [Piston], [Tastypie] and [Dagny] projects. +Various inspiration taken from the [Rails], [Piston], [Tastypie], [Dagny] and [django-viewsets] projects. Development of REST framework 2.0 was sponsored by [DabApps]. @@ -153,6 +153,7 @@ You can also contact [@_tomchristie][twitter] directly on twitter. [piston]: https://bitbucket.org/jespern/django-piston [tastypie]: https://github.com/toastdriven/django-tastypie [dagny]: https://github.com/zacharyvoase/dagny +[django-viewsets]: https://github.com/BertrandBordage/django-viewsets [dabapps]: http://lab.dabapps.com [sandbox]: http://restframework.herokuapp.com/ [heroku]: http://www.heroku.com/ From 24c9c455feaa47487196a2c9343746d7d5bdd962 Mon Sep 17 00:00:00 2001 From: Brian Zambrano Date: Mon, 13 May 2013 10:51:51 -0700 Subject: [PATCH 027/197] Allow for missing non-required nested objects. Serializer fields which are themselves serializers should not be required. Specifically, if a nested object is set to "required=False", it should be possible to serialize the main object and have the sub-object set to None/null. --- rest_framework/fields.py | 2 +- rest_framework/tests/serializer.py | 47 ++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/rest_framework/fields.py b/rest_framework/fields.py index c83ee5ecf..1f38b7959 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -50,7 +50,7 @@ def get_component(obj, attr_name): return that attribute on the object. """ if isinstance(obj, dict): - val = obj[attr_name] + val = obj.get(attr_name) else: val = getattr(obj, attr_name) diff --git a/rest_framework/tests/serializer.py b/rest_framework/tests/serializer.py index 84e1ee4e0..6e7323275 100644 --- a/rest_framework/tests/serializer.py +++ b/rest_framework/tests/serializer.py @@ -43,6 +43,17 @@ class CommentSerializer(serializers.Serializer): return instance +class NamesSerializer(serializers.Serializer): + first = serializers.CharField() + last = serializers.CharField(required=False, default='') + initials = serializers.CharField(required=False, default='') + + +class PersonIdentifierSerializer(serializers.Serializer): + ssn = serializers.CharField() + names = NamesSerializer(source='names', required=False) + + class BookSerializer(serializers.ModelSerializer): isbn = serializers.RegexField(regex=r'^[0-9]{13}$', error_messages={'invalid': 'isbn has to be exact 13 numbers'}) @@ -141,6 +152,42 @@ class BasicTests(TestCase): self.assertFalse(serializer.object is expected) self.assertEqual(serializer.data['sub_comment'], 'And Merry Christmas!') + def test_create_nested(self): + """Test a serializer with nested data.""" + names = {'first': 'John', 'last': 'Doe', 'initials': 'jd'} + data = {'ssn': '1234567890', 'names': names} + serializer = PersonIdentifierSerializer(data=data) + + self.assertEqual(serializer.is_valid(), True) + self.assertEqual(serializer.object, data) + self.assertFalse(serializer.object is data) + self.assertEqual(serializer.data['names'], names) + + def test_create_partial_nested(self): + """Test a serializer with nested data which has missing fields.""" + names = {'first': 'John'} + data = {'ssn': '1234567890', 'names': names} + serializer = PersonIdentifierSerializer(data=data) + + expected_names = {'first': 'John', 'last': '', 'initials': ''} + data['names'] = expected_names + + self.assertEqual(serializer.is_valid(), True) + self.assertEqual(serializer.object, data) + self.assertFalse(serializer.object is expected_names) + self.assertEqual(serializer.data['names'], expected_names) + + def test_null_nested(self): + """Test a serializer with a nonexistent nested field""" + data = {'ssn': '1234567890'} + serializer = PersonIdentifierSerializer(data=data) + + self.assertEqual(serializer.is_valid(), True) + self.assertEqual(serializer.object, data) + self.assertFalse(serializer.object is data) + expected = {'ssn': '1234567890', 'names': None} + self.assertEqual(serializer.data, expected) + def test_update(self): serializer = CommentSerializer(self.comment, data=self.data) expected = self.comment From f9aeb6838379c301ebd2019a49b806c13749261a Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 14 May 2013 09:59:29 +0100 Subject: [PATCH 028/197] Updated release notes --- docs/api-guide/filtering.md | 4 ++-- docs/topics/release-notes.md | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/api-guide/filtering.md b/docs/api-guide/filtering.md index b5dfc68ec..d67980294 100644 --- a/docs/api-guide/filtering.md +++ b/docs/api-guide/filtering.md @@ -182,7 +182,7 @@ For more details on using filter sets see the [django-filter documentation][djan The `SearchFilterBackend` class supports simple single query parameter based searching, and is based on the [Django admin's search functionality][search-django-admin]. -The `SearchFilterBackend` class will only be applied if the view has a `search_fields` attribute set. The `search_fields` attribute should be a list of names of text fields on the model. +The `SearchFilterBackend` class will only be applied if the view has a `search_fields` attribute set. The `search_fields` attribute should be a list of names of text type fields on the model, such as `CharField` or `TextField`. class UserListView(generics.ListAPIView): queryset = User.objects.all() @@ -223,7 +223,7 @@ For example, you might need to restrict users to only being able to see objects def filter_queryset(self, request, queryset, view): return queryset.filter(owner=request.user) -We could do the same thing by overriding `get_queryset` on the views, but using a filter backend allows you to more easily add this restriction to multiple views, or to apply it across the entire API. +We could achieve the same behavior by overriding `get_queryset()` on the views, but using a filter backend allows you to more easily add this restriction to multiple views, or to apply it across the entire API. [cite]: https://docs.djangoproject.com/en/dev/topics/db/queries/#retrieving-specific-objects-with-filters [django-filter]: https://github.com/alex/django-filter diff --git a/docs/topics/release-notes.md b/docs/topics/release-notes.md index 259aafddb..7ec3d79a0 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`: ## 2.3.x series +### Master + +* Added SearchFilter +* Added GenericViewSet + ### 2.3.2 **Date**: 8th May 2013 From 752c01420f7574cd99e28a17d56df711b675ce71 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 14 May 2013 10:01:05 +0100 Subject: [PATCH 029/197] Fix Django 1.3 compat with routers --- rest_framework/routers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rest_framework/routers.py b/rest_framework/routers.py index 0707635a4..ed4dc338a 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -16,7 +16,7 @@ For example, you might have a `urls.py` that looks something like this: from __future__ import unicode_literals from collections import namedtuple -from django.conf.urls import url, patterns +from rest_framework.compat import patterns, url from rest_framework.decorators import api_view from rest_framework.response import Response from rest_framework.reverse import reverse From b2bf5f1f886d131957f99308a0da89b24b3352d4 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 14 May 2013 10:10:44 +0100 Subject: [PATCH 030/197] SearchFilter may be comma and/or whitespace seperated --- rest_framework/filters.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/rest_framework/filters.py b/rest_framework/filters.py index 57f0f7c83..c496ec4b1 100644 --- a/rest_framework/filters.py +++ b/rest_framework/filters.py @@ -75,7 +75,14 @@ class DjangoFilterBackend(BaseFilterBackend): class SearchFilter(BaseFilterBackend): search_param = 'search' # The URL query parameter used for the search. - delimiter = None # For example, set to ',' for comma delimited searchs. + + def get_search_terms(self, request): + """ + Search terms are set by a ?search=... query parameter, + and may be comma and/or whitespace delimited. + """ + params = request.QUERY_PARAMS.get(self.search_param) + return params.replace(',', ' ').split() def construct_search(self, field_name): if field_name.startswith('^'): @@ -93,11 +100,10 @@ class SearchFilter(BaseFilterBackend): if not search_fields: return None - search_terms = request.QUERY_PARAMS.get(self.search_param) orm_lookups = [self.construct_search(str(search_field)) for search_field in search_fields] - for search_term in search_terms.split(self.delimiter): + for search_term in self.get_search_terms(request): or_queries = [models.Q(**{orm_lookup: search_term}) for orm_lookup in orm_lookups] queryset = queryset.filter(reduce(operator.or_, or_queries)) From aa0dcd2e4a9df80aa5e0c915a471e97245627a37 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 14 May 2013 10:16:00 +0100 Subject: [PATCH 031/197] More docs on SearchFilter --- docs/api-guide/filtering.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/api-guide/filtering.md b/docs/api-guide/filtering.md index d67980294..94244301c 100644 --- a/docs/api-guide/filtering.md +++ b/docs/api-guide/filtering.md @@ -198,7 +198,17 @@ You can also perform a related lookup on a ForeignKey or ManyToManyField with th search_fields = ('username', 'email', 'profile__profession') -By default, searches will use case-insensitive partial matches. If the search parameter contains multiple whitespace seperated words, then objects will be returned in the list only if all the provided words are matched. +By default, searches will use case-insensitive partial matches. The search parameter may contain multiple search terms, which should be whitespace and/or comma seperated. If multiple search terms are used then objects will be returned in the list only if all the provided terms are matched. + +The search behavior may be restricted by prepending various characters to the `search_fields`. + +* '^' Starts-with search. +* '=' Exact matches. +* '@' Full-text search. (Currently only supported Django's MySQL backend.) + +For example: + + search_fields = ('=username', '=email') For more details, see the [Django documentation][search-django-admin]. From 08bc97626960f108f01657e4ad12b7fd62e6183d Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 14 May 2013 10:16:46 +0100 Subject: [PATCH 032/197] Rename filter tests --- rest_framework/tests/{filterset.py => filters.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename rest_framework/tests/{filterset.py => filters.py} (100%) diff --git a/rest_framework/tests/filterset.py b/rest_framework/tests/filters.py similarity index 100% rename from rest_framework/tests/filterset.py rename to rest_framework/tests/filters.py From 6a037f63edf33e7a76f56828cf68bfae4ccb4f80 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 14 May 2013 11:27:03 +0100 Subject: [PATCH 033/197] Added OrderingFilter --- docs/api-guide/filtering.md | 32 ++++++++- rest_framework/filters.py | 41 ++++++++++- rest_framework/tests/filters.py | 116 ++++++++++++++++++++++++++++++++ 3 files changed, 186 insertions(+), 3 deletions(-) diff --git a/docs/api-guide/filtering.md b/docs/api-guide/filtering.md index 94244301c..7e99832ee 100644 --- a/docs/api-guide/filtering.md +++ b/docs/api-guide/filtering.md @@ -190,7 +190,7 @@ The `SearchFilterBackend` class will only be applied if the view has a `search_f filter_backends = (filters.SearchFilter,) search_fields = ('username', 'email') -This will allow the client to filter the itemss in the list by making queries such as: +This will allow the client to filter the items in the list by making queries such as: http://example.com/api/users?search=russell @@ -198,7 +198,7 @@ You can also perform a related lookup on a ForeignKey or ManyToManyField with th search_fields = ('username', 'email', 'profile__profession') -By default, searches will use case-insensitive partial matches. The search parameter may contain multiple search terms, which should be whitespace and/or comma seperated. If multiple search terms are used then objects will be returned in the list only if all the provided terms are matched. +By default, searches will use case-insensitive partial matches. The search parameter may contain multiple search terms, which should be whitespace and/or comma separated. If multiple search terms are used then objects will be returned in the list only if all the provided terms are matched. The search behavior may be restricted by prepending various characters to the `search_fields`. @@ -214,6 +214,34 @@ For more details, see the [Django documentation][search-django-admin]. --- +## OrderingFilter + +The `OrderingFilter` class supports simple query parameter controlled ordering of results. For example: + + http://example.com/api/users?ordering=username + +The client may also specify reverse orderings by prefixing the field name with '-', like so: + + http://example.com/api/users?ordering=-username + +Multiple orderings may also be specified: + + http://example.com/api/users?ordering=account,username + +If an `ordering` attribute is set on the view, this will be used as the default ordering. + +Typicaly you'd instead control this by setting `order_by` on the initial queryset, but using the `ordering` parameter on the view allows you to specify the ordering in a way that it can then be passed automatically as context to a rendered template. This makes it possible to automatically render column headers differently if they are being used to order the results. + + class UserListView(generics.ListAPIView): + queryset = User.objects.all() + serializer = UserSerializer + filter_backends = (filters.OrderingFilter,) + ordering = ('username',) + +The `ordering` attribute may be either a string or a list/tuple of strings. + +--- + # Custom generic filtering You can also provide your own generic filtering backend, or write an installable app for other developers to use. diff --git a/rest_framework/filters.py b/rest_framework/filters.py index c496ec4b1..308e7da2c 100644 --- a/rest_framework/filters.py +++ b/rest_framework/filters.py @@ -4,7 +4,7 @@ returned by list views. """ from __future__ import unicode_literals from django.db import models -from rest_framework.compat import django_filters +from rest_framework.compat import django_filters, six from functools import reduce import operator @@ -109,3 +109,42 @@ class SearchFilter(BaseFilterBackend): queryset = queryset.filter(reduce(operator.or_, or_queries)) return queryset + + +class OrderingFilter(BaseFilterBackend): + ordering_param = 'order' # The URL query parameter used for the ordering. + + def get_ordering(self, request): + """ + Search terms are set by a ?search=... query parameter, + and may be comma and/or whitespace delimited. + """ + params = request.QUERY_PARAMS.get(self.ordering_param) + if params: + return [param.strip() for param in params.split(',')] + + def get_default_ordering(self, view): + ordering = getattr(view, 'ordering', None) + if isinstance(ordering, six.string_types): + return (ordering,) + return ordering + + def remove_invalid_fields(self, queryset, ordering): + field_names = [field.name for field in queryset.model._meta.fields] + return [term for term in ordering if term.lstrip('-') in field_names] + + def filter_queryset(self, request, queryset, view): + ordering = self.get_ordering(request) + + if ordering: + # Skip any incorrect parameters + ordering = self.remove_invalid_fields(queryset, ordering) + + if not ordering: + # Use 'ordering' attribtue by default + ordering = self.get_default_ordering(view) + + if ordering: + return queryset.order_by(*ordering) + + return queryset diff --git a/rest_framework/tests/filters.py b/rest_framework/tests/filters.py index e5414232f..6b604deb1 100644 --- a/rest_framework/tests/filters.py +++ b/rest_framework/tests/filters.py @@ -335,3 +335,119 @@ class SearchFilterTests(TestCase): {'id': 2, 'title': 'zz', 'text': 'bcd'} ] ) + + +class OrdringFilterModel(models.Model): + title = models.CharField(max_length=20) + text = models.CharField(max_length=100) + + +class OrderingFilterTests(TestCase): + def setUp(self): + # Sequence of title/text is: + # + # zyx abc + # yxw bcd + # xwv cde + for idx in range(3): + title = ( + chr(ord('z') - idx) + + chr(ord('y') - idx) + + chr(ord('x') - idx) + ) + text = ( + chr(idx + ord('a')) + + chr(idx + ord('b')) + + chr(idx + ord('c')) + ) + OrdringFilterModel(title=title, text=text).save() + + def test_ordering(self): + class OrderingListView(generics.ListAPIView): + model = OrdringFilterModel + filter_backends = (filters.OrderingFilter,) + ordering = ('title',) + + view = OrderingListView.as_view() + request = factory.get('?order=text') + response = view(request) + self.assertEqual( + response.data, + [ + {'id': 1, 'title': 'zyx', 'text': 'abc'}, + {'id': 2, 'title': 'yxw', 'text': 'bcd'}, + {'id': 3, 'title': 'xwv', 'text': 'cde'}, + ] + ) + + def test_reverse_ordering(self): + class OrderingListView(generics.ListAPIView): + model = OrdringFilterModel + filter_backends = (filters.OrderingFilter,) + ordering = ('title',) + + view = OrderingListView.as_view() + request = factory.get('?order=-text') + response = view(request) + self.assertEqual( + response.data, + [ + {'id': 3, 'title': 'xwv', 'text': 'cde'}, + {'id': 2, 'title': 'yxw', 'text': 'bcd'}, + {'id': 1, 'title': 'zyx', 'text': 'abc'}, + ] + ) + + def test_incorrectfield_ordering(self): + class OrderingListView(generics.ListAPIView): + model = OrdringFilterModel + filter_backends = (filters.OrderingFilter,) + ordering = ('title',) + + view = OrderingListView.as_view() + request = factory.get('?order=foobar') + response = view(request) + self.assertEqual( + response.data, + [ + {'id': 3, 'title': 'xwv', 'text': 'cde'}, + {'id': 2, 'title': 'yxw', 'text': 'bcd'}, + {'id': 1, 'title': 'zyx', 'text': 'abc'}, + ] + ) + + def test_default_ordering(self): + class OrderingListView(generics.ListAPIView): + model = OrdringFilterModel + filter_backends = (filters.OrderingFilter,) + ordering = ('title',) + + view = OrderingListView.as_view() + request = factory.get('') + response = view(request) + self.assertEqual( + response.data, + [ + {'id': 3, 'title': 'xwv', 'text': 'cde'}, + {'id': 2, 'title': 'yxw', 'text': 'bcd'}, + {'id': 1, 'title': 'zyx', 'text': 'abc'}, + ] + ) + + def test_default_ordering_using_string(self): + class OrderingListView(generics.ListAPIView): + model = OrdringFilterModel + filter_backends = (filters.OrderingFilter,) + ordering = 'title' + + view = OrderingListView.as_view() + request = factory.get('') + response = view(request) + self.assertEqual( + response.data, + [ + {'id': 3, 'title': 'xwv', 'text': 'cde'}, + {'id': 2, 'title': 'yxw', 'text': 'bcd'}, + {'id': 1, 'title': 'zyx', 'text': 'abc'}, + ] + ) From 2cff6e69dbe3828eca56d0ce60ffdfc80fed045c Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 14 May 2013 11:27:08 +0100 Subject: [PATCH 034/197] Added OrderingFilter --- docs/api-guide/filtering.md | 2 +- rest_framework/filters.py | 2 +- rest_framework/tests/filters.py | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/api-guide/filtering.md b/docs/api-guide/filtering.md index 7e99832ee..a710ad7dd 100644 --- a/docs/api-guide/filtering.md +++ b/docs/api-guide/filtering.md @@ -216,7 +216,7 @@ For more details, see the [Django documentation][search-django-admin]. ## OrderingFilter -The `OrderingFilter` class supports simple query parameter controlled ordering of results. For example: +The `OrderingFilter` class supports simple query parameter controlled ordering of results. To specify the result order, set a query parameter named `'order'` to the required field name. For example: http://example.com/api/users?ordering=username diff --git a/rest_framework/filters.py b/rest_framework/filters.py index 308e7da2c..6a3e055d4 100644 --- a/rest_framework/filters.py +++ b/rest_framework/filters.py @@ -112,7 +112,7 @@ class SearchFilter(BaseFilterBackend): class OrderingFilter(BaseFilterBackend): - ordering_param = 'order' # The URL query parameter used for the ordering. + ordering_param = 'ordering' # The URL query parameter used for the ordering. def get_ordering(self, request): """ diff --git a/rest_framework/tests/filters.py b/rest_framework/tests/filters.py index 6b604deb1..b20d59805 100644 --- a/rest_framework/tests/filters.py +++ b/rest_framework/tests/filters.py @@ -369,7 +369,7 @@ class OrderingFilterTests(TestCase): ordering = ('title',) view = OrderingListView.as_view() - request = factory.get('?order=text') + request = factory.get('?ordering=text') response = view(request) self.assertEqual( response.data, @@ -387,7 +387,7 @@ class OrderingFilterTests(TestCase): ordering = ('title',) view = OrderingListView.as_view() - request = factory.get('?order=-text') + request = factory.get('?ordering=-text') response = view(request) self.assertEqual( response.data, @@ -405,7 +405,7 @@ class OrderingFilterTests(TestCase): ordering = ('title',) view = OrderingListView.as_view() - request = factory.get('?order=foobar') + request = factory.get('?ordering=foobar') response = view(request) self.assertEqual( response.data, From a303d0f38c4758fc3aad412529922203e5785e29 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 14 May 2013 11:37:59 +0100 Subject: [PATCH 035/197] Fix filter test renaming --- rest_framework/tests/filters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rest_framework/tests/filters.py b/rest_framework/tests/filters.py index b20d59805..18972c84b 100644 --- a/rest_framework/tests/filters.py +++ b/rest_framework/tests/filters.py @@ -222,7 +222,7 @@ class IntegrationTestDetailFiltering(CommonFilteringTestCase): """ Integration tests for filtered detail views. """ - urls = 'rest_framework.tests.filterset' + urls = 'rest_framework.tests.filters' def _get_url(self, item): return reverse('detail-view', kwargs=dict(pk=item.pk)) From 727a64f3b91cbb4b86b9a7e1ecb94b964046b155 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 14 May 2013 11:38:55 +0100 Subject: [PATCH 036/197] Updated release notes --- docs/topics/release-notes.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/topics/release-notes.md b/docs/topics/release-notes.md index 7ec3d79a0..5777b82db 100644 --- a/docs/topics/release-notes.md +++ b/docs/topics/release-notes.md @@ -43,7 +43,9 @@ You can determine your currently installed version using `pip freeze`: ### Master * Added SearchFilter +* Added OrderingFilter * Added GenericViewSet +* Bugfix: Multiple `@action` and `@link` methods now allowed on viewsets. ### 2.3.2 From 6487fa535b6399920a4a6bc2aba306a8cae8c81c Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 14 May 2013 11:39:30 +0100 Subject: [PATCH 037/197] Added @avinash240 for bugfix #822 --- docs/topics/credits.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/topics/credits.md b/docs/topics/credits.md index 9871c64e5..63132604c 100644 --- a/docs/topics/credits.md +++ b/docs/topics/credits.md @@ -121,6 +121,7 @@ The following people have helped make REST framework great. * Andrew Hughes - [eyepulp] * Daniel Hepper - [dhepper] * Hamish Campbell - [hamishcampbell] +* Marlon Bailey - [avinash240] Many thanks to everyone who's contributed to the project. @@ -277,3 +278,4 @@ You can also contact [@_tomchristie][twitter] directly on twitter. [eyepulp]: https://github.com/eyepulp [dhepper]: https://github.com/dhepper [hamishcampbell]: https://github.com/hamishcampbell +[avinash240]: https://github.com/avinash240 \ No newline at end of file From d62414147fa949af4db698afedae7b5506229a9f Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 14 May 2013 17:53:37 +0100 Subject: [PATCH 038/197] Fix assert messaging on fields/exclude checking. Closes #833 --- rest_framework/serializers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index d7a4c9ef9..ecff2c524 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -200,7 +200,7 @@ class BaseSerializer(WritableField): # If 'fields' is specified, use those fields, in that order. if self.opts.fields: - assert isinstance(self.opts.fields, (list, tuple)), '`include` must be a list or tuple' + assert isinstance(self.opts.fields, (list, tuple)), '`fields` must be a list or tuple' new = SortedDict() for key in self.opts.fields: new[key] = ret[key] @@ -208,7 +208,7 @@ class BaseSerializer(WritableField): # Remove anything in 'exclude' if self.opts.exclude: - assert isinstance(self.opts.fields, (list, tuple)), '`exclude` must be a list or tuple' + assert isinstance(self.opts.exclude, (list, tuple)), '`exclude` must be a list or tuple' for key in self.opts.exclude: ret.pop(key, None) From 599c0eb0db1efc194049ffcec5c5c5b457f63647 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 14 May 2013 19:38:41 +0200 Subject: [PATCH 039/197] Added @jsummerfield for cleanups in #824. Thanks! --- docs/topics/credits.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/topics/credits.md b/docs/topics/credits.md index 097dec7f0..482f75d21 100644 --- a/docs/topics/credits.md +++ b/docs/topics/credits.md @@ -122,6 +122,7 @@ The following people have helped make REST framework great. * Daniel Hepper - [dhepper] * Hamish Campbell - [hamishcampbell] * Marlon Bailey - [avinash240] +* James Summerfield - [jsummerfield] Many thanks to everyone who's contributed to the project. @@ -280,3 +281,4 @@ You can also contact [@_tomchristie][twitter] directly on twitter. [dhepper]: https://github.com/dhepper [hamishcampbell]: https://github.com/hamishcampbell [avinash240]: https://github.com/avinash240 +[jsummerfield]: https://github.com/jsummerfield From e939e1755a94b50c87a82c0f777645e28fe91bf0 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 14 May 2013 21:40:55 +0100 Subject: [PATCH 040/197] Base automatic filterset model on the queryset model. Fixes #834. --- rest_framework/filters.py | 19 ++++++------------- rest_framework/tests/filters.py | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/rest_framework/filters.py b/rest_framework/filters.py index 6a3e055d4..34831dd72 100644 --- a/rest_framework/filters.py +++ b/rest_framework/filters.py @@ -32,40 +32,33 @@ class DjangoFilterBackend(BaseFilterBackend): def __init__(self): assert django_filters, 'Using DjangoFilterBackend, but django-filter is not installed' - def get_filter_class(self, view): + def get_filter_class(self, view, queryset=None): """ Return the django-filters `FilterSet` used to filter the queryset. """ filter_class = getattr(view, 'filter_class', None) filter_fields = getattr(view, 'filter_fields', None) - model_cls = getattr(view, 'model', None) - queryset = getattr(view, 'queryset', None) - if model_cls is None and queryset is not None: - model_cls = queryset.model if filter_class: filter_model = filter_class.Meta.model - assert issubclass(filter_model, model_cls), \ - 'FilterSet model %s does not match view model %s' % \ - (filter_model, model_cls) + assert issubclass(filter_model, queryset.model), \ + 'FilterSet model %s does not match queryset model %s' % \ + (filter_model, queryset.model) return filter_class if filter_fields: - assert model_cls is not None, 'Cannot use DjangoFilterBackend ' \ - 'on a view which does not have a .model or .queryset attribute.' - class AutoFilterSet(self.default_filter_set): class Meta: - model = model_cls + model = queryset.model fields = filter_fields return AutoFilterSet return None def filter_queryset(self, request, queryset, view): - filter_class = self.get_filter_class(view) + filter_class = self.get_filter_class(view, queryset) if filter_class: return filter_class(request.QUERY_PARAMS, queryset=queryset).qs diff --git a/rest_framework/tests/filters.py b/rest_framework/tests/filters.py index 18972c84b..a58c66ae2 100644 --- a/rest_framework/tests/filters.py +++ b/rest_framework/tests/filters.py @@ -70,9 +70,19 @@ if django_filters: filter_fields = ['decimal', 'date'] filter_backend = filters.DjangoFilterBackend + class GetQuerysetView(generics.ListCreateAPIView): + serializer_class = FilterableItemSerializer + filter_class = SeveralFieldsFilter + filter_backend = filters.DjangoFilterBackend + + def get_queryset(self): + return FilterableItem.objects.all() + urlpatterns = patterns('', url(r'^(?P\d+)/$', FilterClassDetailView.as_view(), name='detail-view'), url(r'^$', FilterClassRootView.as_view(), name='root-view'), + url(r'^get-queryset/$', GetQuerysetView.as_view(), + name='get-queryset-view'), ) @@ -147,6 +157,17 @@ class IntegrationTestFiltering(CommonFilteringTestCase): expected_data = [f for f in self.data if f['decimal'] == search_decimal] self.assertEqual(response.data, expected_data) + @unittest.skipUnless(django_filters, 'django-filters not installed') + def test_filter_with_get_queryset_only(self): + """ + Regression test for #834. + """ + view = GetQuerysetView.as_view() + request = factory.get('/get-queryset/') + view(request).render() + # Used to raise "issubclass() arg 2 must be a class or tuple of classes" + # here when neither `model' nor `queryset' was specified. + @unittest.skipUnless(django_filters, 'django-filters not installed') def test_get_filtered_class_root_view(self): """ From 092d5223eb7ea1bbf9b6bb967200cb3725e02112 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 15 May 2013 10:29:51 +0100 Subject: [PATCH 041/197] Fix searchfilter issues --- rest_framework/filters.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rest_framework/filters.py b/rest_framework/filters.py index 34831dd72..c058bc715 100644 --- a/rest_framework/filters.py +++ b/rest_framework/filters.py @@ -74,7 +74,7 @@ class SearchFilter(BaseFilterBackend): Search terms are set by a ?search=... query parameter, and may be comma and/or whitespace delimited. """ - params = request.QUERY_PARAMS.get(self.search_param) + params = request.QUERY_PARAMS.get(self.search_param, '') return params.replace(',', ' ').split() def construct_search(self, field_name): @@ -91,7 +91,7 @@ class SearchFilter(BaseFilterBackend): search_fields = getattr(view, 'search_fields', None) if not search_fields: - return None + return queryset orm_lookups = [self.construct_search(str(search_field)) for search_field in search_fields] From af88a5b1751da32018e8408eac01a91a5f63f8ce Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 15 May 2013 14:25:25 +0100 Subject: [PATCH 042/197] Test and fix which closes #652. --- rest_framework/serializers.py | 8 +++++++- rest_framework/tests/serializer.py | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index ecff2c524..7707de7a7 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -649,8 +649,14 @@ class ModelSerializer(Serializer): # Add the `read_only` flag to any fields that have bee specified # in the `read_only_fields` option for field_name in self.opts.read_only_fields: + assert field_name not in self.base_fields.keys(), \ + "field '%s' on serializer '%s' specfied in " \ + "`read_only_fields`, but also added " \ + "as an explict field. Remove it from `read_only_fields`." % \ + (field_name, self.__class__.__name__) assert field_name in ret, \ - "read_only_fields on '%s' included invalid item '%s'" % \ + "Noexistant field '%s' specified in `read_only_fields` " \ + "on serializer '%s'." % \ (self.__class__.__name__, field_name) ret[field_name].read_only = True diff --git a/rest_framework/tests/serializer.py b/rest_framework/tests/serializer.py index 84e1ee4e0..db3881f9a 100644 --- a/rest_framework/tests/serializer.py +++ b/rest_framework/tests/serializer.py @@ -78,6 +78,18 @@ class PersonSerializer(serializers.ModelSerializer): read_only_fields = ('age',) +class PersonSerializerInvalidReadOnly(serializers.ModelSerializer): + """ + Testing for #652. + """ + info = serializers.Field(source='info') + + class Meta: + model = Person + fields = ('name', 'age', 'info') + read_only_fields = ('age', 'info') + + class AlbumsSerializer(serializers.ModelSerializer): class Meta: @@ -189,6 +201,12 @@ class BasicTests(TestCase): # Assert age is unchanged (35) self.assertEqual(instance.age, self.person_data['age']) + def test_invalid_read_only_fields(self): + """ + Regression test for #652. + """ + self.assertRaises(AssertionError, PersonSerializerInvalidReadOnly, []) + class DictStyleSerializer(serializers.Serializer): """ From 66aa2f7a16c4bdb858b1e207acbf4839e7d8e7d1 Mon Sep 17 00:00:00 2001 From: Xavier Ordoquy Date: Thu, 16 May 2013 09:47:22 +0200 Subject: [PATCH 043/197] Small typo in the ViewSet example. --- docs/index.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/index.md b/docs/index.md index cc960a98a..7c38efd34 100644 --- a/docs/index.md +++ b/docs/index.md @@ -113,8 +113,8 @@ Here's our project's root `urls.py` module: # Routers provide an easy way of automatically determining the URL conf router = routers.DefaultRouter() - router.register(r'users', views.UserViewSet) - router.register(r'groups', views.GroupViewSet) + router.register(r'users', UserViewSet) + router.register(r'groups', GroupViewSet) # Wire up our API using automatic URL routing. From aff88d15f7a483bca2da120339b1b346aa8b1d4c Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 16 May 2013 15:08:12 +0100 Subject: [PATCH 044/197] Version 2.3.3 --- docs/topics/release-notes.md | 5 ++++- rest_framework/__init__.py | 2 +- rest_framework/permissions.py | 5 +++++ rest_framework/routers.py | 17 ++++++++++------- 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/docs/topics/release-notes.md b/docs/topics/release-notes.md index 5777b82db..e7d627211 100644 --- a/docs/topics/release-notes.md +++ b/docs/topics/release-notes.md @@ -40,12 +40,15 @@ You can determine your currently installed version using `pip freeze`: ## 2.3.x series -### Master +### 2.3.2 + +**Date**: 16th May 2013 * Added SearchFilter * Added OrderingFilter * Added GenericViewSet * Bugfix: Multiple `@action` and `@link` methods now allowed on viewsets. +* Bugfix: Fix API Root view issue with DjangoModelPermissions ### 2.3.2 diff --git a/rest_framework/__init__.py b/rest_framework/__init__.py index b4961e2f2..0b1e67fbc 100644 --- a/rest_framework/__init__.py +++ b/rest_framework/__init__.py @@ -1,4 +1,4 @@ -__version__ = '2.3.2' +__version__ = '2.3.3' VERSION = __version__ # synonym diff --git a/rest_framework/permissions.py b/rest_framework/permissions.py index 751f31a7c..45fcfd665 100644 --- a/rest_framework/permissions.py +++ b/rest_framework/permissions.py @@ -126,6 +126,11 @@ class DjangoModelPermissions(BasePermission): if model_cls is None and queryset is not None: model_cls = queryset.model + # Workaround to ensure DjangoModelPermissions are not applied + # to the root view when using DefaultRouter. + if model_cls is None and getattr(view, '_ignore_model_permissions'): + return True + assert model_cls, ('Cannot apply DjangoModelPermissions on a view that' ' does not have `.model` or `.queryset` property.') diff --git a/rest_framework/routers.py b/rest_framework/routers.py index 76714fd04..dba104c3f 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -16,6 +16,7 @@ For example, you might have a `urls.py` that looks something like this: from __future__ import unicode_literals from collections import namedtuple +from rest_framework import views from rest_framework.compat import patterns, url from rest_framework.decorators import api_view from rest_framework.response import Response @@ -217,14 +218,16 @@ class DefaultRouter(SimpleRouter): for prefix, viewset, basename in self.registry: api_root_dict[prefix] = list_name.format(basename=basename) - @api_view(('GET',)) - def api_root(request, format=None): - ret = {} - for key, url_name in api_root_dict.items(): - ret[key] = reverse(url_name, request=request, format=format) - return Response(ret) + class APIRoot(views.APIView): + _ignore_model_permissions = True - return api_root + def get(self, request, format=None): + ret = {} + for key, url_name in api_root_dict.items(): + ret[key] = reverse(url_name, request=request, format=format) + return Response(ret) + + return APIRoot.as_view() def get_urls(self): """ From abe207b869c771187523efd3d189ffc0beba51c3 Mon Sep 17 00:00:00 2001 From: Andy Freeland Date: Thu, 16 May 2013 11:24:11 -0400 Subject: [PATCH 045/197] HyperlinkedIdentityField uses `lookup_field` kwarg. According to the [Serializers API Guide][1], `HyperlinkedIdentityField` takes `lookup_field` as a kwarg like the other related fields and the generic views. However, this was not actually implemented. [1]: http://django-rest-framework.org/api-guide/serializers.html#hyperlinkedmodelserializer --- docs/api-guide/relations.md | 6 +-- rest_framework/relations.py | 21 +++++++++- .../tests/hyperlinkedserializers.py | 40 +++++++++++++++++++ 3 files changed, 61 insertions(+), 6 deletions(-) diff --git a/docs/api-guide/relations.md b/docs/api-guide/relations.md index 756e1562a..155c89de3 100644 --- a/docs/api-guide/relations.md +++ b/docs/api-guide/relations.md @@ -196,15 +196,13 @@ Would serialize to a representation like this: 'artist': 'Thom Yorke' 'track_listing': 'http://www.example.com/api/track_list/12/', } - + This field is always read-only. **Arguments**: * `view_name` - The view name that should be used as the target of the relationship. **required**. -* `slug_field` - The field on the target that should be used for the lookup. Default is `'slug'`. -* `pk_url_kwarg` - The named url parameter for the pk field lookup. Default is `pk`. -* `slug_url_kwarg` - The named url parameter for the slug field lookup. Default is to use the same value as given for `slug_field`. +* `lookup_field` - The field on the target that should be used for the lookup. Should correspond to a URL keyword argument on the referenced view. Default is `'pk'`. * `format` - If using format suffixes, hyperlinked fields will use the same format suffix for the target unless overridden by using the `format` argument. --- diff --git a/rest_framework/relations.py b/rest_framework/relations.py index fc5054b2b..c4b790d47 100644 --- a/rest_framework/relations.py +++ b/rest_framework/relations.py @@ -465,10 +465,13 @@ class HyperlinkedIdentityField(Field): """ Represents the instance, or a property on the instance, using hyperlinking. """ + lookup_field = 'pk' + read_only = True + + # These are all pending deprecation pk_url_kwarg = 'pk' slug_field = 'slug' slug_url_kwarg = None # Defaults to same as `slug_field` unless overridden - read_only = True def __init__(self, *args, **kwargs): # TODO: Make view_name mandatory, and have the @@ -477,6 +480,19 @@ class HyperlinkedIdentityField(Field): # Optionally the format of the target hyperlink may be specified self.format = kwargs.pop('format', None) + self.lookup_field = kwargs.pop('lookup_field', self.lookup_field) + + # These are pending deprecation + if 'pk_url_kwarg' in kwargs: + msg = 'pk_url_kwarg is pending deprecation. Use lookup_field instead.' + warnings.warn(msg, PendingDeprecationWarning, stacklevel=2) + if 'slug_url_kwarg' in kwargs: + msg = 'slug_url_kwarg is pending deprecation. Use lookup_field instead.' + warnings.warn(msg, PendingDeprecationWarning, stacklevel=2) + if 'slug_field' in kwargs: + msg = 'slug_field is pending deprecation. Use lookup_field instead.' + warnings.warn(msg, PendingDeprecationWarning, stacklevel=2) + self.slug_field = kwargs.pop('slug_field', self.slug_field) default_slug_kwarg = self.slug_url_kwarg or self.slug_field self.pk_url_kwarg = kwargs.pop('pk_url_kwarg', self.pk_url_kwarg) @@ -488,7 +504,8 @@ class HyperlinkedIdentityField(Field): request = self.context.get('request', None) format = self.context.get('format', None) view_name = self.view_name or self.parent.opts.view_name - kwargs = {self.pk_url_kwarg: obj.pk} + lookup_field = getattr(obj, self.lookup_field) + kwargs = {self.lookup_field: lookup_field} if request is None: warnings.warn("Using `HyperlinkedIdentityField` without including the " diff --git a/rest_framework/tests/hyperlinkedserializers.py b/rest_framework/tests/hyperlinkedserializers.py index 9a61f299a..8fc6ba773 100644 --- a/rest_framework/tests/hyperlinkedserializers.py +++ b/rest_framework/tests/hyperlinkedserializers.py @@ -27,6 +27,14 @@ class PhotoSerializer(serializers.Serializer): return Photo(**attrs) +class AlbumSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='album-detail', lookup_field='title') + + class Meta: + model = Album + fields = ('title', 'url') + + class BasicList(generics.ListCreateAPIView): model = BasicModel model_serializer_class = serializers.HyperlinkedModelSerializer @@ -73,6 +81,8 @@ class PhotoListCreate(generics.ListCreateAPIView): class AlbumDetail(generics.RetrieveAPIView): model = Album + serializer_class = AlbumSerializer + lookup_field = 'title' class OptionalRelationDetail(generics.RetrieveUpdateDestroyAPIView): @@ -180,6 +190,36 @@ class TestManyToManyHyperlinkedView(TestCase): self.assertEqual(response.data, self.data[0]) +class TestHyperlinkedIdentityFieldLookup(TestCase): + urls = 'rest_framework.tests.hyperlinkedserializers' + + def setUp(self): + """ + Create 3 Album instances. + """ + titles = ['foo', 'bar', 'baz'] + for title in titles: + album = Album(title=title) + album.save() + self.detail_view = AlbumDetail.as_view() + self.data = { + 'foo': {'title': 'foo', 'url': 'http://testserver/albums/foo/'}, + 'bar': {'title': 'bar', 'url': 'http://testserver/albums/bar/'}, + 'baz': {'title': 'baz', 'url': 'http://testserver/albums/baz/'} + } + + def test_lookup_field(self): + """ + GET requests to AlbumDetail view should return serialized Albums + with a url field keyed by `title`. + """ + for album in Album.objects.all(): + request = factory.get('/albums/{0}/'.format(album.title)) + response = self.detail_view(request, title=album.title) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, self.data[album.title]) + + class TestCreateWithForeignKeys(TestCase): urls = 'rest_framework.tests.hyperlinkedserializers' From bc3dfca5e303b6e8bd7455405d0148238178b846 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 16 May 2013 19:58:17 +0200 Subject: [PATCH 046/197] Added @rouge8 for bugfix #844. Thank you! --- docs/topics/credits.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/topics/credits.md b/docs/topics/credits.md index 482f75d21..8151b4d3a 100644 --- a/docs/topics/credits.md +++ b/docs/topics/credits.md @@ -123,6 +123,7 @@ The following people have helped make REST framework great. * Hamish Campbell - [hamishcampbell] * Marlon Bailey - [avinash240] * James Summerfield - [jsummerfield] +* Andy Freeland - [rouge8] Many thanks to everyone who's contributed to the project. @@ -282,3 +283,4 @@ You can also contact [@_tomchristie][twitter] directly on twitter. [hamishcampbell]: https://github.com/hamishcampbell [avinash240]: https://github.com/avinash240 [jsummerfield]: https://github.com/jsummerfield +[rouge8]: https://github.com/rouge8 From f1fb434e6da0505b6cd74a3822d278fabde86ff6 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 16 May 2013 19:59:33 +0200 Subject: [PATCH 047/197] Update release-notes.md --- docs/topics/release-notes.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/topics/release-notes.md b/docs/topics/release-notes.md index e7d627211..560dd3050 100644 --- a/docs/topics/release-notes.md +++ b/docs/topics/release-notes.md @@ -40,6 +40,10 @@ You can determine your currently installed version using `pip freeze`: ## 2.3.x series +### Master + +* Bugfix: HyperlinkedIdentityField now uses `lookup_field` kwarg. + ### 2.3.2 **Date**: 16th May 2013 From 646b3b266d2bbfca23d4b1c0c00072cb56cfe8c1 Mon Sep 17 00:00:00 2001 From: Michael Elovskikh Date: Thu, 16 May 2013 22:32:32 -0700 Subject: [PATCH 048/197] Updated README.md From df11198c6cfd543925eabc95d10c596c40ab3ab5 Mon Sep 17 00:00:00 2001 From: Johannes Date: Fri, 17 May 2013 12:27:48 +0200 Subject: [PATCH 049/197] Fix typo + grammar in viewsets docs --- docs/api-guide/viewsets.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api-guide/viewsets.md b/docs/api-guide/viewsets.md index e354a43ad..cd92dc585 100644 --- a/docs/api-guide/viewsets.md +++ b/docs/api-guide/viewsets.md @@ -13,11 +13,11 @@ A `ViewSet` class is simply **a type of class-based View, that does not provide The method handlers for a `ViewSet` are only bound to the corresponding actions at the point of finalizing the view, using the `.as_view()` method. -Typically, rather than exlicitly registering the views in a viewset in the urlconf, you'll register the viewset with a router class, that automatically determines the urlconf for you. +Typically, rather than explicitly registering the views in a viewset in the urlconf, you'll register the viewset with a router class, that automatically determines the urlconf for you. ## Example -Let's define a simple viewset that can be used to listing or retrieving all the users in the system. +Let's define a simple viewset that can be used to list or retrieve all the users in the system. class UserViewSet(viewsets.ViewSet): """ From 14ded26167b68aaf8316a6bf83b6be3e77c8bbd8 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 17 May 2013 21:28:33 +0100 Subject: [PATCH 050/197] PendingDeprecation warning to allow_empty --- rest_framework/mixins.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/rest_framework/mixins.py b/rest_framework/mixins.py index ae703771d..55d21a706 100644 --- a/rest_framework/mixins.py +++ b/rest_framework/mixins.py @@ -10,6 +10,7 @@ from django.http import Http404 from rest_framework import status from rest_framework.response import Response from rest_framework.request import clone_request +import warnings def _get_validation_exclusions(obj, pk=None, slug_field=None, lookup_field=None): @@ -77,6 +78,12 @@ class ListModelMixin(object): # Default is to allow empty querysets. This can be altered by setting # `.allow_empty = False`, to raise 404 errors on empty querysets. if not self.allow_empty and not self.object_list: + warnings.warn( + 'The `allow_empty` parameter is due to be deprecated. ' + 'To use `allow_empty=False` style behavior, You should override ' + '`get_queryset()` and explicitly raise a 404 on empty querysets.', + PendingDeprecationWarning + ) class_name = self.__class__.__name__ error_msg = self.empty_error % {'class_name': class_name} raise Http404(error_msg) From b6fb377c2b4b747597bc3291dadd52b633b135b4 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 17 May 2013 21:57:11 +0100 Subject: [PATCH 051/197] Fix PendingDeprecation warnings in tests --- rest_framework/tests/filters.py | 12 ++++++------ rest_framework/tests/generics.py | 25 +++++++++---------------- rest_framework/tests/pagination.py | 4 ++-- 3 files changed, 17 insertions(+), 24 deletions(-) diff --git a/rest_framework/tests/filters.py b/rest_framework/tests/filters.py index a58c66ae2..8ae6d5303 100644 --- a/rest_framework/tests/filters.py +++ b/rest_framework/tests/filters.py @@ -24,7 +24,7 @@ if django_filters: class FilterFieldsRootView(generics.ListCreateAPIView): model = FilterableItem filter_fields = ['decimal', 'date'] - filter_backend = filters.DjangoFilterBackend + filter_backends = (filters.DjangoFilterBackend,) # These class are used to test a filter class. class SeveralFieldsFilter(django_filters.FilterSet): @@ -39,7 +39,7 @@ if django_filters: class FilterClassRootView(generics.ListCreateAPIView): model = FilterableItem filter_class = SeveralFieldsFilter - filter_backend = filters.DjangoFilterBackend + filter_backends = (filters.DjangoFilterBackend,) # These classes are used to test a misconfigured filter class. class MisconfiguredFilter(django_filters.FilterSet): @@ -52,12 +52,12 @@ if django_filters: class IncorrectlyConfiguredRootView(generics.ListCreateAPIView): model = FilterableItem filter_class = MisconfiguredFilter - filter_backend = filters.DjangoFilterBackend + filter_backends = (filters.DjangoFilterBackend,) class FilterClassDetailView(generics.RetrieveAPIView): model = FilterableItem filter_class = SeveralFieldsFilter - filter_backend = filters.DjangoFilterBackend + filter_backends = (filters.DjangoFilterBackend,) # Regression test for #814 class FilterableItemSerializer(serializers.ModelSerializer): @@ -68,12 +68,12 @@ if django_filters: queryset = FilterableItem.objects.all() serializer_class = FilterableItemSerializer filter_fields = ['decimal', 'date'] - filter_backend = filters.DjangoFilterBackend + filter_backends = (filters.DjangoFilterBackend,) class GetQuerysetView(generics.ListCreateAPIView): serializer_class = FilterableItemSerializer filter_class = SeveralFieldsFilter - filter_backend = filters.DjangoFilterBackend + filter_backends = (filters.DjangoFilterBackend,) def get_queryset(self): return FilterableItem.objects.all() diff --git a/rest_framework/tests/generics.py b/rest_framework/tests/generics.py index eca50d821..2799d1430 100644 --- a/rest_framework/tests/generics.py +++ b/rest_framework/tests/generics.py @@ -39,6 +39,7 @@ class SlugBasedInstanceView(InstanceView): """ model = SlugBasedModel serializer_class = SlugSerializer + lookup_field = 'slug' class TestRootView(TestCase): @@ -434,22 +435,14 @@ class TestFilterBackendAppliedToViews(TestCase): {'id': obj.id, 'text': obj.text} for obj in self.objects.all() ] - self.root_view = RootView.as_view() - self.instance_view = InstanceView.as_view() - self.original_root_backend = getattr(RootView, 'filter_backend') - self.original_instance_backend = getattr(InstanceView, 'filter_backend') - - def tearDown(self): - setattr(RootView, 'filter_backend', self.original_root_backend) - setattr(InstanceView, 'filter_backend', self.original_instance_backend) def test_get_root_view_filters_by_name_with_filter_backend(self): """ GET requests to ListCreateAPIView should return filtered list. """ - setattr(RootView, 'filter_backend', InclusiveFilterBackend) + root_view = RootView.as_view(filter_backends=(InclusiveFilterBackend,)) request = factory.get('/') - response = self.root_view(request).render() + response = root_view(request).render() self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data), 1) self.assertEqual(response.data, [{'id': 1, 'text': 'foo'}]) @@ -458,9 +451,9 @@ class TestFilterBackendAppliedToViews(TestCase): """ GET requests to ListCreateAPIView should return empty list when all models are filtered out. """ - setattr(RootView, 'filter_backend', ExclusiveFilterBackend) + root_view = RootView.as_view(filter_backends=(ExclusiveFilterBackend,)) request = factory.get('/') - response = self.root_view(request).render() + response = root_view(request).render() self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data, []) @@ -468,9 +461,9 @@ class TestFilterBackendAppliedToViews(TestCase): """ GET requests to RetrieveUpdateDestroyAPIView should raise 404 when model filtered out. """ - setattr(InstanceView, 'filter_backend', ExclusiveFilterBackend) + instance_view = InstanceView.as_view(filter_backends=(ExclusiveFilterBackend,)) request = factory.get('/1') - response = self.instance_view(request, pk=1).render() + response = instance_view(request, pk=1).render() self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) self.assertEqual(response.data, {'detail': 'Not found'}) @@ -478,8 +471,8 @@ class TestFilterBackendAppliedToViews(TestCase): """ GET requests to RetrieveUpdateDestroyAPIView should return a single object when not excluded """ - setattr(InstanceView, 'filter_backend', InclusiveFilterBackend) + instance_view = InstanceView.as_view(filter_backends=(InclusiveFilterBackend,)) request = factory.get('/1') - response = self.instance_view(request, pk=1).render() + response = instance_view(request, pk=1).render() self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data, {'id': 1, 'text': 'foo'}) diff --git a/rest_framework/tests/pagination.py b/rest_framework/tests/pagination.py index 894d53d6a..e538a78e5 100644 --- a/rest_framework/tests/pagination.py +++ b/rest_framework/tests/pagination.py @@ -130,7 +130,7 @@ class IntegrationTestPaginationAndFiltering(TestCase): model = FilterableItem paginate_by = 10 filter_class = DecimalFilter - filter_backend = filters.DjangoFilterBackend + filter_backends = (filters.DjangoFilterBackend,) view = FilterFieldsRootView.as_view() @@ -177,7 +177,7 @@ class IntegrationTestPaginationAndFiltering(TestCase): class BasicFilterFieldsRootView(generics.ListCreateAPIView): model = FilterableItem paginate_by = 10 - filter_backend = DecimalFilterBackend + filter_backends = (DecimalFilterBackend,) view = BasicFilterFieldsRootView.as_view() From 34776da9249a5d73f822b3562bc56a5674b10ac7 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 17 May 2013 22:09:23 +0100 Subject: [PATCH 052/197] Minor mixin refactoring --- rest_framework/mixins.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/rest_framework/mixins.py b/rest_framework/mixins.py index 55d21a706..f3cd5868f 100644 --- a/rest_framework/mixins.py +++ b/rest_framework/mixins.py @@ -43,7 +43,6 @@ def _get_validation_exclusions(obj, pk=None, slug_field=None, lookup_field=None) class CreateModelMixin(object): """ Create a model instance. - Should be mixed in with any `GenericAPIView`. """ def create(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.DATA, files=request.FILES) @@ -68,7 +67,6 @@ class CreateModelMixin(object): class ListModelMixin(object): """ List a queryset. - Should be mixed in with `MultipleObjectAPIView`. """ empty_error = "Empty list and '%(class_name)s.allow_empty' is False." @@ -101,7 +99,6 @@ class ListModelMixin(object): class RetrieveModelMixin(object): """ Retrieve a model instance. - Should be mixed in with `SingleObjectAPIView`. """ def retrieve(self, request, *args, **kwargs): self.object = self.get_object() @@ -112,17 +109,22 @@ class RetrieveModelMixin(object): class UpdateModelMixin(object): """ Update a model instance. - Should be mixed in with `SingleObjectAPIView`. """ - def update(self, request, *args, **kwargs): - partial = kwargs.pop('partial', False) - self.object = None + def get_object_or_none(self): try: - self.object = self.get_object() + return self.get_object() except Http404: # If this is a PUT-as-create operation, we need to ensure that # we have relevant permissions, as if this was a POST request. - self.check_permissions(clone_request(request, 'POST')) + # This will either raise a PermissionDenied exception, + # or simply return None + self.check_permissions(clone_request(self.request, 'POST')) + + def update(self, request, *args, **kwargs): + partial = kwargs.pop('partial', False) + self.object = self.get_object_or_none() + + if self.object is None: created = True save_kwargs = {'force_insert': True} success_status_code = status.HTTP_201_CREATED @@ -175,7 +177,6 @@ class UpdateModelMixin(object): class DestroyModelMixin(object): """ Destroy a model instance. - Should be mixed in with `SingleObjectAPIView`. """ def destroy(self, request, *args, **kwargs): obj = self.get_object() From aea040161ae29ec4b5335be5164aa8e5ada506e3 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Sat, 18 May 2013 09:36:09 +0100 Subject: [PATCH 053/197] Forms in Broseable API support dynamic serializers based on request method --- rest_framework/renderers.py | 42 +++++++++++++++++++++++++------- rest_framework/tests/generics.py | 34 +++++++++++++++++++++++++- 2 files changed, 66 insertions(+), 10 deletions(-) diff --git a/rest_framework/renderers.py b/rest_framework/renderers.py index 1917a0803..8361cd409 100644 --- a/rest_framework/renderers.py +++ b/rest_framework/renderers.py @@ -336,7 +336,7 @@ class BrowsableAPIRenderer(BaseRenderer): return # Cannot use form overloading try: - view.check_permissions(clone_request(request, method)) + view.check_permissions(request) except exceptions.APIException: return False # Doesn't have permissions return True @@ -372,6 +372,30 @@ class BrowsableAPIRenderer(BaseRenderer): return fields + def _get_form(self, view, method, request): + # We need to impersonate a request with the correct method, + # so that eg. any dynamic get_serializer_class methods return the + # correct form for each method. + restore = view.request + request = clone_request(request, method) + view.request = request + try: + return self.get_form(view, method, request) + finally: + view.request = restore + + def _get_raw_data_form(self, view, method, request, media_types): + # We need to impersonate a request with the correct method, + # so that eg. any dynamic get_serializer_class methods return the + # correct form for each method. + restore = view.request + request = clone_request(request, method) + view.request = request + try: + return self.get_raw_data_form(view, method, request, media_types) + finally: + view.request = restore + def get_form(self, view, method, request): """ Get a form, possibly bound to either the input or output data. @@ -465,15 +489,15 @@ class BrowsableAPIRenderer(BaseRenderer): renderer = self.get_default_renderer(view) content = self.get_content(renderer, data, accepted_media_type, renderer_context) - put_form = self.get_form(view, 'PUT', request) - post_form = self.get_form(view, 'POST', request) - patch_form = self.get_form(view, 'PATCH', request) - delete_form = self.get_form(view, 'DELETE', request) - options_form = self.get_form(view, 'OPTIONS', request) + put_form = self._get_form(view, 'PUT', request) + post_form = self._get_form(view, 'POST', request) + patch_form = self._get_form(view, 'PATCH', request) + delete_form = self._get_form(view, 'DELETE', request) + options_form = self._get_form(view, 'OPTIONS', request) - raw_data_put_form = self.get_raw_data_form(view, 'PUT', request, media_types) - raw_data_post_form = self.get_raw_data_form(view, 'POST', request, media_types) - raw_data_patch_form = self.get_raw_data_form(view, 'PATCH', request, media_types) + raw_data_put_form = self._get_raw_data_form(view, 'PUT', request, media_types) + raw_data_post_form = self._get_raw_data_form(view, 'POST', request, media_types) + raw_data_patch_form = self._get_raw_data_form(view, 'PATCH', request, media_types) raw_data_put_or_patch_form = raw_data_put_form or raw_data_patch_form name = self.get_name(view) diff --git a/rest_framework/tests/generics.py b/rest_framework/tests/generics.py index 2799d1430..15d87e866 100644 --- a/rest_framework/tests/generics.py +++ b/rest_framework/tests/generics.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals from django.db import models from django.shortcuts import get_object_or_404 from django.test import TestCase -from rest_framework import generics, serializers, status +from rest_framework import generics, renderers, serializers, status from rest_framework.tests.utils import RequestFactory from rest_framework.tests.models import BasicModel, Comment, SlugBasedModel from rest_framework.compat import six @@ -476,3 +476,35 @@ class TestFilterBackendAppliedToViews(TestCase): response = instance_view(request, pk=1).render() self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data, {'id': 1, 'text': 'foo'}) + + +class TwoFieldModel(models.Model): + field_a = models.CharField(max_length=100) + field_b = models.CharField(max_length=100) + + +class DynamicSerializerView(generics.ListCreateAPIView): + model = TwoFieldModel + renderer_classes = (renderers.BrowsableAPIRenderer, renderers.JSONRenderer) + + def get_serializer_class(self): + if self.request.method == 'POST': + class DynamicSerializer(serializers.ModelSerializer): + class Meta: + model = TwoFieldModel + fields = ('field_b',) + return DynamicSerializer + return super(DynamicSerializerView, self).get_serializer_class() + + +class TestFilterBackendAppliedToViews(TestCase): + + def test_dynamic_serializer_form_in_browsable_api(self): + """ + GET requests to ListCreateAPIView should return filtered list. + """ + view = DynamicSerializerView.as_view() + request = factory.get('/') + response = view(request).render() + self.assertContains(response, 'field_b') + self.assertNotContains(response, 'field_a') From ed0bd195f58ae6c0502f9c54cbd34681875adb14 Mon Sep 17 00:00:00 2001 From: Xavier Ordoquy Date: Sat, 18 May 2013 12:07:44 +0200 Subject: [PATCH 054/197] Updated the dependencies version and added the ALLOWED_HOSTS for tests. --- .travis.yml | 17 +++++++++-------- rest_framework/runtests/settings.py | 2 ++ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/.travis.yml b/.travis.yml index 205feef92..3a7c2d7ad 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,9 +7,9 @@ python: - "3.3" env: - - DJANGO="django==1.5 --use-mirrors" - - DJANGO="django==1.4.3 --use-mirrors" - - DJANGO="django==1.3.5 --use-mirrors" + - DJANGO="django==1.5.1 --use-mirrors" + - DJANGO="django==1.4.5 --use-mirrors" + - DJANGO="django==1.3.7 --use-mirrors" install: - pip install $DJANGO @@ -18,7 +18,7 @@ install: - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install django-oauth-plus==2.0 --use-mirrors; fi" - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install django-oauth2-provider==0.2.3 --use-mirrors; fi" - "if [[ ${DJANGO::11} == 'django==1.3' ]]; then pip install django-filter==0.5.4 --use-mirrors; fi" - - "if [[ ${DJANGO::11} != 'django==1.3' ]]; then pip install django-filter==0.6a1 --use-mirrors; fi" + - "if [[ ${DJANGO::11} != 'django==1.3' ]]; then pip install django-filter==0.6 --use-mirrors; fi" - export PYTHONPATH=. script: @@ -27,10 +27,11 @@ script: matrix: exclude: - python: "3.2" - env: DJANGO="django==1.4.3 --use-mirrors" + env: DJANGO="django==1.4.5 --use-mirrors" - python: "3.2" - env: DJANGO="django==1.3.5 --use-mirrors" + env: DJANGO="django==1.3.7 --use-mirrors" - python: "3.3" - env: DJANGO="django==1.4.3 --use-mirrors" + env: DJANGO="django==1.4.5 --use-mirrors" - python: "3.3" - env: DJANGO="django==1.3.5 --use-mirrors" + env: DJANGO="django==1.3.7 --use-mirrors" + diff --git a/rest_framework/runtests/settings.py b/rest_framework/runtests/settings.py index 9b519f271..9dd7b545e 100644 --- a/rest_framework/runtests/settings.py +++ b/rest_framework/runtests/settings.py @@ -4,6 +4,8 @@ DEBUG = True TEMPLATE_DEBUG = DEBUG DEBUG_PROPAGATE_EXCEPTIONS = True +ALLOWED_HOSTS = ['*'] + ADMINS = ( # ('Your Name', 'your_email@domain.com'), ) From 0cd7c80e6eaf3ca17d0fb8f8878054ce570e3932 Mon Sep 17 00:00:00 2001 From: Craig de Stigter Date: Sat, 18 May 2013 12:16:30 +0200 Subject: [PATCH 055/197] add tests for related field source for RelatedField and PrimaryKeyRelatedField. #694 --- rest_framework/tests/relations.py | 37 +++++++++++++++++++++++++- rest_framework/tests/relations_pk.py | 39 +++++++++++++++++++++++++++- rest_framework/tests/serializer.py | 17 ------------ 3 files changed, 74 insertions(+), 19 deletions(-) diff --git a/rest_framework/tests/relations.py b/rest_framework/tests/relations.py index cbf93c65e..f28f0de93 100644 --- a/rest_framework/tests/relations.py +++ b/rest_framework/tests/relations.py @@ -5,6 +5,7 @@ from __future__ import unicode_literals from django.db import models from django.test import TestCase from rest_framework import serializers +from rest_framework.tests.models import BlogPost class NullModel(models.Model): @@ -33,7 +34,7 @@ class FieldTests(TestCase): self.assertRaises(serializers.ValidationError, field.from_native, []) -class TestManyRelateMixin(TestCase): +class TestManyRelatedMixin(TestCase): def test_missing_many_to_many_related_field(self): ''' Regression test for #632 @@ -45,3 +46,37 @@ class TestManyRelateMixin(TestCase): into = {} field.field_from_native({}, None, 'field_name', into) self.assertEqual(into['field_name'], []) + + +# Regression tests for #694 (`source` attribute on related fields) + +class RelatedFieldSourceTests(TestCase): + def test_related_manager_source(self): + """ + Relational fields should be able to use manager-returning methods as their source. + """ + BlogPost.objects.create(title='blah') + field = serializers.RelatedField(many=True, source='get_blogposts_manager') + + class ClassWithManagerMethod(object): + def get_blogposts_manager(self): + return BlogPost.objects + + obj = ClassWithManagerMethod() + value = field.field_to_native(obj, 'field_name') + self.assertEqual(value, ['BlogPost object']) + + def test_related_queryset_source(self): + """ + Relational fields should be able to use queryset-returning methods as their source. + """ + BlogPost.objects.create(title='blah') + field = serializers.RelatedField(many=True, source='get_blogposts_queryset') + + class ClassWithQuerysetMethod(object): + def get_blogposts_queryset(self): + return BlogPost.objects.all() + + obj = ClassWithQuerysetMethod() + value = field.field_to_native(obj, 'field_name') + self.assertEqual(value, ['BlogPost object']) diff --git a/rest_framework/tests/relations_pk.py b/rest_framework/tests/relations_pk.py index 5ce8b5671..51fe59e90 100644 --- a/rest_framework/tests/relations_pk.py +++ b/rest_framework/tests/relations_pk.py @@ -1,7 +1,10 @@ from __future__ import unicode_literals from django.test import TestCase from rest_framework import serializers -from rest_framework.tests.models import ManyToManyTarget, ManyToManySource, ForeignKeyTarget, ForeignKeySource, NullableForeignKeySource, OneToOneTarget, NullableOneToOneSource +from rest_framework.tests.models import ( + BlogPost, ManyToManyTarget, ManyToManySource, ForeignKeyTarget, ForeignKeySource, + NullableForeignKeySource, OneToOneTarget, NullableOneToOneSource, +) from rest_framework.compat import six @@ -421,3 +424,37 @@ class PKNullableOneToOneTests(TestCase): {'id': 2, 'name': 'target-2', 'nullable_source': 1}, ] self.assertEqual(serializer.data, expected) + + +# Regression tests for #694 (`source` attribute on related fields) + +class PrimaryKeyRelatedFieldSourceTests(TestCase): + def test_related_manager_source(self): + """ + Relational fields should be able to use manager-returning methods as their source. + """ + BlogPost.objects.create(title='blah') + field = serializers.PrimaryKeyRelatedField(many=True, source='get_blogposts_manager') + + class ClassWithManagerMethod(object): + def get_blogposts_manager(self): + return BlogPost.objects + + obj = ClassWithManagerMethod() + value = field.field_to_native(obj, 'field_name') + self.assertEqual(value, [1]) + + def test_related_queryset_source(self): + """ + Relational fields should be able to use queryset-returning methods as their source. + """ + BlogPost.objects.create(title='blah') + field = serializers.PrimaryKeyRelatedField(many=True, source='get_blogposts_queryset') + + class ClassWithQuerysetMethod(object): + def get_blogposts_queryset(self): + return BlogPost.objects.all() + + obj = ClassWithQuerysetMethod() + value = field.field_to_native(obj, 'field_name') + self.assertEqual(value, [1]) diff --git a/rest_framework/tests/serializer.py b/rest_framework/tests/serializer.py index db3881f9a..34acbaab4 100644 --- a/rest_framework/tests/serializer.py +++ b/rest_framework/tests/serializer.py @@ -871,23 +871,6 @@ class RelatedTraversalTest(TestCase): self.assertEqual(serializer.data, expected) - def test_queryset_nested_traversal(self): - """ - Relational fields should be able to use methods as their source. - """ - BlogPost.objects.create(title='blah') - - class QuerysetMethodSerializer(serializers.Serializer): - blogposts = serializers.RelatedField(many=True, source='get_all_blogposts') - - class ClassWithQuerysetMethod(object): - def get_all_blogposts(self): - return BlogPost.objects - - obj = ClassWithQuerysetMethod() - serializer = QuerysetMethodSerializer(obj) - self.assertEqual(serializer.data, {'blogposts': ['BlogPost object']}) - class SerializerMethodFieldTests(TestCase): def setUp(self): From 930bd4d0e1f9a74ee56a57ef36c93b1c1d124f91 Mon Sep 17 00:00:00 2001 From: Craig de Stigter Date: Sat, 18 May 2013 12:23:12 +0200 Subject: [PATCH 056/197] add tests for related field source for HyperlinkedRelatedField. #694 --- rest_framework/tests/relations_hyperlink.py | 48 +++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/rest_framework/tests/relations_hyperlink.py b/rest_framework/tests/relations_hyperlink.py index b1eed9a76..8fb4687f5 100644 --- a/rest_framework/tests/relations_hyperlink.py +++ b/rest_framework/tests/relations_hyperlink.py @@ -4,6 +4,7 @@ from django.test.client import RequestFactory from rest_framework import serializers from rest_framework.compat import patterns, url from rest_framework.tests.models import ( + BlogPost, ManyToManyTarget, ManyToManySource, ForeignKeyTarget, ForeignKeySource, NullableForeignKeySource, OneToOneTarget, NullableOneToOneSource ) @@ -16,6 +17,7 @@ def dummy_view(request, pk): pass urlpatterns = patterns('', + url(r'^dummyurl/(?P[0-9]+)/$', dummy_view, name='dummy-url'), 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'), @@ -451,3 +453,49 @@ class HyperlinkedNullableOneToOneTests(TestCase): {'url': 'http://testserver/onetoonetarget/2/', 'name': 'target-2', 'nullable_source': None}, ] self.assertEqual(serializer.data, expected) + + +# Regression tests for #694 (`source` attribute on related fields) + +class HyperlinkedRelatedFieldSourceTests(TestCase): + urls = 'rest_framework.tests.relations_hyperlink' + + def test_related_manager_source(self): + """ + Relational fields should be able to use manager-returning methods as their source. + """ + BlogPost.objects.create(title='blah') + field = serializers.HyperlinkedRelatedField( + many=True, + source='get_blogposts_manager', + view_name='dummy-url', + ) + field.context = {'request': request} + + class ClassWithManagerMethod(object): + def get_blogposts_manager(self): + return BlogPost.objects + + obj = ClassWithManagerMethod() + value = field.field_to_native(obj, 'field_name') + self.assertEqual(value, ['http://testserver/dummyurl/1/']) + + def test_related_queryset_source(self): + """ + Relational fields should be able to use queryset-returning methods as their source. + """ + BlogPost.objects.create(title='blah') + field = serializers.HyperlinkedRelatedField( + many=True, + source='get_blogposts_queryset', + view_name='dummy-url', + ) + field.context = {'request': request} + + class ClassWithQuerysetMethod(object): + def get_blogposts_queryset(self): + return BlogPost.objects.all() + + obj = ClassWithQuerysetMethod() + value = field.field_to_native(obj, 'field_name') + self.assertEqual(value, ['http://testserver/dummyurl/1/']) From 3691cd2ffd26ee639b88aa9f516f211e0558d454 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20T=C3=B6rnqvist?= Date: Sat, 18 May 2013 13:26:59 +0300 Subject: [PATCH 057/197] Use smart_text() instead of nonexistant smart_unicode() --- rest_framework/fields.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 3c4e975ae..e4da14566 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -117,10 +117,10 @@ class Field(object): self.source = source if label is not None: - self.label = smart_unicode(label) + self.label = smart_text(label) if help_text is not None: - self.help_text = smart_unicode(help_text) + self.help_text = smart_text(help_text) def initialize(self, parent, field_name): """ From a73c16b85f79aeb9139734a64623b49bc169fce9 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Sat, 18 May 2013 11:27:48 +0100 Subject: [PATCH 058/197] serializers.Field respects ordering on dicts if it exists. Closes #832 --- rest_framework/fields.py | 7 ++++++- rest_framework/tests/fields.py | 19 ++++++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/rest_framework/fields.py b/rest_framework/fields.py index c83ee5ecf..49d2a6d5d 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -19,6 +19,7 @@ from django import forms from django.forms import widgets from django.utils.encoding import is_protected_type from django.utils.translation import ugettext_lazy as _ +from django.utils.datastructures import SortedDict from rest_framework import ISO_8601 from rest_framework.compat import timezone, parse_date, parse_datetime, parse_time @@ -170,7 +171,11 @@ class Field(object): elif hasattr(value, '__iter__') and not isinstance(value, (dict, six.string_types)): return [self.to_native(item) for item in value] elif isinstance(value, dict): - return dict(map(self.to_native, (k, v)) for k, v in value.items()) + # Make sure we preserve field ordering, if it exists + ret = SortedDict() + for key, val in value.items(): + ret[key] = self.to_native(val) + return ret return smart_text(value) def attributes(self): diff --git a/rest_framework/tests/fields.py b/rest_framework/tests/fields.py index 3cdfa0f62..5b5ce835a 100644 --- a/rest_framework/tests/fields.py +++ b/rest_framework/tests/fields.py @@ -2,13 +2,12 @@ General serializer field tests. """ from __future__ import unicode_literals +from django.utils.datastructures import SortedDict import datetime from decimal import Decimal - from django.db import models from django.test import TestCase from django.core import validators - from rest_framework import serializers from rest_framework.serializers import Serializer @@ -63,6 +62,20 @@ class BasicFieldTests(TestCase): serializer = CharPrimaryKeyModelSerializer() self.assertEqual(serializer.fields['id'].read_only, False) + def test_dict_field_ordering(self): + """ + Field should preserve dictionary ordering, if it exists. + See: https://github.com/tomchristie/django-rest-framework/issues/832 + """ + ret = SortedDict() + ret['c'] = 1 + ret['b'] = 1 + ret['a'] = 1 + ret['z'] = 1 + field = serializers.Field() + keys = list(field.to_native(ret).keys()) + self.assertEqual(keys, ['c', 'b', 'a', 'z']) + class DateFieldTest(TestCase): """ @@ -645,4 +658,4 @@ class DecimalFieldTest(TestCase): s = DecimalSerializer(data={'decimal_field': '12345.6'}) self.assertFalse(s.is_valid()) - self.assertEqual(s.errors, {'decimal_field': ['Ensure that there are no more than 4 digits in total.']}) \ No newline at end of file + self.assertEqual(s.errors, {'decimal_field': ['Ensure that there are no more than 4 digits in total.']}) From 764299843cbedce9afd07db7226f639208ff0608 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20T=C3=B6rnqvist?= Date: Sat, 18 May 2013 13:30:39 +0300 Subject: [PATCH 059/197] ManyHyperlinkedRelatedField comes form rest_framework.relations now --- rest_framework/tests/serializer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rest_framework/tests/serializer.py b/rest_framework/tests/serializer.py index b0c7e5689..fc433a8f2 100644 --- a/rest_framework/tests/serializer.py +++ b/rest_framework/tests/serializer.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals from django.utils.datastructures import MultiValueDict from django.test import TestCase -from rest_framework import serializers, fields +from rest_framework import serializers, fields, relations from rest_framework.tests.models import (HasPositiveIntegerAsChoice, Album, ActionItem, Anchor, BasicModel, BlankFieldModel, BlogPost, BlogPostComment, Book, CallableDefaultValueModel, DefaultValueModel, ManyToManyModel, Person, ReadOnlyManyToManyModel, Photo) @@ -1170,5 +1170,5 @@ class FieldLabelTest(TestCase): """ self.assertEquals(u'Label', fields.Field(label='Label', help_text='Help').label) self.assertEquals(u'Help', fields.CharField(label='Label', help_text='Help').help_text) - self.assertEquals(u'Label', fields.ManyHyperlinkedRelatedField(view_name='fake', label='Label', help_text='Help').label) + self.assertEquals(u'Label', relations.ManyHyperlinkedRelatedField(view_name='fake', label='Label', help_text='Help').label) From c992b600f7b0aefb156cddb5e27b438ccc316b39 Mon Sep 17 00:00:00 2001 From: Craig de Stigter Date: Sat, 18 May 2013 12:32:48 +0200 Subject: [PATCH 060/197] add tests for dotted lookup in RelatedField, PrimaryKeyRelatedField, and HyperlinkedRelatedField. #694 --- rest_framework/tests/relations.py | 18 ++++++++++++++++ rest_framework/tests/relations_hyperlink.py | 23 +++++++++++++++++++++ rest_framework/tests/relations_pk.py | 18 ++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/rest_framework/tests/relations.py b/rest_framework/tests/relations.py index f28f0de93..d19219c90 100644 --- a/rest_framework/tests/relations.py +++ b/rest_framework/tests/relations.py @@ -80,3 +80,21 @@ class RelatedFieldSourceTests(TestCase): obj = ClassWithQuerysetMethod() value = field.field_to_native(obj, 'field_name') self.assertEqual(value, ['BlogPost object']) + + def test_dotted_source(self): + """ + Source argument should support dotted.source notation. + """ + BlogPost.objects.create(title='blah') + field = serializers.RelatedField(many=True, source='a.b.c') + + class ClassWithQuerysetMethod(object): + a = { + 'b': { + 'c': BlogPost.objects.all() + } + } + + obj = ClassWithQuerysetMethod() + value = field.field_to_native(obj, 'field_name') + self.assertEqual(value, ['BlogPost object']) diff --git a/rest_framework/tests/relations_hyperlink.py b/rest_framework/tests/relations_hyperlink.py index 8fb4687f5..b3efbf524 100644 --- a/rest_framework/tests/relations_hyperlink.py +++ b/rest_framework/tests/relations_hyperlink.py @@ -499,3 +499,26 @@ class HyperlinkedRelatedFieldSourceTests(TestCase): obj = ClassWithQuerysetMethod() value = field.field_to_native(obj, 'field_name') self.assertEqual(value, ['http://testserver/dummyurl/1/']) + + def test_dotted_source(self): + """ + Source argument should support dotted.source notation. + """ + BlogPost.objects.create(title='blah') + field = serializers.HyperlinkedRelatedField( + many=True, + source='a.b.c', + view_name='dummy-url', + ) + field.context = {'request': request} + + class ClassWithQuerysetMethod(object): + a = { + 'b': { + 'c': BlogPost.objects.all() + } + } + + obj = ClassWithQuerysetMethod() + value = field.field_to_native(obj, 'field_name') + self.assertEqual(value, ['http://testserver/dummyurl/1/']) diff --git a/rest_framework/tests/relations_pk.py b/rest_framework/tests/relations_pk.py index 51fe59e90..0f8c52476 100644 --- a/rest_framework/tests/relations_pk.py +++ b/rest_framework/tests/relations_pk.py @@ -458,3 +458,21 @@ class PrimaryKeyRelatedFieldSourceTests(TestCase): obj = ClassWithQuerysetMethod() value = field.field_to_native(obj, 'field_name') self.assertEqual(value, [1]) + + def test_dotted_source(self): + """ + Source argument should support dotted.source notation. + """ + BlogPost.objects.create(title='blah') + field = serializers.PrimaryKeyRelatedField(many=True, source='a.b.c') + + class ClassWithQuerysetMethod(object): + a = { + 'b': { + 'c': BlogPost.objects.all() + } + } + + obj = ClassWithQuerysetMethod() + value = field.field_to_native(obj, 'field_name') + self.assertEqual(value, [1]) From de5cc8de423a22009d2a643f6c268805f715b212 Mon Sep 17 00:00:00 2001 From: Pablo Recio Date: Sat, 18 May 2013 12:40:25 +0200 Subject: [PATCH 061/197] A model's field is required if is null or blank --- rest_framework/serializers.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index 7707de7a7..500bb3066 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -705,15 +705,14 @@ class ModelSerializer(Serializer): Creates a default instance of a basic non-relational field. """ kwargs = {} - has_default = model_field.has_default() - if model_field.null or model_field.blank or has_default: + if model_field.null or model_field.blank: kwargs['required'] = False if isinstance(model_field, models.AutoField) or not model_field.editable: kwargs['read_only'] = True - if has_default: + if model_field.has_default(): kwargs['default'] = model_field.get_default() if issubclass(model_field.__class__, models.TextField): From ab8bd566f9db327a4c463317011818d421bbf89c Mon Sep 17 00:00:00 2001 From: Pablo Recio Date: Sat, 18 May 2013 12:40:25 +0200 Subject: [PATCH 062/197] Adding `BLANK_CHOICE_DASH` as a choice if the model's field isn't required --- rest_framework/fields.py | 3 ++ rest_framework/tests/fields.py | 28 ++++++++++++++++++- rest_framework/tests/models.py | 26 +++++++++++++++++ rest_framework/tests/serializer.py | 45 +++++++++++++++++++++++++++++- 4 files changed, 100 insertions(+), 2 deletions(-) diff --git a/rest_framework/fields.py b/rest_framework/fields.py index c83ee5ecf..7fd4c6381 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -15,6 +15,7 @@ import warnings from django.core import validators from django.core.exceptions import ValidationError from django.conf import settings +from django.db.models.fields import BLANK_CHOICE_DASH from django import forms from django.forms import widgets from django.utils.encoding import is_protected_type @@ -402,6 +403,8 @@ class ChoiceField(WritableField): def __init__(self, choices=(), *args, **kwargs): super(ChoiceField, self).__init__(*args, **kwargs) self.choices = choices + if not self.required: + self.choices = BLANK_CHOICE_DASH + self.choices def _get_choices(self): return self._choices diff --git a/rest_framework/tests/fields.py b/rest_framework/tests/fields.py index 3cdfa0f62..f313ba60a 100644 --- a/rest_framework/tests/fields.py +++ b/rest_framework/tests/fields.py @@ -645,4 +645,30 @@ class DecimalFieldTest(TestCase): s = DecimalSerializer(data={'decimal_field': '12345.6'}) self.assertFalse(s.is_valid()) - self.assertEqual(s.errors, {'decimal_field': ['Ensure that there are no more than 4 digits in total.']}) \ No newline at end of file + self.assertEqual(s.errors, {'decimal_field': ['Ensure that there are no more than 4 digits in total.']}) + + +class ChoiceFieldTests(TestCase): + """ + Tests for the ChoiceField options generator + """ + + SAMPLE_CHOICES = [ + ('red', 'Red'), + ('green', 'Green'), + ('blue', 'Blue'), + ] + + def test_choices_required(self): + """ + Make sure proper choices are rendered if field is required + """ + f = serializers.ChoiceField(required=True, choices=self.SAMPLE_CHOICES) + self.assertEqual(f.choices, self.SAMPLE_CHOICES) + + def test_choices_not_required(self): + """ + Make sure proper choices (plus blank) are rendered if the field isn't required + """ + f = serializers.ChoiceField(required=False, choices=self.SAMPLE_CHOICES) + self.assertEqual(f.choices, models.fields.BLANK_CHOICE_DASH + self.SAMPLE_CHOICES) diff --git a/rest_framework/tests/models.py b/rest_framework/tests/models.py index 40e41a644..5d98b04bd 100644 --- a/rest_framework/tests/models.py +++ b/rest_framework/tests/models.py @@ -117,6 +117,32 @@ class OptionalRelationModel(RESTFrameworkModel): other = models.ForeignKey('OptionalRelationModel', blank=True, null=True) +# Model for issue #725 +class SeveralChoicesModel(RESTFrameworkModel): + color = models.CharField( + max_length=10, + choices=[('red', 'Red'), ('green', 'Green'), ('blue', 'Blue')], + blank=False + ) + drink = models.CharField( + max_length=10, + choices=[('beer', 'Beer'), ('wine', 'Wine'), ('cider', 'Cider')], + blank=False, + default='beer' + ) + os = models.CharField( + max_length=10, + choices=[('linux', 'Linux'), ('osx', 'OSX'), ('windows', 'Windows')], + blank=True + ) + music_genre = models.CharField( + max_length=10, + choices=[('rock', 'Rock'), ('metal', 'Metal'), ('grunge', 'Grunge')], + blank=True, + default='metal' + ) + + # Model for RegexField class Book(RESTFrameworkModel): isbn = models.CharField(max_length=13) diff --git a/rest_framework/tests/serializer.py b/rest_framework/tests/serializer.py index db3881f9a..3f39308de 100644 --- a/rest_framework/tests/serializer.py +++ b/rest_framework/tests/serializer.py @@ -1,10 +1,11 @@ from __future__ import unicode_literals +from django.db.models.fields import BLANK_CHOICE_DASH from django.utils.datastructures import MultiValueDict from django.test import TestCase from rest_framework import serializers from rest_framework.tests.models import (HasPositiveIntegerAsChoice, Album, ActionItem, Anchor, BasicModel, BlankFieldModel, BlogPost, BlogPostComment, Book, CallableDefaultValueModel, DefaultValueModel, - ManyToManyModel, Person, ReadOnlyManyToManyModel, Photo) + ManyToManyModel, Person, ReadOnlyManyToManyModel, Photo, SeveralChoicesModel) import datetime import pickle @@ -1018,6 +1019,48 @@ class SerializerPickleTests(TestCase): repr(pickle.loads(pickle.dumps(data, 0))) +# test for issue #725 +class SerializerChoiceFields(TestCase): + + def setUp(self): + super(SerializerChoiceFields, self).setUp() + + class SeveralChoicesSerializer(serializers.ModelSerializer): + class Meta: + model = SeveralChoicesModel + fields = ('color', 'drink', 'os', 'music_genre') + + self.several_choices_serializer = SeveralChoicesSerializer + + def test_choices_blank_false_not_default(self): + serializer = self.several_choices_serializer() + self.assertEqual( + serializer.fields['color'].choices, + [('red', 'Red'), ('green', 'Green'), ('blue', 'Blue')] + ) + + def test_choices_blank_false_with_default(self): + serializer = self.several_choices_serializer() + self.assertEqual( + serializer.fields['drink'].choices, + [('beer', 'Beer'), ('wine', 'Wine'), ('cider', 'Cider')] + ) + + def test_choices_blank_true_not_default(self): + serializer = self.several_choices_serializer() + self.assertEqual( + serializer.fields['os'].choices, + BLANK_CHOICE_DASH + [('linux', 'Linux'), ('osx', 'OSX'), ('windows', 'Windows')] + ) + + def test_choices_blank_true_with_default(self): + serializer = self.several_choices_serializer() + self.assertEqual( + serializer.fields['music_genre'].choices, + BLANK_CHOICE_DASH + [('rock', 'Rock'), ('metal', 'Metal'), ('grunge', 'Grunge')] + ) + + class DepthTest(TestCase): def test_implicit_nesting(self): From 8fe43236a22e56d1741b49b92f0c53e01cd9e5f6 Mon Sep 17 00:00:00 2001 From: Pablo Recio Date: Sat, 18 May 2013 13:23:38 +0200 Subject: [PATCH 063/197] Moved test model into closer to the testcase --- rest_framework/tests/models.py | 26 -------------------------- rest_framework/tests/serializer.py | 28 +++++++++++++++++++++++++++- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/rest_framework/tests/models.py b/rest_framework/tests/models.py index 5d98b04bd..40e41a644 100644 --- a/rest_framework/tests/models.py +++ b/rest_framework/tests/models.py @@ -117,32 +117,6 @@ class OptionalRelationModel(RESTFrameworkModel): other = models.ForeignKey('OptionalRelationModel', blank=True, null=True) -# Model for issue #725 -class SeveralChoicesModel(RESTFrameworkModel): - color = models.CharField( - max_length=10, - choices=[('red', 'Red'), ('green', 'Green'), ('blue', 'Blue')], - blank=False - ) - drink = models.CharField( - max_length=10, - choices=[('beer', 'Beer'), ('wine', 'Wine'), ('cider', 'Cider')], - blank=False, - default='beer' - ) - os = models.CharField( - max_length=10, - choices=[('linux', 'Linux'), ('osx', 'OSX'), ('windows', 'Windows')], - blank=True - ) - music_genre = models.CharField( - max_length=10, - choices=[('rock', 'Rock'), ('metal', 'Metal'), ('grunge', 'Grunge')], - blank=True, - default='metal' - ) - - # Model for RegexField class Book(RESTFrameworkModel): isbn = models.CharField(max_length=13) diff --git a/rest_framework/tests/serializer.py b/rest_framework/tests/serializer.py index 85b952835..c043f4175 100644 --- a/rest_framework/tests/serializer.py +++ b/rest_framework/tests/serializer.py @@ -1,11 +1,12 @@ from __future__ import unicode_literals +from django.db import models from django.db.models.fields import BLANK_CHOICE_DASH from django.utils.datastructures import MultiValueDict from django.test import TestCase from rest_framework import serializers from rest_framework.tests.models import (HasPositiveIntegerAsChoice, Album, ActionItem, Anchor, BasicModel, BlankFieldModel, BlogPost, BlogPostComment, Book, CallableDefaultValueModel, DefaultValueModel, - ManyToManyModel, Person, ReadOnlyManyToManyModel, Photo, SeveralChoicesModel) + ManyToManyModel, Person, ReadOnlyManyToManyModel, Photo) import datetime import pickle @@ -1003,6 +1004,31 @@ class SerializerPickleTests(TestCase): # test for issue #725 +class SeveralChoicesModel(models.Model): + color = models.CharField( + max_length=10, + choices=[('red', 'Red'), ('green', 'Green'), ('blue', 'Blue')], + blank=False + ) + drink = models.CharField( + max_length=10, + choices=[('beer', 'Beer'), ('wine', 'Wine'), ('cider', 'Cider')], + blank=False, + default='beer' + ) + os = models.CharField( + max_length=10, + choices=[('linux', 'Linux'), ('osx', 'OSX'), ('windows', 'Windows')], + blank=True + ) + music_genre = models.CharField( + max_length=10, + choices=[('rock', 'Rock'), ('metal', 'Metal'), ('grunge', 'Grunge')], + blank=True, + default='metal' + ) + + class SerializerChoiceFields(TestCase): def setUp(self): From a0e3c44c99a61a6dc878308bdf0890fbb10c41e4 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Sat, 18 May 2013 13:40:20 +0200 Subject: [PATCH 064/197] Added @craigds, @pyriku, @brianz - Yay for sprints! --- docs/topics/credits.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/topics/credits.md b/docs/topics/credits.md index 8151b4d3a..5998b4ca4 100644 --- a/docs/topics/credits.md +++ b/docs/topics/credits.md @@ -124,6 +124,9 @@ The following people have helped make REST framework great. * Marlon Bailey - [avinash240] * James Summerfield - [jsummerfield] * Andy Freeland - [rouge8] +* Craig de Stigter - [craigds] +* Pablo Recio - [pyriku] +* Brian Zambrano - [brianz] Many thanks to everyone who's contributed to the project. @@ -284,3 +287,6 @@ You can also contact [@_tomchristie][twitter] directly on twitter. [avinash240]: https://github.com/avinash240 [jsummerfield]: https://github.com/jsummerfield [rouge8]: https://github.com/rouge8 +[craigds]: https://github.com/craigds +[pyriku]: https://github.com/pyriku +[brianz]: https://github.com/brianz From 611919aa0aee261100a3cbfa9ed9b746d56ce3da Mon Sep 17 00:00:00 2001 From: Mark McArdle Date: Sat, 18 May 2013 13:10:05 +0100 Subject: [PATCH 065/197] Initial commit of fix for https://github.com/tomchristie/django-rest-framework/issues/775 --- .../rest_framework/css/bootstrap-tweaks.css | 159 ++++++++++++++++++ .../static/rest_framework/css/default.css | 149 ---------------- .../templates/rest_framework/base.html | 13 +- .../templates/rest_framework/login_base.html | 6 +- 4 files changed, 170 insertions(+), 157 deletions(-) diff --git a/rest_framework/static/rest_framework/css/bootstrap-tweaks.css b/rest_framework/static/rest_framework/css/bootstrap-tweaks.css index c650ef2e9..69e1e70d7 100644 --- a/rest_framework/static/rest_framework/css/bootstrap-tweaks.css +++ b/rest_framework/static/rest_framework/css/bootstrap-tweaks.css @@ -19,4 +19,163 @@ a single block in the template. .navbar-inverse .brand:hover a { color: white; text-decoration: none; +} + +/* custom navigation styles */ +.wrapper .navbar{ + width: 100%; + position: absolute; + left: 0; + top: 0; +} + +.navbar .navbar-inner{ + background: #2C2C2C; + color: white; + border: none; + border-top: 5px solid #A30000; + border-radius: 0px; +} + +.navbar .navbar-inner .nav li, .navbar .navbar-inner .nav li a, .navbar .navbar-inner .brand{ + color: white; +} + +.nav-list > .active > a, .nav-list > .active > a:hover { + background: #2c2c2c; +} + +.navbar .navbar-inner .dropdown-menu li a, .navbar .navbar-inner .dropdown-menu li{ + color: #A30000; +} +.navbar .navbar-inner .dropdown-menu li a:hover{ + background: #eeeeee; + color: #c20000; +} + +/*=== dabapps bootstrap styles ====*/ + +html{ + width:100%; + background: none; +} + +body, .navbar .navbar-inner .container-fluid { + max-width: 1150px; + margin: 0 auto; +} + +body{ + background: url("../img/grid.png") repeat-x; + background-attachment: fixed; +} + +#content{ + margin: 0; +} + +/* sticky footer and footer */ +html, body { + height: 100%; +} +.wrapper { + min-height: 100%; + height: auto !important; + height: 100%; + margin: 0 auto -60px; +} + +.form-switcher { + margin-bottom: 0; +} + +.well { + -webkit-box-shadow: none; + -moz-box-shadow: none; + box-shadow: none; +} + +.well .form-actions { + padding-bottom: 0; + margin-bottom: 0; +} + +.well form { + margin-bottom: 0; +} + +.nav-tabs { + border: 0; +} + +.nav-tabs > li { + float: right; +} + +.nav-tabs li a { + margin-right: 0; +} + +.nav-tabs > .active > a { + background: #f5f5f5; +} + +.nav-tabs > .active > a:hover { + background: #f5f5f5; +} + +.tabbable.first-tab-active .tab-content +{ + border-top-right-radius: 0; +} + +#footer, #push { + height: 60px; /* .push must be the same height as .footer */ +} + +#footer{ + text-align: right; +} + +#footer p { + text-align: center; + color: gray; + border-top: 1px solid #DDD; + padding-top: 10px; +} + +#footer a { + color: gray; + font-weight: bold; +} + +#footer a:hover { + color: gray; +} + +.page-header { + border-bottom: none; + padding-bottom: 0px; + margin-bottom: 20px; +} + +/* custom general page styles */ +.hero-unit h2, .hero-unit h1{ + color: #A30000; +} + +body a, body a{ + color: #A30000; +} + +body a:hover{ + color: #c20000; +} + +#content a span{ + text-decoration: underline; + } + +.request-info { + clear:both; } \ No newline at end of file diff --git a/rest_framework/static/rest_framework/css/default.css b/rest_framework/static/rest_framework/css/default.css index d806267bc..0261a3038 100644 --- a/rest_framework/static/rest_framework/css/default.css +++ b/rest_framework/static/rest_framework/css/default.css @@ -69,152 +69,3 @@ pre { margin-bottom: 20px; } - -/*=== dabapps bootstrap styles ====*/ - -html{ - width:100%; - background: none; -} - -body, .navbar .navbar-inner .container-fluid { - max-width: 1150px; - margin: 0 auto; -} - -body{ - background: url("../img/grid.png") repeat-x; - background-attachment: fixed; -} - -#content{ - margin: 0; -} -/* custom navigation styles */ -.wrapper .navbar{ - width: 100%; - position: absolute; - left: 0; - top: 0; -} - -.navbar .navbar-inner{ - background: #2C2C2C; - color: white; - border: none; - border-top: 5px solid #A30000; - border-radius: 0px; -} - -.navbar .navbar-inner .nav li, .navbar .navbar-inner .nav li a, .navbar .navbar-inner .brand{ - color: white; -} - -.nav-list > .active > a, .nav-list > .active > a:hover { - background: #2c2c2c; -} - -.navbar .navbar-inner .dropdown-menu li a, .navbar .navbar-inner .dropdown-menu li{ - color: #A30000; -} -.navbar .navbar-inner .dropdown-menu li a:hover{ - background: #eeeeee; - color: #c20000; -} - -/* custom general page styles */ -.hero-unit h2, .hero-unit h1{ - color: #A30000; -} - -body a, body a{ - color: #A30000; -} - -body a:hover{ - color: #c20000; -} - -#content a span{ - text-decoration: underline; - } - -/* sticky footer and footer */ -html, body { - height: 100%; -} -.wrapper { - min-height: 100%; - height: auto !important; - height: 100%; - margin: 0 auto -60px; -} - -.form-switcher { - margin-bottom: 0; -} - -.well { - -webkit-box-shadow: none; - -moz-box-shadow: none; - box-shadow: none; -} - -.well .form-actions { - padding-bottom: 0; - margin-bottom: 0; -} - -.well form { - margin-bottom: 0; -} - -.nav-tabs { - border: 0; -} - -.nav-tabs > li { - float: right; -} - -.nav-tabs li a { - margin-right: 0; -} - -.nav-tabs > .active > a { - background: #f5f5f5; -} - -.nav-tabs > .active > a:hover { - background: #f5f5f5; -} - -.tabbable.first-tab-active .tab-content -{ - border-top-right-radius: 0; -} - -#footer, #push { - height: 60px; /* .push must be the same height as .footer */ -} - -#footer{ - text-align: right; -} - -#footer p { - text-align: center; - color: gray; - border-top: 1px solid #DDD; - padding-top: 10px; -} - -#footer a { - color: gray; - font-weight: bold; -} - -#footer a:hover { - color: gray; -} - diff --git a/rest_framework/templates/rest_framework/base.html b/rest_framework/templates/rest_framework/base.html index 4410f285f..9d939e738 100644 --- a/rest_framework/templates/rest_framework/base.html +++ b/rest_framework/templates/rest_framework/base.html @@ -13,8 +13,10 @@ {% block title %}Django REST framework{% endblock %} {% block style %} - {% block bootstrap_theme %}{% endblock %} - + {% block bootstrap_theme %} + + + {% endblock %} {% endblock %} @@ -30,8 +32,8 @@