From 9ae0ca1caeb7d195719b9544da2a3a7c4fc85b26 Mon Sep 17 00:00:00 2001 From: Michal Dvorak (cen38289) Date: Mon, 3 Dec 2012 17:26:01 +0100 Subject: #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(-) (limited to 'rest_framework') diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 482a3d48..907bab74 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 4519ab05..2dab7914 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, -- cgit v1.2.3 From ad01fa0eae990ca1607d44cbabba5425c9d0b9a4 Mon Sep 17 00:00:00 2001 From: Michal Dvorak Date: Mon, 3 Dec 2012 19:07:07 +0100 Subject: #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(-) (limited to 'rest_framework') diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index 2dab7914..e4fcbd67 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 c35861c6..a13f1ef3 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 61a05da1..cc83a740 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) -- cgit v1.2.3 From dea0f9129c770b6a9ccebce7296235b529fa59e7 Mon Sep 17 00:00:00 2001 From: Michal Dvorak Date: Mon, 3 Dec 2012 19:10:57 +0100 Subject: Fixed screwed formatting --- rest_framework/fields.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) (limited to 'rest_framework') diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 907bab74..74b4cb7c 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 -- cgit v1.2.3 From a7849157bcfb8eb07b0ac934ae7c49a965bf6875 Mon Sep 17 00:00:00 2001 From: Michal Dvorak (cen38289) Date: Tue, 4 Dec 2012 10:00:14 +0100 Subject: Moved ctor test to separate unit test --- rest_framework/tests/serializer.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) (limited to 'rest_framework') diff --git a/rest_framework/tests/serializer.py b/rest_framework/tests/serializer.py index cc83a740..76c9c465 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') -- cgit v1.2.3 From 2a82b64963792b353a7a2634c003692bd4957c9f Mon Sep 17 00:00:00 2001 From: Michal Dvorak (cen38289) Date: Tue, 4 Dec 2012 14:16:45 +0100 Subject: 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(-) (limited to 'rest_framework') diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 74b4cb7c..f57dc57f 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 e4fcbd67..37496be3 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 76c9c465..44adf92e 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) -- cgit v1.2.3 From 84be169353f0dd2ceb06fe459b72aa2452fcbeb5 Mon Sep 17 00:00:00 2001 From: Craig de Stigter Date: Fri, 1 Mar 2013 16:13:04 +1300 Subject: 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(-) (limited to 'rest_framework') diff --git a/rest_framework/relations.py b/rest_framework/relations.py index 0c108717..ef465b3c 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()] -- cgit v1.2.3 From 0081d744b9f530b2418d1e82d7ad94a2ebc31c9c Mon Sep 17 00:00:00 2001 From: Matteo Suppo Date: Sat, 23 Mar 2013 14:18:11 +0100 Subject: Added tests for issue 747 in serializer.py --- rest_framework/tests/serializer.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) (limited to 'rest_framework') diff --git a/rest_framework/tests/serializer.py b/rest_framework/tests/serializer.py index 05217f35..0386ca76 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")) -- cgit v1.2.3 From b5640bb77843c50f42a649982b9b9592113c6f59 Mon Sep 17 00:00:00 2001 From: Matteo Suppo Date: Sat, 23 Mar 2013 14:18:55 +0100 Subject: Forcing translations of lazy translatable strings in Field to_native method --- rest_framework/fields.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'rest_framework') diff --git a/rest_framework/fields.py b/rest_framework/fields.py index f3496b53..09f076ab 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): """ -- cgit v1.2.3 From 31f94ab409f1d5f41982a5946b980cf3ad8e3ba9 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 9 May 2013 13:31:42 +0100 Subject: Added GenericViewSet and docs tweaking --- rest_framework/viewsets.py | 9 +++++++++ 1 file changed, 9 insertions(+) (limited to 'rest_framework') diff --git a/rest_framework/viewsets.py b/rest_framework/viewsets.py index 0eb3e86d..7c820091 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, -- cgit v1.2.3 From 939cc5adba6f5a95aac317134eb841838a0bff3f Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 9 May 2013 13:35:01 +0100 Subject: Tweak inheritance --- rest_framework/viewsets.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) (limited to 'rest_framework') diff --git a/rest_framework/viewsets.py b/rest_framework/viewsets.py index 7c820091..d91323f2 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. -- cgit v1.2.3 From 0176a5391b5d0c5c5dd61133f17b9b68840d6e1a Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 9 May 2013 17:09:40 +0100 Subject: Fix HyperlinkedModelSerializer not respecting lookup_fields --- rest_framework/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'rest_framework') diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index ea5175e2..d7a4c9ef 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): -- cgit v1.2.3 From 773a92eab3ac4b635511483ef906b3b8de9dedc9 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 10 May 2013 21:57:05 +0100 Subject: 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(-) (limited to 'rest_framework') diff --git a/rest_framework/tests/models.py b/rest_framework/tests/models.py index f2117538..40e41a64 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 6b8ef02f..894d53d6 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. -- cgit v1.2.3 From 8ce36d2bf1a899683208dc7de425a238ab27d0b3 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 10 May 2013 21:57:20 +0100 Subject: SearchFilter and tests --- rest_framework/filters.py | 9 ++++- rest_framework/tests/filterset.py | 81 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 3 deletions(-) (limited to 'rest_framework') diff --git a/rest_framework/filters.py b/rest_framework/filters.py index f2163f6f..54cbbde3 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 023bd016..7865fedd 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'} + ] + ) -- cgit v1.2.3 From 293dc3e6d8071fb464a63593831309468e457d6b Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 10 May 2013 22:33:11 +0100 Subject: Added SearchFilter --- rest_framework/filters.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) (limited to 'rest_framework') diff --git a/rest_framework/filters.py b/rest_framework/filters.py index 54cbbde3..3edef30d 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)) -- cgit v1.2.3 From dd51d369c8228f3add37cc639702097b0df9cd90 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 10 May 2013 23:02:24 +0100 Subject: Unicode string fix --- rest_framework/tests/filterset.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) (limited to 'rest_framework') diff --git a/rest_framework/tests/filterset.py b/rest_framework/tests/filterset.py index 7865fedd..e5414232 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'} ] ) -- cgit v1.2.3 From fd4a66cfc7888775d20b18665d63156cf3dae13a Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 10 May 2013 23:06:42 +0100 Subject: Fix py3k compat with functools.reduce --- rest_framework/filters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'rest_framework') diff --git a/rest_framework/filters.py b/rest_framework/filters.py index 3edef30d..57f0f7c8 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 -- cgit v1.2.3 From 9d2580dccfe23e113221c7e150bddebb95d98214 Mon Sep 17 00:00:00 2001 From: Marlon Bailey Date: Sat, 11 May 2013 22:26:34 -0400 Subject: 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 (limited to 'rest_framework') diff --git a/rest_framework/routers.py b/rest_framework/routers.py index 0707635a..ebdf2b2a 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 00000000..138d13d7 --- /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) -- cgit v1.2.3 From 5e2d8052d4bf87c81cc9807c96c933ca975cc483 Mon Sep 17 00:00:00 2001 From: Marlon Bailey Date: Sun, 12 May 2013 09:22:14 -0400 Subject: 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(-) (limited to 'rest_framework') diff --git a/rest_framework/tests/routers.py b/rest_framework/tests/routers.py index 138d13d7..4e4765cb 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) + + -- cgit v1.2.3 From 5074bbe4b21a0fc116e4288743fb78314a76a33b Mon Sep 17 00:00:00 2001 From: James Summerfield Date: Mon, 13 May 2013 07:51:23 +0200 Subject: 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(-) (limited to 'rest_framework') diff --git a/rest_framework/templates/rest_framework/login_base.html b/rest_framework/templates/rest_framework/login_base.html index 380d5820..a3e73b6b 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 %} +
+
+ + +
+
+
+
+ + +
+
+ +
+ +
+
- -
-
-
- -
-
- -
+
+
+
+
-- cgit v1.2.3 From 24c9c455feaa47487196a2c9343746d7d5bdd962 Mon Sep 17 00:00:00 2001 From: Brian Zambrano Date: Mon, 13 May 2013 10:51:51 -0700 Subject: 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(-) (limited to 'rest_framework') diff --git a/rest_framework/fields.py b/rest_framework/fields.py index c83ee5ec..1f38b795 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 84e1ee4e..6e732327 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 -- cgit v1.2.3 From 752c01420f7574cd99e28a17d56df711b675ce71 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 14 May 2013 10:01:05 +0100 Subject: Fix Django 1.3 compat with routers --- rest_framework/routers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'rest_framework') diff --git a/rest_framework/routers.py b/rest_framework/routers.py index 0707635a..ed4dc338 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 -- cgit v1.2.3 From b2bf5f1f886d131957f99308a0da89b24b3352d4 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 14 May 2013 10:10:44 +0100 Subject: SearchFilter may be comma and/or whitespace seperated --- rest_framework/filters.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) (limited to 'rest_framework') diff --git a/rest_framework/filters.py b/rest_framework/filters.py index 57f0f7c8..c496ec4b 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)) -- cgit v1.2.3 From 08bc97626960f108f01657e4ad12b7fd62e6183d Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 14 May 2013 10:16:46 +0100 Subject: Rename filter tests --- rest_framework/tests/filters.py | 337 ++++++++++++++++++++++++++++++++++++++ rest_framework/tests/filterset.py | 337 -------------------------------------- 2 files changed, 337 insertions(+), 337 deletions(-) create mode 100644 rest_framework/tests/filters.py delete mode 100644 rest_framework/tests/filterset.py (limited to 'rest_framework') diff --git a/rest_framework/tests/filters.py b/rest_framework/tests/filters.py new file mode 100644 index 00000000..e5414232 --- /dev/null +++ b/rest_framework/tests/filters.py @@ -0,0 +1,337 @@ +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 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): + model = FilterableItem + filter_fields = ['decimal', 'date'] + filter_backend = filters.DjangoFilterBackend + + # These class are used to test a filter class. + class SeveralFieldsFilter(django_filters.FilterSet): + text = django_filters.CharFilter(lookup_type='icontains') + decimal = django_filters.NumberFilter(lookup_type='lt') + date = django_filters.DateFilter(lookup_type='gt') + + class Meta: + model = FilterableItem + fields = ['text', 'decimal', 'date'] + + class FilterClassRootView(generics.ListCreateAPIView): + model = FilterableItem + filter_class = SeveralFieldsFilter + filter_backend = filters.DjangoFilterBackend + + # These classes are used to test a misconfigured filter class. + class MisconfiguredFilter(django_filters.FilterSet): + text = django_filters.CharFilter(lookup_type='icontains') + + class Meta: + model = BasicModel + fields = ['text'] + + class IncorrectlyConfiguredRootView(generics.ListCreateAPIView): + model = FilterableItem + filter_class = MisconfiguredFilter + filter_backend = filters.DjangoFilterBackend + + class FilterClassDetailView(generics.RetrieveAPIView): + model = FilterableItem + filter_class = SeveralFieldsFilter + filter_backend = filters.DjangoFilterBackend + + # Regression test for #814 + class FilterableItemSerializer(serializers.ModelSerializer): + class Meta: + model = FilterableItem + + class FilterFieldsQuerysetView(generics.ListCreateAPIView): + queryset = FilterableItem.objects.all() + serializer_class = FilterableItemSerializer + filter_fields = ['decimal', 'date'] + filter_backend = filters.DjangoFilterBackend + + urlpatterns = patterns('', + url(r'^(?P\d+)/$', FilterClassDetailView.as_view(), name='detail-view'), + url(r'^$', FilterClassRootView.as_view(), name='root-view'), + ) + + +class CommonFilteringTestCase(TestCase): + def _serialize_object(self, obj): + return {'id': obj.id, 'text': obj.text, 'decimal': obj.decimal, 'date': obj.date} + + def setUp(self): + """ + Create 10 FilterableItem instances. + """ + base_data = ('a', Decimal('0.25'), datetime.date(2012, 10, 8)) + for i in range(10): + text = chr(i + ord(base_data[0])) * 3 # Produces string 'aaa', 'bbb', etc. + decimal = base_data[1] + i + date = base_data[2] - datetime.timedelta(days=i * 2) + FilterableItem(text=text, decimal=decimal, date=date).save() + + self.objects = FilterableItem.objects + self.data = [ + self._serialize_object(obj) + for obj in self.objects.all() + ] + + +class IntegrationTestFiltering(CommonFilteringTestCase): + """ + Integration tests for filtered list views. + """ + + @unittest.skipUnless(django_filters, 'django-filters not installed') + def test_get_filtered_fields_root_view(self): + """ + GET requests to paginated ListCreateAPIView should return paginated results. + """ + view = FilterFieldsRootView.as_view() + + # Basic test with no filter. + request = factory.get('/') + response = view(request).render() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, self.data) + + # Tests that the decimal filter works. + search_decimal = Decimal('2.25') + request = factory.get('/?decimal=%s' % search_decimal) + response = view(request).render() + self.assertEqual(response.status_code, status.HTTP_200_OK) + expected_data = [f for f in self.data if f['decimal'] == search_decimal] + self.assertEqual(response.data, expected_data) + + # Tests that the date filter works. + search_date = datetime.date(2012, 9, 22) + request = factory.get('/?date=%s' % search_date) # search_date str: '2012-09-22' + response = view(request).render() + self.assertEqual(response.status_code, status.HTTP_200_OK) + expected_data = [f for f in self.data if f['date'] == search_date] + self.assertEqual(response.data, expected_data) + + @unittest.skipUnless(django_filters, 'django-filters not installed') + def test_filter_with_queryset(self): + """ + Regression test for #814. + """ + view = FilterFieldsQuerysetView.as_view() + + # Tests that the decimal filter works. + search_decimal = Decimal('2.25') + request = factory.get('/?decimal=%s' % search_decimal) + response = view(request).render() + self.assertEqual(response.status_code, status.HTTP_200_OK) + 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_get_filtered_class_root_view(self): + """ + GET requests to filtered ListCreateAPIView that have a filter_class set + should return filtered results. + """ + view = FilterClassRootView.as_view() + + # Basic test with no filter. + request = factory.get('/') + response = view(request).render() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, self.data) + + # Tests that the decimal filter set with 'lt' in the filter class works. + search_decimal = Decimal('4.25') + request = factory.get('/?decimal=%s' % search_decimal) + response = view(request).render() + self.assertEqual(response.status_code, status.HTTP_200_OK) + expected_data = [f for f in self.data if f['decimal'] < search_decimal] + self.assertEqual(response.data, expected_data) + + # Tests that the date filter set with 'gt' in the filter class works. + search_date = datetime.date(2012, 10, 2) + request = factory.get('/?date=%s' % search_date) # search_date str: '2012-10-02' + response = view(request).render() + self.assertEqual(response.status_code, status.HTTP_200_OK) + expected_data = [f for f in self.data if f['date'] > search_date] + self.assertEqual(response.data, expected_data) + + # Tests that the text filter set with 'icontains' in the filter class works. + search_text = 'ff' + request = factory.get('/?text=%s' % search_text) + response = view(request).render() + self.assertEqual(response.status_code, status.HTTP_200_OK) + expected_data = [f for f in self.data if search_text in f['text'].lower()] + self.assertEqual(response.data, expected_data) + + # Tests that multiple filters works. + search_decimal = Decimal('5.25') + search_date = datetime.date(2012, 10, 2) + request = factory.get('/?decimal=%s&date=%s' % (search_decimal, search_date)) + response = view(request).render() + self.assertEqual(response.status_code, status.HTTP_200_OK) + expected_data = [f for f in self.data if f['date'] > search_date and + f['decimal'] < search_decimal] + self.assertEqual(response.data, expected_data) + + @unittest.skipUnless(django_filters, 'django-filters not installed') + def test_incorrectly_configured_filter(self): + """ + An error should be displayed when the filter class is misconfigured. + """ + view = IncorrectlyConfiguredRootView.as_view() + + request = factory.get('/') + self.assertRaises(AssertionError, view, request) + + @unittest.skipUnless(django_filters, 'django-filters not installed') + def test_unknown_filter(self): + """ + GET requests with filters that aren't configured should return 200. + """ + view = FilterFieldsRootView.as_view() + + search_integer = 10 + request = factory.get('/?integer=%s' % search_integer) + response = view(request).render() + self.assertEqual(response.status_code, status.HTTP_200_OK) + + +class IntegrationTestDetailFiltering(CommonFilteringTestCase): + """ + Integration tests for filtered detail views. + """ + urls = 'rest_framework.tests.filterset' + + def _get_url(self, item): + return reverse('detail-view', kwargs=dict(pk=item.pk)) + + @unittest.skipUnless(django_filters, 'django-filters not installed') + def test_get_filtered_detail_view(self): + """ + GET requests to filtered RetrieveAPIView that have a filter_class set + should return filtered results. + """ + item = self.objects.all()[0] + data = self._serialize_object(item) + + # Basic test with no filter. + response = self.client.get(self._get_url(item)) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, data) + + # Tests that the decimal filter set that should fail. + search_decimal = Decimal('4.25') + high_item = self.objects.filter(decimal__gt=search_decimal)[0] + response = self.client.get('{url}?decimal={param}'.format(url=self._get_url(high_item), param=search_decimal)) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + # Tests that the decimal filter set that should succeed. + search_decimal = Decimal('4.25') + low_item = self.objects.filter(decimal__lt=search_decimal)[0] + low_item_data = self._serialize_object(low_item) + response = self.client.get('{url}?decimal={param}'.format(url=self._get_url(low_item), param=search_decimal)) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, low_item_data) + + # Tests that multiple filters works. + search_decimal = Decimal('5.25') + search_date = datetime.date(2012, 10, 2) + valid_item = self.objects.filter(decimal__lt=search_decimal, date__gt=search_date)[0] + valid_item_data = self._serialize_object(valid_item) + 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, + [ + {'id': 1, 'title': 'z', 'text': 'abc'}, + {'id': 2, 'title': 'zz', 'text': '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, + [ + {'id': 3, 'title': 'zzz', 'text': '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, + [ + {'id': 2, 'title': 'zz', 'text': 'bcd'} + ] + ) diff --git a/rest_framework/tests/filterset.py b/rest_framework/tests/filterset.py deleted file mode 100644 index e5414232..00000000 --- a/rest_framework/tests/filterset.py +++ /dev/null @@ -1,337 +0,0 @@ -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 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): - model = FilterableItem - filter_fields = ['decimal', 'date'] - filter_backend = filters.DjangoFilterBackend - - # These class are used to test a filter class. - class SeveralFieldsFilter(django_filters.FilterSet): - text = django_filters.CharFilter(lookup_type='icontains') - decimal = django_filters.NumberFilter(lookup_type='lt') - date = django_filters.DateFilter(lookup_type='gt') - - class Meta: - model = FilterableItem - fields = ['text', 'decimal', 'date'] - - class FilterClassRootView(generics.ListCreateAPIView): - model = FilterableItem - filter_class = SeveralFieldsFilter - filter_backend = filters.DjangoFilterBackend - - # These classes are used to test a misconfigured filter class. - class MisconfiguredFilter(django_filters.FilterSet): - text = django_filters.CharFilter(lookup_type='icontains') - - class Meta: - model = BasicModel - fields = ['text'] - - class IncorrectlyConfiguredRootView(generics.ListCreateAPIView): - model = FilterableItem - filter_class = MisconfiguredFilter - filter_backend = filters.DjangoFilterBackend - - class FilterClassDetailView(generics.RetrieveAPIView): - model = FilterableItem - filter_class = SeveralFieldsFilter - filter_backend = filters.DjangoFilterBackend - - # Regression test for #814 - class FilterableItemSerializer(serializers.ModelSerializer): - class Meta: - model = FilterableItem - - class FilterFieldsQuerysetView(generics.ListCreateAPIView): - queryset = FilterableItem.objects.all() - serializer_class = FilterableItemSerializer - filter_fields = ['decimal', 'date'] - filter_backend = filters.DjangoFilterBackend - - urlpatterns = patterns('', - url(r'^(?P\d+)/$', FilterClassDetailView.as_view(), name='detail-view'), - url(r'^$', FilterClassRootView.as_view(), name='root-view'), - ) - - -class CommonFilteringTestCase(TestCase): - def _serialize_object(self, obj): - return {'id': obj.id, 'text': obj.text, 'decimal': obj.decimal, 'date': obj.date} - - def setUp(self): - """ - Create 10 FilterableItem instances. - """ - base_data = ('a', Decimal('0.25'), datetime.date(2012, 10, 8)) - for i in range(10): - text = chr(i + ord(base_data[0])) * 3 # Produces string 'aaa', 'bbb', etc. - decimal = base_data[1] + i - date = base_data[2] - datetime.timedelta(days=i * 2) - FilterableItem(text=text, decimal=decimal, date=date).save() - - self.objects = FilterableItem.objects - self.data = [ - self._serialize_object(obj) - for obj in self.objects.all() - ] - - -class IntegrationTestFiltering(CommonFilteringTestCase): - """ - Integration tests for filtered list views. - """ - - @unittest.skipUnless(django_filters, 'django-filters not installed') - def test_get_filtered_fields_root_view(self): - """ - GET requests to paginated ListCreateAPIView should return paginated results. - """ - view = FilterFieldsRootView.as_view() - - # Basic test with no filter. - request = factory.get('/') - response = view(request).render() - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data, self.data) - - # Tests that the decimal filter works. - search_decimal = Decimal('2.25') - request = factory.get('/?decimal=%s' % search_decimal) - response = view(request).render() - self.assertEqual(response.status_code, status.HTTP_200_OK) - expected_data = [f for f in self.data if f['decimal'] == search_decimal] - self.assertEqual(response.data, expected_data) - - # Tests that the date filter works. - search_date = datetime.date(2012, 9, 22) - request = factory.get('/?date=%s' % search_date) # search_date str: '2012-09-22' - response = view(request).render() - self.assertEqual(response.status_code, status.HTTP_200_OK) - expected_data = [f for f in self.data if f['date'] == search_date] - self.assertEqual(response.data, expected_data) - - @unittest.skipUnless(django_filters, 'django-filters not installed') - def test_filter_with_queryset(self): - """ - Regression test for #814. - """ - view = FilterFieldsQuerysetView.as_view() - - # Tests that the decimal filter works. - search_decimal = Decimal('2.25') - request = factory.get('/?decimal=%s' % search_decimal) - response = view(request).render() - self.assertEqual(response.status_code, status.HTTP_200_OK) - 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_get_filtered_class_root_view(self): - """ - GET requests to filtered ListCreateAPIView that have a filter_class set - should return filtered results. - """ - view = FilterClassRootView.as_view() - - # Basic test with no filter. - request = factory.get('/') - response = view(request).render() - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data, self.data) - - # Tests that the decimal filter set with 'lt' in the filter class works. - search_decimal = Decimal('4.25') - request = factory.get('/?decimal=%s' % search_decimal) - response = view(request).render() - self.assertEqual(response.status_code, status.HTTP_200_OK) - expected_data = [f for f in self.data if f['decimal'] < search_decimal] - self.assertEqual(response.data, expected_data) - - # Tests that the date filter set with 'gt' in the filter class works. - search_date = datetime.date(2012, 10, 2) - request = factory.get('/?date=%s' % search_date) # search_date str: '2012-10-02' - response = view(request).render() - self.assertEqual(response.status_code, status.HTTP_200_OK) - expected_data = [f for f in self.data if f['date'] > search_date] - self.assertEqual(response.data, expected_data) - - # Tests that the text filter set with 'icontains' in the filter class works. - search_text = 'ff' - request = factory.get('/?text=%s' % search_text) - response = view(request).render() - self.assertEqual(response.status_code, status.HTTP_200_OK) - expected_data = [f for f in self.data if search_text in f['text'].lower()] - self.assertEqual(response.data, expected_data) - - # Tests that multiple filters works. - search_decimal = Decimal('5.25') - search_date = datetime.date(2012, 10, 2) - request = factory.get('/?decimal=%s&date=%s' % (search_decimal, search_date)) - response = view(request).render() - self.assertEqual(response.status_code, status.HTTP_200_OK) - expected_data = [f for f in self.data if f['date'] > search_date and - f['decimal'] < search_decimal] - self.assertEqual(response.data, expected_data) - - @unittest.skipUnless(django_filters, 'django-filters not installed') - def test_incorrectly_configured_filter(self): - """ - An error should be displayed when the filter class is misconfigured. - """ - view = IncorrectlyConfiguredRootView.as_view() - - request = factory.get('/') - self.assertRaises(AssertionError, view, request) - - @unittest.skipUnless(django_filters, 'django-filters not installed') - def test_unknown_filter(self): - """ - GET requests with filters that aren't configured should return 200. - """ - view = FilterFieldsRootView.as_view() - - search_integer = 10 - request = factory.get('/?integer=%s' % search_integer) - response = view(request).render() - self.assertEqual(response.status_code, status.HTTP_200_OK) - - -class IntegrationTestDetailFiltering(CommonFilteringTestCase): - """ - Integration tests for filtered detail views. - """ - urls = 'rest_framework.tests.filterset' - - def _get_url(self, item): - return reverse('detail-view', kwargs=dict(pk=item.pk)) - - @unittest.skipUnless(django_filters, 'django-filters not installed') - def test_get_filtered_detail_view(self): - """ - GET requests to filtered RetrieveAPIView that have a filter_class set - should return filtered results. - """ - item = self.objects.all()[0] - data = self._serialize_object(item) - - # Basic test with no filter. - response = self.client.get(self._get_url(item)) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data, data) - - # Tests that the decimal filter set that should fail. - search_decimal = Decimal('4.25') - high_item = self.objects.filter(decimal__gt=search_decimal)[0] - response = self.client.get('{url}?decimal={param}'.format(url=self._get_url(high_item), param=search_decimal)) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - # Tests that the decimal filter set that should succeed. - search_decimal = Decimal('4.25') - low_item = self.objects.filter(decimal__lt=search_decimal)[0] - low_item_data = self._serialize_object(low_item) - response = self.client.get('{url}?decimal={param}'.format(url=self._get_url(low_item), param=search_decimal)) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data, low_item_data) - - # Tests that multiple filters works. - search_decimal = Decimal('5.25') - search_date = datetime.date(2012, 10, 2) - valid_item = self.objects.filter(decimal__lt=search_decimal, date__gt=search_date)[0] - valid_item_data = self._serialize_object(valid_item) - 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, - [ - {'id': 1, 'title': 'z', 'text': 'abc'}, - {'id': 2, 'title': 'zz', 'text': '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, - [ - {'id': 3, 'title': 'zzz', 'text': '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, - [ - {'id': 2, 'title': 'zz', 'text': 'bcd'} - ] - ) -- cgit v1.2.3 From 6a037f63edf33e7a76f56828cf68bfae4ccb4f80 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 14 May 2013 11:27:03 +0100 Subject: Added OrderingFilter --- rest_framework/filters.py | 41 +++++++++++++- rest_framework/tests/filters.py | 116 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 1 deletion(-) (limited to 'rest_framework') diff --git a/rest_framework/filters.py b/rest_framework/filters.py index c496ec4b..308e7da2 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 e5414232..6b604deb 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'}, + ] + ) -- cgit v1.2.3 From 2cff6e69dbe3828eca56d0ce60ffdfc80fed045c Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 14 May 2013 11:27:08 +0100 Subject: Added OrderingFilter --- rest_framework/filters.py | 2 +- rest_framework/tests/filters.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) (limited to 'rest_framework') diff --git a/rest_framework/filters.py b/rest_framework/filters.py index 308e7da2..6a3e055d 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 6b604deb..b20d5980 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, -- cgit v1.2.3 From a303d0f38c4758fc3aad412529922203e5785e29 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 14 May 2013 11:37:59 +0100 Subject: Fix filter test renaming --- rest_framework/tests/filters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'rest_framework') diff --git a/rest_framework/tests/filters.py b/rest_framework/tests/filters.py index b20d5980..18972c84 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)) -- cgit v1.2.3 From d62414147fa949af4db698afedae7b5506229a9f Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 14 May 2013 17:53:37 +0100 Subject: Fix assert messaging on fields/exclude checking. Closes #833 --- rest_framework/serializers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'rest_framework') diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index d7a4c9ef..ecff2c52 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) -- cgit v1.2.3 From e939e1755a94b50c87a82c0f777645e28fe91bf0 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 14 May 2013 21:40:55 +0100 Subject: 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(-) (limited to 'rest_framework') diff --git a/rest_framework/filters.py b/rest_framework/filters.py index 6a3e055d..34831dd7 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 18972c84..a58c66ae 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): """ -- cgit v1.2.3 From 092d5223eb7ea1bbf9b6bb967200cb3725e02112 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 15 May 2013 10:29:51 +0100 Subject: Fix searchfilter issues --- rest_framework/filters.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'rest_framework') diff --git a/rest_framework/filters.py b/rest_framework/filters.py index 34831dd7..c058bc71 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] -- cgit v1.2.3 From af88a5b1751da32018e8408eac01a91a5f63f8ce Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 15 May 2013 14:25:25 +0100 Subject: Test and fix which closes #652. --- rest_framework/serializers.py | 8 +++++++- rest_framework/tests/serializer.py | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) (limited to 'rest_framework') diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index ecff2c52..7707de7a 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 84e1ee4e..db3881f9 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): """ -- cgit v1.2.3 From aff88d15f7a483bca2da120339b1b346aa8b1d4c Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 16 May 2013 15:08:12 +0100 Subject: Version 2.3.3 --- rest_framework/__init__.py | 2 +- rest_framework/permissions.py | 5 +++++ rest_framework/routers.py | 17 ++++++++++------- 3 files changed, 16 insertions(+), 8 deletions(-) (limited to 'rest_framework') diff --git a/rest_framework/__init__.py b/rest_framework/__init__.py index b4961e2f..0b1e67fb 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 751f31a7..45fcfd66 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 76714fd0..dba104c3 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): """ -- cgit v1.2.3 From abe207b869c771187523efd3d189ffc0beba51c3 Mon Sep 17 00:00:00 2001 From: Andy Freeland Date: Thu, 16 May 2013 11:24:11 -0400 Subject: 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 --- rest_framework/relations.py | 21 ++++++++++++-- rest_framework/tests/hyperlinkedserializers.py | 40 ++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) (limited to 'rest_framework') diff --git a/rest_framework/relations.py b/rest_framework/relations.py index fc5054b2..c4b790d4 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 9a61f299..8fc6ba77 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' -- cgit v1.2.3 From 14ded26167b68aaf8316a6bf83b6be3e77c8bbd8 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 17 May 2013 21:28:33 +0100 Subject: PendingDeprecation warning to allow_empty --- rest_framework/mixins.py | 7 +++++++ 1 file changed, 7 insertions(+) (limited to 'rest_framework') diff --git a/rest_framework/mixins.py b/rest_framework/mixins.py index ae703771..55d21a70 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) -- cgit v1.2.3 From b6fb377c2b4b747597bc3291dadd52b633b135b4 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 17 May 2013 21:57:11 +0100 Subject: 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(-) (limited to 'rest_framework') diff --git a/rest_framework/tests/filters.py b/rest_framework/tests/filters.py index a58c66ae..8ae6d530 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 eca50d82..2799d143 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 894d53d6..e538a78e 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() -- cgit v1.2.3 From 34776da9249a5d73f822b3562bc56a5674b10ac7 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 17 May 2013 22:09:23 +0100 Subject: Minor mixin refactoring --- rest_framework/mixins.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) (limited to 'rest_framework') diff --git a/rest_framework/mixins.py b/rest_framework/mixins.py index 55d21a70..f3cd5868 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() -- cgit v1.2.3 From aea040161ae29ec4b5335be5164aa8e5ada506e3 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Sat, 18 May 2013 09:36:09 +0100 Subject: 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(-) (limited to 'rest_framework') diff --git a/rest_framework/renderers.py b/rest_framework/renderers.py index 1917a080..8361cd40 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 2799d143..15d87e86 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') -- cgit v1.2.3 From ed0bd195f58ae6c0502f9c54cbd34681875adb14 Mon Sep 17 00:00:00 2001 From: Xavier Ordoquy Date: Sat, 18 May 2013 12:07:44 +0200 Subject: Updated the dependencies version and added the ALLOWED_HOSTS for tests. --- rest_framework/runtests/settings.py | 2 ++ 1 file changed, 2 insertions(+) (limited to 'rest_framework') diff --git a/rest_framework/runtests/settings.py b/rest_framework/runtests/settings.py index 9b519f27..9dd7b545 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'), ) -- cgit v1.2.3 From 0cd7c80e6eaf3ca17d0fb8f8878054ce570e3932 Mon Sep 17 00:00:00 2001 From: Craig de Stigter Date: Sat, 18 May 2013 12:16:30 +0200 Subject: 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(-) (limited to 'rest_framework') diff --git a/rest_framework/tests/relations.py b/rest_framework/tests/relations.py index cbf93c65..f28f0de9 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 5ce8b567..51fe59e9 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 db3881f9..34acbaab 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): -- cgit v1.2.3 From 930bd4d0e1f9a74ee56a57ef36c93b1c1d124f91 Mon Sep 17 00:00:00 2001 From: Craig de Stigter Date: Sat, 18 May 2013 12:23:12 +0200 Subject: add tests for related field source for HyperlinkedRelatedField. #694 --- rest_framework/tests/relations_hyperlink.py | 48 +++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) (limited to 'rest_framework') diff --git a/rest_framework/tests/relations_hyperlink.py b/rest_framework/tests/relations_hyperlink.py index b1eed9a7..8fb4687f 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/']) -- cgit v1.2.3 From 3691cd2ffd26ee639b88aa9f516f211e0558d454 Mon Sep 17 00:00:00 2001 From: Markus Törnqvist Date: Sat, 18 May 2013 13:26:59 +0300 Subject: Use smart_text() instead of nonexistant smart_unicode() --- rest_framework/fields.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'rest_framework') diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 3c4e975a..e4da1456 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): """ -- cgit v1.2.3 From a73c16b85f79aeb9139734a64623b49bc169fce9 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Sat, 18 May 2013 11:27:48 +0100 Subject: 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(-) (limited to 'rest_framework') diff --git a/rest_framework/fields.py b/rest_framework/fields.py index c83ee5ec..49d2a6d5 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 3cdfa0f6..5b5ce835 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.']}) -- cgit v1.2.3 From 764299843cbedce9afd07db7226f639208ff0608 Mon Sep 17 00:00:00 2001 From: Markus Törnqvist Date: Sat, 18 May 2013 13:30:39 +0300 Subject: ManyHyperlinkedRelatedField comes form rest_framework.relations now --- rest_framework/tests/serializer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'rest_framework') diff --git a/rest_framework/tests/serializer.py b/rest_framework/tests/serializer.py index b0c7e568..fc433a8f 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) -- cgit v1.2.3 From c992b600f7b0aefb156cddb5e27b438ccc316b39 Mon Sep 17 00:00:00 2001 From: Craig de Stigter Date: Sat, 18 May 2013 12:32:48 +0200 Subject: 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(+) (limited to 'rest_framework') diff --git a/rest_framework/tests/relations.py b/rest_framework/tests/relations.py index f28f0de9..d19219c9 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 8fb4687f..b3efbf52 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 51fe59e9..0f8c5247 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]) -- cgit v1.2.3 From de5cc8de423a22009d2a643f6c268805f715b212 Mon Sep 17 00:00:00 2001 From: Pablo Recio Date: Sat, 18 May 2013 12:40:25 +0200 Subject: A model's field is required if is null or blank --- rest_framework/serializers.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) (limited to 'rest_framework') diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index 7707de7a..500bb306 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): -- cgit v1.2.3 From ab8bd566f9db327a4c463317011818d421bbf89c Mon Sep 17 00:00:00 2001 From: Pablo Recio Date: Sat, 18 May 2013 12:40:25 +0200 Subject: 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(-) (limited to 'rest_framework') diff --git a/rest_framework/fields.py b/rest_framework/fields.py index c83ee5ec..7fd4c638 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 3cdfa0f6..f313ba60 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 40e41a64..5d98b04b 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 db3881f9..3f39308d 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): -- cgit v1.2.3 From 8fe43236a22e56d1741b49b92f0c53e01cd9e5f6 Mon Sep 17 00:00:00 2001 From: Pablo Recio Date: Sat, 18 May 2013 13:23:38 +0200 Subject: 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(-) (limited to 'rest_framework') diff --git a/rest_framework/tests/models.py b/rest_framework/tests/models.py index 5d98b04b..40e41a64 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 85b95283..c043f417 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): -- cgit v1.2.3 From 611919aa0aee261100a3cbfa9ed9b746d56ce3da Mon Sep 17 00:00:00 2001 From: Mark McArdle Date: Sat, 18 May 2013 13:10:05 +0100 Subject: Initial commit of fix for https://github.com/tomchristie/django-rest-framework/issues/775 --- .../static/rest_framework/css/bootstrap-tweaks.css | 159 +++++++++++++++++++++ .../static/rest_framework/css/default.css | 149 ------------------- rest_framework/templates/rest_framework/base.html | 13 +- .../templates/rest_framework/login_base.html | 6 +- 4 files changed, 170 insertions(+), 157 deletions(-) (limited to 'rest_framework') diff --git a/rest_framework/static/rest_framework/css/bootstrap-tweaks.css b/rest_framework/static/rest_framework/css/bootstrap-tweaks.css index c650ef2e..69e1e70d 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 d806267b..0261a303 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 4410f285..9d939e73 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 @@