From e65445c9c99a44569716df7bbbfe498fcfb4879d Mon Sep 17 00:00:00 2001 From: Miroslav Shubernetskiy Date: Sun, 1 Feb 2015 17:47:45 -0500 Subject: [PATCH 01/16] moved existing mixins to drf2 package --- rest_framework_bulk/drf2/__init__.py | 1 + rest_framework_bulk/{ => drf2}/mixins.py | 0 rest_framework_bulk/drf3/__init__.py | 1 + 3 files changed, 2 insertions(+) create mode 100644 rest_framework_bulk/drf2/__init__.py rename rest_framework_bulk/{ => drf2}/mixins.py (100%) create mode 100644 rest_framework_bulk/drf3/__init__.py diff --git a/rest_framework_bulk/drf2/__init__.py b/rest_framework_bulk/drf2/__init__.py new file mode 100644 index 0000000..c957747 --- /dev/null +++ b/rest_framework_bulk/drf2/__init__.py @@ -0,0 +1 @@ +from __future__ import print_function, unicode_literals diff --git a/rest_framework_bulk/mixins.py b/rest_framework_bulk/drf2/mixins.py similarity index 100% rename from rest_framework_bulk/mixins.py rename to rest_framework_bulk/drf2/mixins.py diff --git a/rest_framework_bulk/drf3/__init__.py b/rest_framework_bulk/drf3/__init__.py new file mode 100644 index 0000000..c957747 --- /dev/null +++ b/rest_framework_bulk/drf3/__init__.py @@ -0,0 +1 @@ +from __future__ import print_function, unicode_literals From 840ceaed20ef9b55ef2511227f1b66b9a7b45a4f Mon Sep 17 00:00:00 2001 From: Miroslav Shubernetskiy Date: Sun, 1 Feb 2015 17:51:16 -0500 Subject: [PATCH 02/16] importing appropriate drf mixing depending on its version --- README.rst | 4 ++-- requirements.txt | 2 +- rest_framework_bulk/drf3/mixins.py | 1 + rest_framework_bulk/mixins.py | 12 ++++++++++++ 4 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 rest_framework_bulk/drf3/mixins.py create mode 100644 rest_framework_bulk/mixins.py diff --git a/README.rst b/README.rst index 929a76a..d0ad780 100644 --- a/README.rst +++ b/README.rst @@ -21,9 +21,9 @@ within the framework. That is the purpose of this project. Requirements ------------ -* Python (2.6, 2.7 and 3.3) +* Python 2.7+ * Django 1.3+ -* Django REST Framework >= 2.2.5 (when bulk features were added to serializers), < 3.0 +* Django REST Framework >= 2.2.5 (when bulk features were added to serializers) Installing ---------- diff --git a/requirements.txt b/requirements.txt index c8a5923..6e5370e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ django -djangorestframework<3 +djangorestframework diff --git a/rest_framework_bulk/drf3/mixins.py b/rest_framework_bulk/drf3/mixins.py new file mode 100644 index 0000000..c957747 --- /dev/null +++ b/rest_framework_bulk/drf3/mixins.py @@ -0,0 +1 @@ +from __future__ import print_function, unicode_literals diff --git a/rest_framework_bulk/mixins.py b/rest_framework_bulk/mixins.py new file mode 100644 index 0000000..2869a87 --- /dev/null +++ b/rest_framework_bulk/mixins.py @@ -0,0 +1,12 @@ +from __future__ import print_function, unicode_literals +import rest_framework + + +# import appropriate mixins depending on the DRF version +# this allows to maintain clean code for each DRF version +# without doing any magic +# a little more code but a lit clearer what is going on +if str(rest_framework.__version__).startswith('2'): + from .drf2.mixins import * # noqa +else: + from .drf3.mixins import * # noqa From 7ce139e025464031c598da0320352e56df5257ce Mon Sep 17 00:00:00 2001 From: Miroslav Shubernetskiy Date: Sun, 1 Feb 2015 21:01:36 -0500 Subject: [PATCH 03/16] initial DRF3 support except update which requires to implement update logic --- rest_framework_bulk/drf2/mixins.py | 33 +++++- rest_framework_bulk/drf3/mixins.py | 112 ++++++++++++++++++ .../tests/simple_app/serializers.py | 9 ++ rest_framework_bulk/tests/simple_app/views.py | 19 +-- rest_framework_bulk/tests/test_generics.py | 12 +- 5 files changed, 170 insertions(+), 15 deletions(-) create mode 100644 rest_framework_bulk/tests/simple_app/serializers.py diff --git a/rest_framework_bulk/drf2/mixins.py b/rest_framework_bulk/drf2/mixins.py index 8f19073..3d84416 100644 --- a/rest_framework_bulk/drf2/mixins.py +++ b/rest_framework_bulk/drf2/mixins.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals, print_function -from django.core.exceptions import ValidationError +import traceback +from django.core.exceptions import ImproperlyConfigured, ValidationError from rest_framework import status from rest_framework.mixins import CreateModelMixin from rest_framework.response import Response @@ -32,9 +33,11 @@ def create(self, request, *args, **kwargs): else: serializer = self.get_serializer(data=request.DATA, many=True) if serializer.is_valid(): - [self.pre_save(obj) for obj in serializer.object] + for obj in serializer.object: + self.pre_save(obj) self.object = serializer.save(force_insert=True) - [self.post_save(obj, created=True) for obj in self.object] + for obj in self.object: + self.post_save(obj, created=True) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -47,7 +50,23 @@ class BulkUpdateModelMixin(object): """ def get_object(self): - return self.get_queryset() + try: + super(BulkUpdateModelMixin, self).get_object() + except ImproperlyConfigured: + # probably happened when called get_object() within metdata() + # which is not allowed on list viewset however since we are enabling + # PUT here, we should handle the exception + # if called within metadata(), we can simply swallow exception + # since that method does not actually do anything + # with the returned object + for file, line, function, code in traceback.extract_stack(): + if all((file.endswith('rest_framework/generics.py'), + function == 'metadata')): + return + + # not called inside metadata() so probably something went + # wrong and so we should reraise exception + raise def bulk_update(self, request, *args, **kwargs): partial = kwargs.pop('partial', False) @@ -60,13 +79,15 @@ def bulk_update(self, request, *args, **kwargs): if serializer.is_valid(): try: - [self.pre_save(obj) for obj in serializer.object] + for obj in serializer.object: + self.pre_save(obj) except ValidationError as err: # full_clean on model instances may be called in pre_save # so we have to handle eventual errors. return Response(err.message_dict, status=status.HTTP_400_BAD_REQUEST) self.object = serializer.save(force_update=True) - [self.post_save(obj, created=False) for obj in self.object] + for obj in self.object: + self.post_save(obj, created=False) return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/rest_framework_bulk/drf3/mixins.py b/rest_framework_bulk/drf3/mixins.py index c957747..5934599 100644 --- a/rest_framework_bulk/drf3/mixins.py +++ b/rest_framework_bulk/drf3/mixins.py @@ -1 +1,113 @@ from __future__ import print_function, unicode_literals +import traceback +from rest_framework import status +from rest_framework.mixins import CreateModelMixin +from rest_framework.response import Response + + +__all__ = [ + 'BulkCreateModelMixin', + 'BulkDestroyModelMixin', + 'BulkUpdateModelMixin', +] + + +class BulkCreateModelMixin(CreateModelMixin): + """ + Either create a single or many model instances in bulk by using the + Serializer's ``many=True`` ability from Django REST >= 2.2.5. + + .. note:: + This mixin uses the same method to create model instances + as ``CreateModelMixin`` because both non-bulk and bulk + requests will use ``POST`` request method. + """ + + def create(self, request, *args, **kwargs): + bulk = isinstance(request.data, list) + + if not bulk: + return super(BulkCreateModelMixin, self).create(request, *args, **kwargs) + + else: + serializer = self.get_serializer(data=request.data, many=True) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + +class BulkUpdateModelMixin(object): + """ + Update model instances in bulk by using the Serializer's + ``many=True`` ability from Django REST >= 2.2.5. + """ + + def get_object(self): + try: + super(BulkUpdateModelMixin, self).get_object() + except AssertionError: + # probably happened when called get_object() within options() + # via self.metadata_class which is not allowed on list viewset + # however since we are enabling PUT here, we should handle the + # exception if called within options() + # We can simply swallow the exception since that method + # does not actually do anythingwith the returned object + for file, line, function, code in traceback.extract_stack(): + if all((file.endswith('rest_framework/views.py'), + function == 'options')): + return + + # not called inside metadata() so probably something went + # wrong and so we should reraise exception + raise + + def bulk_update(self, request, *args, **kwargs): + partial = kwargs.pop('partial', False) + + # restrict the update to the filtered queryset + serializer = self.get_serializer( + self.filter_queryset(self.get_queryset()), + data=request.data, + many=True, + partial=partial, + ) + serializer.is_valid(raise_exception=True) + self.perform_update(serializer) + return Response(serializer.data, status=status.HTTP_200_OK) + + def perform_update(self, serializer): + serializer.save() + + def partial_bulk_update(self, request, *args, **kwargs): + kwargs['partial'] = True + return self.bulk_update(request, *args, **kwargs) + + +class BulkDestroyModelMixin(object): + """ + Destroy model instances. + """ + + def allow_bulk_destroy(self, qs, filtered): + """ + Hook to ensure that the bulk destroy should be allowed. + + By default this checks that the destroy is only applied to + filtered querysets. + """ + return qs is not filtered + + def bulk_destroy(self, request, *args, **kwargs): + qs = self.get_queryset() + + filtered = self.filter_queryset(qs) + if not self.allow_bulk_destroy(qs, filtered): + return Response(status=status.HTTP_400_BAD_REQUEST) + + for obj in filtered: + self.perform_destroy(obj) + + return Response(status=status.HTTP_204_NO_CONTENT) + + def perform_destroy(self, instance): + instance.delete() diff --git a/rest_framework_bulk/tests/simple_app/serializers.py b/rest_framework_bulk/tests/simple_app/serializers.py new file mode 100644 index 0000000..3a8d438 --- /dev/null +++ b/rest_framework_bulk/tests/simple_app/serializers.py @@ -0,0 +1,9 @@ +from __future__ import print_function, unicode_literals +from rest_framework.serializers import ModelSerializer + +from .models import SimpleModel + + +class SimpleSerializer(ModelSerializer): + class Meta(object): + model = SimpleModel diff --git a/rest_framework_bulk/tests/simple_app/views.py b/rest_framework_bulk/tests/simple_app/views.py index 21b1c5a..7bc16c8 100644 --- a/rest_framework_bulk/tests/simple_app/views.py +++ b/rest_framework_bulk/tests/simple_app/views.py @@ -1,22 +1,25 @@ from __future__ import unicode_literals, print_function from rest_framework_bulk import generics -from . import models +from .models import SimpleModel +from .serializers import SimpleSerializer -class SimpleBulkAPIView(generics.ListBulkCreateUpdateDestroyAPIView): - model = models.SimpleModel +class SimpleMixin(object): + model = SimpleModel + queryset = SimpleModel.objects.all() + serializer_class = SimpleSerializer -class FilteredBulkAPIView(generics.ListBulkCreateUpdateDestroyAPIView): - model = models.SimpleModel +class SimpleBulkAPIView(SimpleMixin, generics.ListBulkCreateUpdateDestroyAPIView): + pass + +class FilteredBulkAPIView(SimpleMixin, generics.ListBulkCreateUpdateDestroyAPIView): def filter_queryset(self, queryset): return queryset.filter(number__gt=5) -class SimpleViewSet(generics.BulkModelViewSet): - model = models.SimpleModel - +class SimpleViewSet(SimpleMixin, generics.BulkModelViewSet): def filter_queryset(self, queryset): return queryset.filter(number__gt=5) diff --git a/rest_framework_bulk/tests/test_generics.py b/rest_framework_bulk/tests/test_generics.py index 87e028f..5522948 100644 --- a/rest_framework_bulk/tests/test_generics.py +++ b/rest_framework_bulk/tests/test_generics.py @@ -152,11 +152,21 @@ def setUp(self): super(TestBulkAPIViewSet, self).setUp() self.url = reverse('api:simple-list') + def test_get_single(self): + """ + Test that we are still able to query single resource + """ + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + def test_get(self): """ Test that GET returns 200 """ - response = self.client.get(self.url) + obj = SimpleModel.objects.create(contents='hello world', number=7) + + response = self.client.get(reverse('api:simple-detail', args=[obj.pk])) self.assertEqual(response.status_code, status.HTTP_200_OK) From 1da9a4bc0e3c869f6383bed5e1e316bb7e43b05b Mon Sep 17 00:00:00 2001 From: Miroslav Shubernetskiy Date: Sat, 7 Feb 2015 14:24:46 -0500 Subject: [PATCH 04/16] adjusted tox to test both DRF2 and DRF3 --- Makefile | 4 ++++ tox.ini | 11 +++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 4a378c7..2a5771b 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,7 @@ COVER_FLAGS=${COVER_CONFIG_FLAGS} ${COVER_REPORT_FLAGS} help: @echo "install - install all requirements including for testing" + @echo "install-quite - same as install but pipes all output to /dev/null" @echo "clean - remove all artifacts" @echo "clean-build - remove build artifacts" @echo "clean-pyc - remove Python file artifacts" @@ -23,6 +24,9 @@ help: install: pip install -r requirements-dev.txt +install-quite: + pip install -r requirements-dev.txt > /dev/null + clean: clean-build clean-pyc clean-test-all clean-build: diff --git a/tox.ini b/tox.ini index 3a27b2d..d088851 100644 --- a/tox.ini +++ b/tox.ini @@ -1,15 +1,22 @@ [tox] envlist = - py27, py34, pypy, pypy3 + {py27,py34,pypy,pypy3}-drf{2,3} [testenv] +basepython = + py27: python2.7 + py34: python3.4 + pypy: pypy + pypy3: pypy3 setenv = PYTHONPATH = {toxinidir}:{toxinidir}/multinosetests commands = + make install-quite pip freeze make check deps = - -rrequirements-dev.txt + drf2: djangorestframework<3 + drf3: djangorestframework>=3 whitelist_externals = make From 8c6dbca11c9107fdc49041c0b76637284677b634 Mon Sep 17 00:00:00 2001 From: Miroslav Shubernetskiy Date: Sat, 7 Feb 2015 14:25:43 -0500 Subject: [PATCH 05/16] added dedicated bulk methods for create, update and delete in DRF3 --- rest_framework_bulk/drf3/mixins.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/rest_framework_bulk/drf3/mixins.py b/rest_framework_bulk/drf3/mixins.py index 5934599..35c1d8c 100644 --- a/rest_framework_bulk/drf3/mixins.py +++ b/rest_framework_bulk/drf3/mixins.py @@ -32,9 +32,12 @@ def create(self, request, *args, **kwargs): else: serializer = self.get_serializer(data=request.data, many=True) serializer.is_valid(raise_exception=True) - self.perform_create(serializer) + self.perform_bulk_create(serializer) return Response(serializer.data, status=status.HTTP_201_CREATED) + def perform_bulk_create(self, serializer): + return self.perform_create(serializer) + class BulkUpdateModelMixin(object): """ @@ -72,16 +75,19 @@ def bulk_update(self, request, *args, **kwargs): partial=partial, ) serializer.is_valid(raise_exception=True) - self.perform_update(serializer) + self.perform_bulk_update(serializer) return Response(serializer.data, status=status.HTTP_200_OK) - def perform_update(self, serializer): - serializer.save() - def partial_bulk_update(self, request, *args, **kwargs): kwargs['partial'] = True return self.bulk_update(request, *args, **kwargs) + def perform_update(self, serializer): + serializer.save() + + def perform_bulk_update(self, serializer): + return self.perform_update(serializer) + class BulkDestroyModelMixin(object): """ @@ -104,10 +110,13 @@ def bulk_destroy(self, request, *args, **kwargs): if not self.allow_bulk_destroy(qs, filtered): return Response(status=status.HTTP_400_BAD_REQUEST) - for obj in filtered: - self.perform_destroy(obj) + self.perform_bulk_destroy(filtered) return Response(status=status.HTTP_204_NO_CONTENT) def perform_destroy(self, instance): instance.delete() + + def perform_bulk_destroy(self, objects): + for obj in objects: + self.perform_destroy(obj) From 43f7b14296796566035b47f2689b6a3b020ab332 Mon Sep 17 00:00:00 2001 From: Miroslav Shubernetskiy Date: Sat, 7 Feb 2015 23:30:55 -0500 Subject: [PATCH 06/16] added BulkListSerializer and BulkSerializerMixin to add DRF3 support --- rest_framework_bulk/drf2/serializers.py | 15 ++++++ rest_framework_bulk/drf3/serializers.py | 53 +++++++++++++++++++ rest_framework_bulk/serializers.py | 12 +++++ .../tests/simple_app/serializers.py | 6 ++- 4 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 rest_framework_bulk/drf2/serializers.py create mode 100644 rest_framework_bulk/drf3/serializers.py create mode 100644 rest_framework_bulk/serializers.py diff --git a/rest_framework_bulk/drf2/serializers.py b/rest_framework_bulk/drf2/serializers.py new file mode 100644 index 0000000..d69de40 --- /dev/null +++ b/rest_framework_bulk/drf2/serializers.py @@ -0,0 +1,15 @@ +from __future__ import print_function, unicode_literals + + +__all__ = [ + 'BulkListSerializer', + 'BulkSerializerMixin', +] + + +class BulkSerializerMixin(object): + pass + + +class BulkListSerializer(object): + pass diff --git a/rest_framework_bulk/drf3/serializers.py b/rest_framework_bulk/drf3/serializers.py new file mode 100644 index 0000000..1969fde --- /dev/null +++ b/rest_framework_bulk/drf3/serializers.py @@ -0,0 +1,53 @@ +from __future__ import print_function, unicode_literals +from rest_framework.serializers import ListSerializer + + +__all__ = [ + 'BulkListSerializer', + 'BulkSerializerMixin', +] + + +class BulkSerializerMixin(object): + def to_internal_value(self, data): + ret = super(BulkSerializerMixin, self).to_internal_value(data) + + id_attr = getattr(self.Meta, 'update_lookup_field', 'id') + request_method = getattr(getattr(self.context.get('view'), 'request'), 'method', '') + + # add update_lookup_field field back to validated data + # since super by default strips out read-only fields + # hence id will no longer be present in validated_data + if all((isinstance(self.root, BulkListSerializer), + id_attr, + request_method in ('PUT', 'PATCH'))): + id_field = self.fields[id_attr] + id_value = id_field.get_value(data) + + ret[id_attr] = id_value + + return ret + + +class BulkListSerializer(ListSerializer): + update_lookup_field = 'id' + + def update(self, instances, all_validated_data): + id_attr = getattr(self.child.Meta, 'update_lookup_field', 'id') + + all_validated_data_by_id = { + i.pop(id_attr): i + for i in all_validated_data + } + + updated_objects = [] + + for obj in instances: + obj_id = getattr(obj, id_attr, None) + obj_validated_data = all_validated_data_by_id.get(obj_id) + if obj_id and obj_validated_data: + # use model serializer to actually update the model + # in case that method is overwritten + updated_objects.append(self.child.update(obj, obj_validated_data)) + + return updated_objects diff --git a/rest_framework_bulk/serializers.py b/rest_framework_bulk/serializers.py new file mode 100644 index 0000000..ae3accc --- /dev/null +++ b/rest_framework_bulk/serializers.py @@ -0,0 +1,12 @@ +from __future__ import print_function, unicode_literals +import rest_framework + + +# import appropriate mixins depending on the DRF version +# this allows to maintain clean code for each DRF version +# without doing any magic +# a little more code but a lit clearer what is going on +if str(rest_framework.__version__).startswith('2'): + from .drf2.serializers import * # noqa +else: + from .drf3.serializers import * # noqa diff --git a/rest_framework_bulk/tests/simple_app/serializers.py b/rest_framework_bulk/tests/simple_app/serializers.py index 3a8d438..423083c 100644 --- a/rest_framework_bulk/tests/simple_app/serializers.py +++ b/rest_framework_bulk/tests/simple_app/serializers.py @@ -1,9 +1,13 @@ from __future__ import print_function, unicode_literals from rest_framework.serializers import ModelSerializer +from rest_framework_bulk.serializers import BulkListSerializer, BulkSerializerMixin from .models import SimpleModel -class SimpleSerializer(ModelSerializer): +class SimpleSerializer(BulkSerializerMixin, # only required in DRF3 + ModelSerializer): class Meta(object): model = SimpleModel + # only required in DRF3 + list_serializer_class = BulkListSerializer From c4e0ab56307468504da3075f2a648a25cf786280 Mon Sep 17 00:00:00 2001 From: Miroslav Shubernetskiy Date: Sat, 7 Feb 2015 23:48:13 -0500 Subject: [PATCH 07/16] added travis config for both DRF2 and DRF3 --- .travis.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 53b7ff5..6318584 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,8 +5,14 @@ python: - "2.7" - "pypy" +env: + - "DRF='djangorestframework<3'" + - "DRF='djangorestframework>=3'" + # command to install dependencies, e.g. pip install -r requirements.txt --use-mirrors -install: pip install -r requirements-dev.txt +install: + - pip install $DRF + - pip install -r requirements-dev.txt # command to run tests, e.g. python setup.py test script: make check From 7694469f3802275cd5a2425d7d9847718248d53d Mon Sep 17 00:00:00 2001 From: Miroslav Shubernetskiy Date: Sat, 7 Feb 2015 23:54:25 -0500 Subject: [PATCH 08/16] added Matthias Erll to contributors since he did initial work for DRF3 support which I based my implementation on [ci skip] --- AUTHORS.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/AUTHORS.rst b/AUTHORS.rst index 70ed60d..c21000a 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -13,6 +13,7 @@ Contributors * Davide Mendolia - https://github.com/davideme * Kevin Brown - https://github.com/kevin-brown * Martin Cavoj - https://github.com/macav +* Matthias Erll - https://github.com/merll * Mjumbe Poe - https://github.com/mjumbewu * Thomas Wajs - https://github.com/thomasWajs * Xavier Ordoquy - https://github.com/xordoquy From 74afc1ac3a9d65f3a90ab7459a7e6d1874668339 Mon Sep 17 00:00:00 2001 From: Miroslav Shubernetskiy Date: Sun, 8 Feb 2015 00:20:11 -0500 Subject: [PATCH 09/16] bumped version to 0.2 and adjusted README with HISTORY --- HISTORY.rst | 10 +++++++- README.rst | 42 ++++++++++++++++++++++++++++++--- rest_framework_bulk/__init__.py | 3 ++- 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index d1de812..f04f145 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,7 +3,15 @@ History ------- -0.1.4 (2014-02-01) +0.2 (2015-02-08) +~~~~~~~~~~~~~~~~ + +* Added DRF3 support. Please note that DRF2 is still supported. + Now we support both DRF2 and DRF3! +* Fixed an issue when using viewsets, single resource update was not working due + to ``get_object()`` overwrite in viewset. + +0.1.4 (2015-02-01) ~~~~~~~~~~~~~~~~~~ * Added base model viewset. diff --git a/README.rst b/README.rst index d0ad780..65dabd6 100644 --- a/README.rst +++ b/README.rst @@ -24,6 +24,7 @@ Requirements * Python 2.7+ * Django 1.3+ * Django REST Framework >= 2.2.5 (when bulk features were added to serializers) +* Django REST Framework >= 3.0.0 (DRF-bulk supports both DRF2 and DRF3!) Installing ---------- @@ -42,9 +43,21 @@ Example The bulk views (and mixins) are very similar to Django REST Framework's own generic views (and mixins):: - from rest_framework_bulk import ListBulkCreateUpdateDestroyAPIView + from rest_framework_bulk import ( + BulkListSerializer, + BulkSerializerMixin, + ListBulkCreateUpdateDestroyAPIView, + ) + + class FooSerializer(BulkSerializerMixin, ModelSerializer): + class Meta(object): + model = FooModel + # only necessary in DRF3 + list_serializer_class = BulkListSerializer + class FooView(ListBulkCreateUpdateDestroyAPIView): - model = FooModel + queryset = FooModel.objects.all() + serializer_class = FooSerializer The above will allow to create the following queries @@ -85,7 +98,7 @@ The above will allow to create the following queries Router ------ -The bulk router can map automatically the bulk actions:: +The bulk router can automatically map the bulk actions:: from rest_framework_bulk.routes import BulkRouter @@ -98,6 +111,29 @@ The bulk router can map automatically the bulk actions:: router = BulkRouter() router.register(r'users', UserViewSet) +DRF3 +---- + +Django REST Framework made many API changes which included major changes +in serializers. As a result, please note the following in order to use +DRF-bulk with DRF3: + +* You must specify custom ``list_serializer_class`` if your view(set) + will require update functionality (when using ``BulkUpdateModelMixin``) +* DRF3 removes read-only fields from ``serializer.validated_data``. + As a result, it is impossible to correlate each ``validated_data`` + in ``ListSerializer`` with a model instance to update since ``validated_data`` + will be missing the model primary key since that is a read-only field. + To deal with that, you must use ``BulkSerializerMixin`` mixin in your serializer + class which will add the model primary key field back to the ``validated_data``. + By default ``id`` field is used however you can customize that field + by using ``update_lookup_field`` in the serializers ``Meta``:: + + class FooSerializer(BulkSerializerMixin, ModelSerializer): + class Meta(object): + model = FooModel + list_serializer_class = BulkListSerializer + update_lookup_field = 'slug' Notes ----- diff --git a/rest_framework_bulk/__init__.py b/rest_framework_bulk/__init__.py index 25f0065..7dceaaf 100644 --- a/rest_framework_bulk/__init__.py +++ b/rest_framework_bulk/__init__.py @@ -1,8 +1,9 @@ -__version__ = '0.1.4' +__version__ = '0.2' __author__ = 'Miroslav Shubernetskiy' try: from .generics import * # noqa from .mixins import * # noqa + from .serializers import * # noqa except Exception: pass From 40d1c9a9cc57cac3f1fc45b4c34319bd20e24343 Mon Sep 17 00:00:00 2001 From: Miroslav Shubernetskiy Date: Sun, 8 Feb 2015 00:24:14 -0500 Subject: [PATCH 10/16] typos [ci skip] --- rest_framework_bulk/drf2/mixins.py | 6 +++--- rest_framework_bulk/drf3/mixins.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/rest_framework_bulk/drf2/mixins.py b/rest_framework_bulk/drf2/mixins.py index 3d84416..6b9f0ee 100644 --- a/rest_framework_bulk/drf2/mixins.py +++ b/rest_framework_bulk/drf2/mixins.py @@ -16,7 +16,7 @@ class BulkCreateModelMixin(CreateModelMixin): """ Either create a single or many model instances in bulk by using the - Serializer's ``many=True`` ability from Django REST >= 2.2.5. + Serializers ``many=True`` ability from Django REST >= 2.2.5. .. note:: This mixin uses the same method to create model instances @@ -45,7 +45,7 @@ def create(self, request, *args, **kwargs): class BulkUpdateModelMixin(object): """ - Update model instances in bulk by using the Serializer's + Update model instances in bulk by using the Serializers ``many=True`` ability from Django REST >= 2.2.5. """ @@ -53,7 +53,7 @@ def get_object(self): try: super(BulkUpdateModelMixin, self).get_object() except ImproperlyConfigured: - # probably happened when called get_object() within metdata() + # probably happened when called get_object() within metadata() # which is not allowed on list viewset however since we are enabling # PUT here, we should handle the exception # if called within metadata(), we can simply swallow exception diff --git a/rest_framework_bulk/drf3/mixins.py b/rest_framework_bulk/drf3/mixins.py index 35c1d8c..69dc90f 100644 --- a/rest_framework_bulk/drf3/mixins.py +++ b/rest_framework_bulk/drf3/mixins.py @@ -15,7 +15,7 @@ class BulkCreateModelMixin(CreateModelMixin): """ Either create a single or many model instances in bulk by using the - Serializer's ``many=True`` ability from Django REST >= 2.2.5. + Serializers ``many=True`` ability from Django REST >= 2.2.5. .. note:: This mixin uses the same method to create model instances @@ -41,7 +41,7 @@ def perform_bulk_create(self, serializer): class BulkUpdateModelMixin(object): """ - Update model instances in bulk by using the Serializer's + Update model instances in bulk by using the Serializers ``many=True`` ability from Django REST >= 2.2.5. """ @@ -54,7 +54,7 @@ def get_object(self): # however since we are enabling PUT here, we should handle the # exception if called within options() # We can simply swallow the exception since that method - # does not actually do anythingwith the returned object + # does not actually do anything with the returned object for file, line, function, code in traceback.extract_stack(): if all((file.endswith('rest_framework/views.py'), function == 'options')): From 52efdad4fb83cf9172f873d459c9b9748e6f249e Mon Sep 17 00:00:00 2001 From: Miroslav Shubernetskiy Date: Sun, 8 Feb 2015 11:08:34 -0500 Subject: [PATCH 11/16] forgot to return object in get_object() function --- rest_framework_bulk/drf2/mixins.py | 2 +- rest_framework_bulk/drf3/mixins.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/rest_framework_bulk/drf2/mixins.py b/rest_framework_bulk/drf2/mixins.py index 6b9f0ee..1c4ebd4 100644 --- a/rest_framework_bulk/drf2/mixins.py +++ b/rest_framework_bulk/drf2/mixins.py @@ -51,7 +51,7 @@ class BulkUpdateModelMixin(object): def get_object(self): try: - super(BulkUpdateModelMixin, self).get_object() + return super(BulkUpdateModelMixin, self).get_object() except ImproperlyConfigured: # probably happened when called get_object() within metadata() # which is not allowed on list viewset however since we are enabling diff --git a/rest_framework_bulk/drf3/mixins.py b/rest_framework_bulk/drf3/mixins.py index 69dc90f..1264e1c 100644 --- a/rest_framework_bulk/drf3/mixins.py +++ b/rest_framework_bulk/drf3/mixins.py @@ -47,7 +47,7 @@ class BulkUpdateModelMixin(object): def get_object(self): try: - super(BulkUpdateModelMixin, self).get_object() + return super(BulkUpdateModelMixin, self).get_object() except AssertionError: # probably happened when called get_object() within options() # via self.metadata_class which is not allowed on list viewset From d21aea81394ba7efa942421061b050f37f7bd8d1 Mon Sep 17 00:00:00 2001 From: Miroslav Shubernetskiy Date: Sun, 8 Feb 2015 11:48:37 -0500 Subject: [PATCH 12/16] filtering queryset before bulk update to guarantee all objects in json are found in db --- rest_framework_bulk/drf3/serializers.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/rest_framework_bulk/drf3/serializers.py b/rest_framework_bulk/drf3/serializers.py index 1969fde..2a71c0c 100644 --- a/rest_framework_bulk/drf3/serializers.py +++ b/rest_framework_bulk/drf3/serializers.py @@ -1,4 +1,5 @@ from __future__ import print_function, unicode_literals +from rest_framework.exceptions import ValidationError from rest_framework.serializers import ListSerializer @@ -32,7 +33,7 @@ def to_internal_value(self, data): class BulkListSerializer(ListSerializer): update_lookup_field = 'id' - def update(self, instances, all_validated_data): + def update(self, queryset, all_validated_data): id_attr = getattr(self.child.Meta, 'update_lookup_field', 'id') all_validated_data_by_id = { @@ -40,9 +41,19 @@ def update(self, instances, all_validated_data): for i in all_validated_data } + # since this method is given a queryset which can have many + # model instances, first find all objects to update + # and only then update the models + objects_to_update = queryset.filter(**{ + '{}__in'.format(id_attr): all_validated_data_by_id.keys(), + }) + + if len(all_validated_data_by_id) != objects_to_update.count(): + raise ValidationError('Could not find find all objects to update.') + updated_objects = [] - for obj in instances: + for obj in objects_to_update: obj_id = getattr(obj, id_attr, None) obj_validated_data = all_validated_data_by_id.get(obj_id) if obj_id and obj_validated_data: From db3c662fcb3cff9a38a4ec2942c647704d34786b Mon Sep 17 00:00:00 2001 From: Miroslav Shubernetskiy Date: Sun, 8 Feb 2015 11:56:45 -0500 Subject: [PATCH 13/16] fixed typo and small optimization in update() --- rest_framework_bulk/drf3/serializers.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/rest_framework_bulk/drf3/serializers.py b/rest_framework_bulk/drf3/serializers.py index 2a71c0c..9098829 100644 --- a/rest_framework_bulk/drf3/serializers.py +++ b/rest_framework_bulk/drf3/serializers.py @@ -49,16 +49,16 @@ def update(self, queryset, all_validated_data): }) if len(all_validated_data_by_id) != objects_to_update.count(): - raise ValidationError('Could not find find all objects to update.') + raise ValidationError('Could not find all objects to update.') updated_objects = [] for obj in objects_to_update: - obj_id = getattr(obj, id_attr, None) + obj_id = getattr(obj, id_attr) obj_validated_data = all_validated_data_by_id.get(obj_id) - if obj_id and obj_validated_data: - # use model serializer to actually update the model - # in case that method is overwritten - updated_objects.append(self.child.update(obj, obj_validated_data)) + + # use model serializer to actually update the model + # in case that method is overwritten + updated_objects.append(self.child.update(obj, obj_validated_data)) return updated_objects From 191c5e7c4a3824f872a853b3ae9603cfd9955ae1 Mon Sep 17 00:00:00 2001 From: Miroslav Shubernetskiy Date: Mon, 9 Feb 2015 05:52:45 -0500 Subject: [PATCH 14/16] completely overwriting get_object() to support bulk requests --- rest_framework_bulk/drf2/mixins.py | 35 +++++++++++++++--------------- rest_framework_bulk/drf3/mixins.py | 29 +++++++++++-------------- 2 files changed, 30 insertions(+), 34 deletions(-) diff --git a/rest_framework_bulk/drf2/mixins.py b/rest_framework_bulk/drf2/mixins.py index 1c4ebd4..66c2d58 100644 --- a/rest_framework_bulk/drf2/mixins.py +++ b/rest_framework_bulk/drf2/mixins.py @@ -49,24 +49,23 @@ class BulkUpdateModelMixin(object): ``many=True`` ability from Django REST >= 2.2.5. """ - def get_object(self): - try: - return super(BulkUpdateModelMixin, self).get_object() - except ImproperlyConfigured: - # probably happened when called get_object() within metadata() - # which is not allowed on list viewset however since we are enabling - # PUT here, we should handle the exception - # if called within metadata(), we can simply swallow exception - # since that method does not actually do anything - # with the returned object - for file, line, function, code in traceback.extract_stack(): - if all((file.endswith('rest_framework/generics.py'), - function == 'metadata')): - return - - # not called inside metadata() so probably something went - # wrong and so we should reraise exception - raise + def get_object(self, queryset=None): + lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field + + if any((lookup_url_kwarg in self.kwargs, + self.pk_url_kwarg in self.kwargs, + self.slug_url_kwarg in self.kwargs)): + return super(BulkUpdateModelMixin, self).get_object(queryset) + + # If the lookup_url_kwarg (or other deprecated variations) + # are not present, get_object() is most likely called + # as part of metadata() which by default simply checks + # for object permissions and raises permission denied if necessary. + # Here we don't need to check for general permissions + # and can simply return None since general permissions + # are checked in initial() which always gets executed + # before any of the API actions (e.g. create, update, etc) + return def bulk_update(self, request, *args, **kwargs): partial = kwargs.pop('partial', False) diff --git a/rest_framework_bulk/drf3/mixins.py b/rest_framework_bulk/drf3/mixins.py index 1264e1c..dd9dbbb 100644 --- a/rest_framework_bulk/drf3/mixins.py +++ b/rest_framework_bulk/drf3/mixins.py @@ -46,23 +46,20 @@ class BulkUpdateModelMixin(object): """ def get_object(self): - try: + lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field + + if lookup_url_kwarg in self.kwargs: return super(BulkUpdateModelMixin, self).get_object() - except AssertionError: - # probably happened when called get_object() within options() - # via self.metadata_class which is not allowed on list viewset - # however since we are enabling PUT here, we should handle the - # exception if called within options() - # We can simply swallow the exception since that method - # does not actually do anything with the returned object - for file, line, function, code in traceback.extract_stack(): - if all((file.endswith('rest_framework/views.py'), - function == 'options')): - return - - # not called inside metadata() so probably something went - # wrong and so we should reraise exception - raise + + # If the lookup_url_kwarg is not present + # get_object() is most likely called as part of options() + # which by default simply checks for object permissions + # and raises permission denied if necessary. + # Here we don't need to check for general permissions + # and can simply return None since general permissions + # are checked in initial() which always gets executed + # before any of the API actions (e.g. create, update, etc) + return def bulk_update(self, request, *args, **kwargs): partial = kwargs.pop('partial', False) From 615393d2f9b3b6722a867a0a197b19acc5390da7 Mon Sep 17 00:00:00 2001 From: Miroslav Shubernetskiy Date: Mon, 9 Feb 2015 05:55:37 -0500 Subject: [PATCH 15/16] removed unused imports --- rest_framework_bulk/drf2/mixins.py | 3 +-- rest_framework_bulk/drf3/mixins.py | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/rest_framework_bulk/drf2/mixins.py b/rest_framework_bulk/drf2/mixins.py index 66c2d58..a64f517 100644 --- a/rest_framework_bulk/drf2/mixins.py +++ b/rest_framework_bulk/drf2/mixins.py @@ -1,6 +1,5 @@ from __future__ import unicode_literals, print_function -import traceback -from django.core.exceptions import ImproperlyConfigured, ValidationError +from django.core.exceptions import ValidationError from rest_framework import status from rest_framework.mixins import CreateModelMixin from rest_framework.response import Response diff --git a/rest_framework_bulk/drf3/mixins.py b/rest_framework_bulk/drf3/mixins.py index dd9dbbb..b1e8c66 100644 --- a/rest_framework_bulk/drf3/mixins.py +++ b/rest_framework_bulk/drf3/mixins.py @@ -1,5 +1,4 @@ from __future__ import print_function, unicode_literals -import traceback from rest_framework import status from rest_framework.mixins import CreateModelMixin from rest_framework.response import Response From 32e2247a4119f1c4c91ee0c0370da2da83ee35e0 Mon Sep 17 00:00:00 2001 From: Miroslav Shubernetskiy Date: Mon, 9 Feb 2015 05:59:57 -0500 Subject: [PATCH 16/16] updated release date [ci skip] --- HISTORY.rst | 2 +- README.rst | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/HISTORY.rst b/HISTORY.rst index f04f145..7bf4f04 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,7 +3,7 @@ History ------- -0.2 (2015-02-08) +0.2 (2015-02-09) ~~~~~~~~~~~~~~~~ * Added DRF3 support. Please note that DRF2 is still supported. diff --git a/README.rst b/README.rst index 65dabd6..0c6027b 100644 --- a/README.rst +++ b/README.rst @@ -134,6 +134,7 @@ DRF-bulk with DRF3: model = FooModel list_serializer_class = BulkListSerializer update_lookup_field = 'slug' + Notes -----