├── .gitignore ├── .travis.yml ├── AUTHORS.rst ├── CONTRIBUTING.rst ├── HISTORY.rst ├── LICENSE.rst ├── MANIFEST.in ├── Makefile ├── README.rst ├── requirements-dev.txt ├── requirements.txt ├── rest_framework_bulk ├── __init__.py ├── drf2 │ ├── __init__.py │ ├── mixins.py │ └── serializers.py ├── drf3 │ ├── __init__.py │ ├── mixins.py │ └── serializers.py ├── generics.py ├── mixins.py ├── models.py ├── routes.py ├── serializers.py └── tests │ ├── __init__.py │ ├── simple_app │ ├── __init__.py │ ├── models.py │ ├── serializers.py │ ├── urls.py │ └── views.py │ └── test_generics.py ├── setup.py ├── tests ├── manage.py ├── runtests.sh └── settings.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *.orig 2 | *.pyc 3 | *~ 4 | 5 | *.sqlite 6 | 7 | dist/ 8 | _build/ 9 | .idea 10 | .coverage 11 | .tox 12 | 13 | *.egg-info 14 | 15 | *zip 16 | *tar.gz 17 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - "3.4" 5 | - "2.7" 6 | - "pypy" 7 | 8 | env: 9 | - "$DJANGO_DRF='django<1.8' 'djangorestframework<3'" 10 | - "$DJANGO_DRF='djangorestframework>=3'" 11 | 12 | # command to install dependencies, e.g. pip install -r requirements.txt --use-mirrors 13 | install: 14 | - pip install $DJANGO_DRF 15 | - pip install -r requirements-dev.txt 16 | - pip freeze 17 | 18 | # command to run tests, e.g. python setup.py test 19 | script: make check 20 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | Credits 2 | ------- 3 | 4 | Development Lead 5 | ~~~~~~~~~~~~~~~~ 6 | 7 | * Miroslav Shubernetskiy - https://github.com/miki725 8 | 9 | Contributors 10 | ~~~~~~~~~~~~ 11 | 12 | * Arien Tolner - https://github.com/Bounder 13 | * Davide Mendolia - https://github.com/davideme 14 | * Kevin Brown - https://github.com/kevin-brown 15 | * Martin Cavoj - https://github.com/macav 16 | * Matthias Erll - https://github.com/merll 17 | * Mjumbe Poe - https://github.com/mjumbewu 18 | * Thomas Wajs - https://github.com/thomasWajs 19 | * Xavier Ordoquy - https://github.com/xordoquy 20 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributing 3 | ============ 4 | 5 | Contributions are welcome, and they are greatly appreciated! Every 6 | little bit helps, and credit will always be given. 7 | 8 | You can contribute in many ways: 9 | 10 | Types of Contributions 11 | ---------------------- 12 | 13 | Report Bugs 14 | ~~~~~~~~~~~ 15 | 16 | Report bugs at https://github.com/miki725/django-rest-framework-bulk/issues. 17 | 18 | If you are reporting a bug, please include: 19 | 20 | * Your operating system name and version. 21 | * Any details about your local setup that might be helpful in troubleshooting. 22 | * Detailed steps to reproduce the bug. 23 | 24 | Fix Bugs 25 | ~~~~~~~~ 26 | 27 | Look through the GitHub issues for bugs. Anything tagged with "bug" 28 | is open to whoever wants to implement it. 29 | 30 | Implement Features 31 | ~~~~~~~~~~~~~~~~~~ 32 | 33 | Look through the GitHub issues for features. Anything tagged with "feature" 34 | is open to whoever wants to implement it. 35 | 36 | Write Documentation 37 | ~~~~~~~~~~~~~~~~~~~ 38 | 39 | Django REST Bulk could always use more documentation, whether 40 | as part of the official Django REST Bulk docs (one day...), in docstrings, 41 | or even on the web in blog posts, articles, and such. 42 | 43 | Submit Feedback 44 | ~~~~~~~~~~~~~~~ 45 | 46 | The best way to send feedback is to file an issue at 47 | https://github.com/miki725/django-rest-framework-bulk/issues. 48 | 49 | If you are proposing a feature: 50 | 51 | * Explain in detail how it would work. 52 | * Keep the scope as narrow as possible, to make it easier to implement. 53 | * Remember that this is a volunteer-driven project, and that contributions 54 | are welcome :) 55 | 56 | Get Started! 57 | ------------ 58 | 59 | Ready to contribute? Here's how to set up ``django-rest-framework-bulk`` for local development. 60 | 61 | 1. Fork the ``django-rest-framework-bulk`` repo on GitHub. 62 | 2. Clone your fork locally:: 63 | 64 | $ git clone git@github.com:your_name_here/django-rest-framework-bulk.git 65 | 66 | 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: 67 | 68 | $ mkvirtualenv djangorestbulk 69 | $ cd django-rest-framework-bulk/ 70 | $ make install 71 | 72 | 4. Create a branch for local development:: 73 | 74 | $ git checkout -b name-of-your-bugfix-or-feature 75 | 76 | Now you can make your changes locally. 77 | 78 | 5. When you're done making changes, check that your changes pass 79 | flake8 and the tests, including testing other Python versions with tox:: 80 | 81 | $ make lint 82 | $ make test-all 83 | 84 | 6. Commit your changes and push your branch to GitHub:: 85 | 86 | $ git add . 87 | $ git commit -m "Your detailed description of your changes." 88 | $ git push origin name-of-your-bugfix-or-feature 89 | 90 | 7. Submit a pull request through the GitHub website. 91 | 92 | Pull Request Guidelines 93 | ----------------------- 94 | 95 | Before you submit a pull request, check that it meets these guidelines: 96 | 97 | 1. The pull request should include tests. 98 | 2. If the pull request adds functionality, the docs should be updated. 99 | Put your new functionality into a function with a docstring, 100 | and add the feature to the list in README.rst. 101 | 3. The pull request should work for Python 2.7, 3.4, and for PyPy. 102 | Check https://travis-ci.org/miki725/django-rest-framework-bulk/pull_requests 103 | and make sure that the tests pass for all supported Python versions. 104 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | .. :changelog: 2 | 3 | History 4 | ------- 5 | 6 | 0.2.1 (2015-04-26) 7 | ~~~~~~~~~~~~~~~~~~ 8 | 9 | * Fixed a bug which allowed to submit data for update to serializer 10 | without update field. 11 | See `#34 `_. 12 | * Removed support for Django1.8 with DRF2.x 13 | 14 | 0.2 (2015-02-09) 15 | ~~~~~~~~~~~~~~~~ 16 | 17 | * Added DRF3 support. Please note that DRF2 is still supported. 18 | Now we support both DRF2 and DRF3! 19 | * Fixed an issue when using viewsets, single resource update was not working due 20 | to ``get_object()`` overwrite in viewset. 21 | 22 | 0.1.4 (2015-02-01) 23 | ~~~~~~~~~~~~~~~~~~ 24 | 25 | * Added base model viewset. 26 | * Fixed installation issues. 27 | See `#18 `_, 28 | `#22 `_. 29 | 30 | 0.1.3 (2014-06-11) 31 | ~~~~~~~~~~~~~~~~~~ 32 | 33 | * Fixed bug how ``post_save()`` was called in bulk create. 34 | 35 | 0.1.2 (2014-04-15) 36 | ~~~~~~~~~~~~~~~~~~ 37 | 38 | * Fixed bug how ``pre_save()`` was called in bulk update. 39 | * Fixed bug of unable to mixins by importing directly ``from rest_framework_bulk import ``. 40 | See `#5 `_ for more info. 41 | 42 | 0.1.1 (2014-01-20) 43 | ~~~~~~~~~~~~~~~~~~ 44 | 45 | * Fixed installation bug with setuptools. 46 | 47 | 0.1 (2014-01-18) 48 | ~~~~~~~~~~~~~~~~ 49 | 50 | * First release on PyPI. 51 | -------------------------------------------------------------------------------- /LICENSE.rst: -------------------------------------------------------------------------------- 1 | License 2 | ------- 3 | 4 | Source code can be found at `Github `_. 5 | 6 | `The MIT License (MIT) `_:: 7 | 8 | Copyright (c) 2014-2015, Miroslav Shubernetskiy 9 | 10 | Permission is hereby granted, free of charge, to any person obtaining a copy 11 | of this software and associated documentation files (the "Software"), to deal 12 | in the Software without restriction, including without limitation the rights to 13 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 14 | the Software, and to permit persons to whom the Software is furnished to do so, 15 | subject to the following conditions: 16 | 17 | The above copyright notice and this permission notice shall be included in all 18 | copies or substantial portions of the Software. 19 | 20 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 21 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 22 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 23 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 24 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 25 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.rst *.txt 2 | recursive-include tests *.sh *.py 3 | recursive-exclude * __pycache__ 4 | recursive-exclude * *.py[co] 5 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean-pyc clean-build docs clean 2 | 3 | TEST_FLAGS=--verbosity=2 4 | COVER_FLAGS=--source=rest_framework_bulk 5 | 6 | help: 7 | @echo "install - install all requirements including for testing" 8 | @echo "install-quite - same as install but pipes all output to /dev/null" 9 | @echo "clean - remove all artifacts" 10 | @echo "clean-build - remove build artifacts" 11 | @echo "clean-pyc - remove Python file artifacts" 12 | @echo "clean-test - remove test and coverage artifacts" 13 | @echo "clean-test-all - remove all test-related artifacts including tox" 14 | @echo "lint - check style with flake8" 15 | @echo "test - run tests quickly with the default Python" 16 | @echo "test-coverage - run tests with coverage report" 17 | @echo "test-all - run tests on every Python version with tox" 18 | @echo "check - run all necessary steps to check validity of project" 19 | @echo "release - package and upload a release" 20 | @echo "dist - package" 21 | 22 | install: 23 | pip install -r requirements-dev.txt 24 | 25 | install-quite: 26 | pip install -r requirements-dev.txt > /dev/null 27 | 28 | clean: clean-build clean-pyc clean-test-all 29 | 30 | clean-build: 31 | @rm -rf build/ 32 | @rm -rf dist/ 33 | @rm -rf *.egg-info 34 | 35 | clean-pyc: 36 | -@find . -name '*.pyc' -follow -print0 | xargs -0 rm -f 37 | -@find . -name '*.pyo' -follow -print0 | xargs -0 rm -f 38 | -@find . -name '__pycache__' -type d -follow -print0 | xargs -0 rm -rf 39 | 40 | clean-test: 41 | rm -rf .coverage coverage* 42 | rm -rf tests/.coverage test/coverage* 43 | rm -rf htmlcov/ 44 | 45 | clean-test-all: clean-test 46 | rm -rf .tox/ 47 | 48 | lint: 49 | flake8 rest_framework_bulk 50 | 51 | test: 52 | python tests/manage.py test ${TEST_FLAGS} 53 | 54 | test-coverage: clean-test 55 | -coverage run ${COVER_FLAGS} tests/manage.py test ${TEST_FLAGS} 56 | @exit_code=$? 57 | @-coverage report 58 | @-coverage html 59 | @exit ${exit_code} 60 | 61 | test-all: 62 | tox 63 | 64 | check: clean-build clean-pyc clean-test lint test 65 | 66 | release: clean 67 | python setup.py sdist upload 68 | 69 | dist: clean 70 | python setup.py sdist 71 | ls -l dist 72 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Django REST Framework Bulk 2 | ========================== 3 | 4 | .. image:: https://badge.fury.io/py/djangorestframework-bulk.png 5 | :target: http://badge.fury.io/py/djangorestframework-bulk 6 | 7 | .. image:: https://travis-ci.org/miki725/django-rest-framework-bulk.svg?branch=master 8 | :target: https://travis-ci.org/miki725/django-rest-framework-bulk 9 | 10 | Django REST Framework bulk CRUD view mixins. 11 | 12 | Overview 13 | -------- 14 | 15 | Django REST Framework comes with many generic views however none 16 | of them allow to do bulk operations such as create, update and delete. 17 | To keep the core of Django REST Framework simple, its maintainer 18 | suggested to create a separate project to allow for bulk operations 19 | within the framework. That is the purpose of this project. 20 | 21 | Requirements 22 | ------------ 23 | 24 | * Python>=2.7 25 | * Django>=1.3 26 | * Django REST Framework >= 3.0.0 27 | * REST Framework >= 2.2.5 28 | (**only with** Django<1.8 since DRF<3 does not support Django1.8) 29 | 30 | Installing 31 | ---------- 32 | 33 | Using pip:: 34 | 35 | $ pip install djangorestframework-bulk 36 | 37 | or from source code:: 38 | 39 | $ pip install -e git+http://github.com/miki725/django-rest-framework-bulk#egg=djangorestframework-bulk 40 | 41 | Example 42 | ------- 43 | 44 | The bulk views (and mixins) are very similar to Django REST Framework's own 45 | generic views (and mixins):: 46 | 47 | from rest_framework_bulk import ( 48 | BulkListSerializer, 49 | BulkSerializerMixin, 50 | ListBulkCreateUpdateDestroyAPIView, 51 | ) 52 | 53 | class FooSerializer(BulkSerializerMixin, ModelSerializer): 54 | class Meta(object): 55 | model = FooModel 56 | # only necessary in DRF3 57 | list_serializer_class = BulkListSerializer 58 | 59 | class FooView(ListBulkCreateUpdateDestroyAPIView): 60 | queryset = FooModel.objects.all() 61 | serializer_class = FooSerializer 62 | 63 | The above will allow to create the following queries 64 | 65 | :: 66 | 67 | # list queryset 68 | GET 69 | 70 | :: 71 | 72 | # create single resource 73 | POST 74 | {"field":"value","field2":"value2"} <- json object in request data 75 | 76 | :: 77 | 78 | # create multiple resources 79 | POST 80 | [{"field":"value","field2":"value2"}] 81 | 82 | :: 83 | 84 | # update multiple resources (requires all fields) 85 | PUT 86 | [{"field":"value","field2":"value2"}] <- json list of objects in data 87 | 88 | :: 89 | 90 | # partial update multiple resources 91 | PATCH 92 | [{"field":"value"}] <- json list of objects in data 93 | 94 | :: 95 | 96 | # delete queryset (see notes) 97 | DELETE 98 | 99 | Router 100 | ------ 101 | 102 | The bulk router can automatically map the bulk actions:: 103 | 104 | from rest_framework_bulk.routes import BulkRouter 105 | 106 | class UserViewSet(BulkModelViewSet): 107 | model = User 108 | 109 | def allow_bulk_destroy(self, qs, filtered): 110 | """Don't forget to fine-grain this method""" 111 | 112 | router = BulkRouter() 113 | router.register(r'users', UserViewSet) 114 | 115 | DRF3 116 | ---- 117 | 118 | Django REST Framework made many API changes which included major changes 119 | in serializers. As a result, please note the following in order to use 120 | DRF-bulk with DRF3: 121 | 122 | * You must specify custom ``list_serializer_class`` if your view(set) 123 | will require update functionality (when using ``BulkUpdateModelMixin``) 124 | * DRF3 removes read-only fields from ``serializer.validated_data``. 125 | As a result, it is impossible to correlate each ``validated_data`` 126 | in ``ListSerializer`` with a model instance to update since ``validated_data`` 127 | will be missing the model primary key since that is a read-only field. 128 | To deal with that, you must use ``BulkSerializerMixin`` mixin in your serializer 129 | class which will add the model primary key field back to the ``validated_data``. 130 | By default ``id`` field is used however you can customize that field 131 | by using ``update_lookup_field`` in the serializers ``Meta``:: 132 | 133 | class FooSerializer(BulkSerializerMixin, ModelSerializer): 134 | class Meta(object): 135 | model = FooModel 136 | list_serializer_class = BulkListSerializer 137 | update_lookup_field = 'slug' 138 | 139 | Notes 140 | ----- 141 | 142 | Most API urls have two URL levels for each resource: 143 | 144 | 1. ``url(r'foo/', ...)`` 145 | 2. ``url(r'foo/(?P\d+)/', ...)`` 146 | 147 | The second url however is not applicable for bulk operations because 148 | the url directly maps to a single resource. Therefore all bulk 149 | generic views only apply to the first url. 150 | 151 | There are multiple generic view classes in case only a certail 152 | bulk functionality is required. For example ``ListBulkCreateAPIView`` 153 | will only do bulk operations for creating resources. 154 | For a complete list of available generic view classes, please 155 | take a look at the source code at ``generics.py`` as it is mostly 156 | self-explanatory. 157 | 158 | Most bulk operations are pretty safe in terms of how they operate, 159 | that is you explicitly describe all requests. For example, if you 160 | need to update 3 specific resources, you have to explicitly identify 161 | those resources in the request's ``PUT`` or ``PATCH`` data. 162 | The only exception to this is bulk delete. Consider a ``DELETE`` 163 | request to the first url. That can potentially delete all resources 164 | without any special confirmation. To try to account for this, bulk delete 165 | mixin allows to implement a hook to determine if the bulk delete 166 | request should be allowed:: 167 | 168 | class FooView(BulkDestroyAPIView): 169 | def allow_bulk_destroy(self, qs, filtered): 170 | # custom logic here 171 | 172 | # default checks if the qs was filtered 173 | # qs comes from self.get_queryset() 174 | # filtered comes from self.filter_queryset(qs) 175 | return qs is not filtered 176 | 177 | By default it checks if the queryset was filtered and if not will not 178 | allow the bulk delete to complete. The logic here is that if the request 179 | is filtered to only get certain resources, more attention was payed hence 180 | the action is less likely to be accidental. On how to filter requests, 181 | please refer to Django REST 182 | `docs `_. 183 | Either way, please use bulk deletes with extreme caution since they 184 | can be dangerous. 185 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | coverage 3 | flake8 4 | tox 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | django 2 | djangorestframework 3 | -------------------------------------------------------------------------------- /rest_framework_bulk/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.2.1' 2 | __author__ = 'Miroslav Shubernetskiy' 3 | 4 | try: 5 | from .generics import * # noqa 6 | from .mixins import * # noqa 7 | from .serializers import * # noqa 8 | except Exception: 9 | pass 10 | -------------------------------------------------------------------------------- /rest_framework_bulk/drf2/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function, unicode_literals 2 | -------------------------------------------------------------------------------- /rest_framework_bulk/drf2/mixins.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals, print_function 2 | from django.core.exceptions import ValidationError 3 | from rest_framework import status 4 | from rest_framework.mixins import CreateModelMixin 5 | from rest_framework.response import Response 6 | 7 | 8 | __all__ = [ 9 | 'BulkCreateModelMixin', 10 | 'BulkDestroyModelMixin', 11 | 'BulkUpdateModelMixin', 12 | ] 13 | 14 | 15 | class BulkCreateModelMixin(CreateModelMixin): 16 | """ 17 | Either create a single or many model instances in bulk by using the 18 | Serializers ``many=True`` ability from Django REST >= 2.2.5. 19 | 20 | .. note:: 21 | This mixin uses the same method to create model instances 22 | as ``CreateModelMixin`` because both non-bulk and bulk 23 | requests will use ``POST`` request method. 24 | """ 25 | 26 | def create(self, request, *args, **kwargs): 27 | bulk = isinstance(request.DATA, list) 28 | 29 | if not bulk: 30 | return super(BulkCreateModelMixin, self).create(request, *args, **kwargs) 31 | 32 | else: 33 | serializer = self.get_serializer(data=request.DATA, many=True) 34 | if serializer.is_valid(): 35 | for obj in serializer.object: 36 | self.pre_save(obj) 37 | self.object = serializer.save(force_insert=True) 38 | for obj in self.object: 39 | self.post_save(obj, created=True) 40 | return Response(serializer.data, status=status.HTTP_201_CREATED) 41 | 42 | return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 43 | 44 | 45 | class BulkUpdateModelMixin(object): 46 | """ 47 | Update model instances in bulk by using the Serializers 48 | ``many=True`` ability from Django REST >= 2.2.5. 49 | """ 50 | 51 | def get_object(self, queryset=None): 52 | lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field 53 | 54 | if any((lookup_url_kwarg in self.kwargs, 55 | self.pk_url_kwarg in self.kwargs, 56 | self.slug_url_kwarg in self.kwargs)): 57 | return super(BulkUpdateModelMixin, self).get_object(queryset) 58 | 59 | # If the lookup_url_kwarg (or other deprecated variations) 60 | # are not present, get_object() is most likely called 61 | # as part of metadata() which by default simply checks 62 | # for object permissions and raises permission denied if necessary. 63 | # Here we don't need to check for general permissions 64 | # and can simply return None since general permissions 65 | # are checked in initial() which always gets executed 66 | # before any of the API actions (e.g. create, update, etc) 67 | return 68 | 69 | def bulk_update(self, request, *args, **kwargs): 70 | partial = kwargs.pop('partial', False) 71 | 72 | # restrict the update to the filtered queryset 73 | serializer = self.get_serializer(self.filter_queryset(self.get_queryset()), 74 | data=request.DATA, 75 | many=True, 76 | partial=partial) 77 | 78 | if serializer.is_valid(): 79 | try: 80 | for obj in serializer.object: 81 | self.pre_save(obj) 82 | except ValidationError as err: 83 | # full_clean on model instances may be called in pre_save 84 | # so we have to handle eventual errors. 85 | return Response(err.message_dict, status=status.HTTP_400_BAD_REQUEST) 86 | self.object = serializer.save(force_update=True) 87 | for obj in self.object: 88 | self.post_save(obj, created=False) 89 | return Response(serializer.data, status=status.HTTP_200_OK) 90 | 91 | return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 92 | 93 | def partial_bulk_update(self, request, *args, **kwargs): 94 | kwargs['partial'] = True 95 | return self.bulk_update(request, *args, **kwargs) 96 | 97 | 98 | class BulkDestroyModelMixin(object): 99 | """ 100 | Destroy model instances. 101 | """ 102 | 103 | def allow_bulk_destroy(self, qs, filtered): 104 | """ 105 | Hook to ensure that the bulk destroy should be allowed. 106 | 107 | By default this checks that the destroy is only applied to 108 | filtered querysets. 109 | """ 110 | return qs is not filtered 111 | 112 | def bulk_destroy(self, request, *args, **kwargs): 113 | qs = self.get_queryset() 114 | filtered = self.filter_queryset(qs) 115 | if not self.allow_bulk_destroy(qs, filtered): 116 | return Response(status=status.HTTP_400_BAD_REQUEST) 117 | 118 | for obj in filtered: 119 | self.pre_delete(obj) 120 | obj.delete() 121 | self.post_delete(obj) 122 | return Response(status=status.HTTP_204_NO_CONTENT) 123 | -------------------------------------------------------------------------------- /rest_framework_bulk/drf2/serializers.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function, unicode_literals 2 | 3 | 4 | __all__ = [ 5 | 'BulkListSerializer', 6 | 'BulkSerializerMixin', 7 | ] 8 | 9 | 10 | class BulkSerializerMixin(object): 11 | pass 12 | 13 | 14 | class BulkListSerializer(object): 15 | pass 16 | -------------------------------------------------------------------------------- /rest_framework_bulk/drf3/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function, unicode_literals 2 | -------------------------------------------------------------------------------- /rest_framework_bulk/drf3/mixins.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function, unicode_literals 2 | from rest_framework import status 3 | from rest_framework.mixins import CreateModelMixin 4 | from rest_framework.response import Response 5 | 6 | 7 | __all__ = [ 8 | 'BulkCreateModelMixin', 9 | 'BulkDestroyModelMixin', 10 | 'BulkUpdateModelMixin', 11 | ] 12 | 13 | 14 | class BulkCreateModelMixin(CreateModelMixin): 15 | """ 16 | Either create a single or many model instances in bulk by using the 17 | Serializers ``many=True`` ability from Django REST >= 2.2.5. 18 | 19 | .. note:: 20 | This mixin uses the same method to create model instances 21 | as ``CreateModelMixin`` because both non-bulk and bulk 22 | requests will use ``POST`` request method. 23 | """ 24 | 25 | def create(self, request, *args, **kwargs): 26 | bulk = isinstance(request.data, list) 27 | 28 | if not bulk: 29 | return super(BulkCreateModelMixin, self).create(request, *args, **kwargs) 30 | 31 | else: 32 | serializer = self.get_serializer(data=request.data, many=True) 33 | serializer.is_valid(raise_exception=True) 34 | self.perform_bulk_create(serializer) 35 | return Response(serializer.data, status=status.HTTP_201_CREATED) 36 | 37 | def perform_bulk_create(self, serializer): 38 | return self.perform_create(serializer) 39 | 40 | 41 | class BulkUpdateModelMixin(object): 42 | """ 43 | Update model instances in bulk by using the Serializers 44 | ``many=True`` ability from Django REST >= 2.2.5. 45 | """ 46 | 47 | def get_object(self): 48 | lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field 49 | 50 | if lookup_url_kwarg in self.kwargs: 51 | return super(BulkUpdateModelMixin, self).get_object() 52 | 53 | # If the lookup_url_kwarg is not present 54 | # get_object() is most likely called as part of options() 55 | # which by default simply checks for object permissions 56 | # and raises permission denied if necessary. 57 | # Here we don't need to check for general permissions 58 | # and can simply return None since general permissions 59 | # are checked in initial() which always gets executed 60 | # before any of the API actions (e.g. create, update, etc) 61 | return 62 | 63 | def bulk_update(self, request, *args, **kwargs): 64 | partial = kwargs.pop('partial', False) 65 | 66 | # restrict the update to the filtered queryset 67 | serializer = self.get_serializer( 68 | self.filter_queryset(self.get_queryset()), 69 | data=request.data, 70 | many=True, 71 | partial=partial, 72 | ) 73 | serializer.is_valid(raise_exception=True) 74 | self.perform_bulk_update(serializer) 75 | return Response(serializer.data, status=status.HTTP_200_OK) 76 | 77 | def partial_bulk_update(self, request, *args, **kwargs): 78 | kwargs['partial'] = True 79 | return self.bulk_update(request, *args, **kwargs) 80 | 81 | def perform_update(self, serializer): 82 | serializer.save() 83 | 84 | def perform_bulk_update(self, serializer): 85 | return self.perform_update(serializer) 86 | 87 | 88 | class BulkDestroyModelMixin(object): 89 | """ 90 | Destroy model instances. 91 | """ 92 | 93 | def allow_bulk_destroy(self, qs, filtered): 94 | """ 95 | Hook to ensure that the bulk destroy should be allowed. 96 | 97 | By default this checks that the destroy is only applied to 98 | filtered querysets. 99 | """ 100 | return qs is not filtered 101 | 102 | def bulk_destroy(self, request, *args, **kwargs): 103 | qs = self.get_queryset() 104 | 105 | filtered = self.filter_queryset(qs) 106 | if not self.allow_bulk_destroy(qs, filtered): 107 | return Response(status=status.HTTP_400_BAD_REQUEST) 108 | 109 | self.perform_bulk_destroy(filtered) 110 | 111 | return Response(status=status.HTTP_204_NO_CONTENT) 112 | 113 | def perform_destroy(self, instance): 114 | instance.delete() 115 | 116 | def perform_bulk_destroy(self, objects): 117 | for obj in objects: 118 | self.perform_destroy(obj) 119 | -------------------------------------------------------------------------------- /rest_framework_bulk/drf3/serializers.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function, unicode_literals 2 | import inspect 3 | 4 | from rest_framework.exceptions import ValidationError 5 | from rest_framework.serializers import ListSerializer 6 | 7 | 8 | __all__ = [ 9 | 'BulkListSerializer', 10 | 'BulkSerializerMixin', 11 | ] 12 | 13 | 14 | class BulkSerializerMixin(object): 15 | def to_internal_value(self, data): 16 | ret = super(BulkSerializerMixin, self).to_internal_value(data) 17 | 18 | id_attr = getattr(self.Meta, 'update_lookup_field', 'id') 19 | request_method = getattr(getattr(self.context.get('view'), 'request'), 'method', '') 20 | 21 | # add update_lookup_field field back to validated data 22 | # since super by default strips out read-only fields 23 | # hence id will no longer be present in validated_data 24 | if all((isinstance(self.root, BulkListSerializer), 25 | id_attr, 26 | request_method in ('PUT', 'PATCH'))): 27 | id_field = self.fields[id_attr] 28 | id_value = id_field.get_value(data) 29 | 30 | ret[id_attr] = id_value 31 | 32 | return ret 33 | 34 | 35 | class BulkListSerializer(ListSerializer): 36 | update_lookup_field = 'id' 37 | 38 | def update(self, queryset, all_validated_data): 39 | id_attr = getattr(self.child.Meta, 'update_lookup_field', 'id') 40 | 41 | all_validated_data_by_id = { 42 | i.pop(id_attr): i 43 | for i in all_validated_data 44 | } 45 | 46 | if not all((bool(i) and not inspect.isclass(i) 47 | for i in all_validated_data_by_id.keys())): 48 | raise ValidationError('') 49 | 50 | # since this method is given a queryset which can have many 51 | # model instances, first find all objects to update 52 | # and only then update the models 53 | objects_to_update = queryset.filter(**{ 54 | '{}__in'.format(id_attr): all_validated_data_by_id.keys(), 55 | }) 56 | 57 | if len(all_validated_data_by_id) != objects_to_update.count(): 58 | raise ValidationError('Could not find all objects to update.') 59 | 60 | updated_objects = [] 61 | 62 | for obj in objects_to_update: 63 | obj_id = getattr(obj, id_attr) 64 | obj_validated_data = all_validated_data_by_id.get(obj_id) 65 | 66 | # use model serializer to actually update the model 67 | # in case that method is overwritten 68 | updated_objects.append(self.child.update(obj, obj_validated_data)) 69 | 70 | return updated_objects 71 | -------------------------------------------------------------------------------- /rest_framework_bulk/generics.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals, print_function 2 | from rest_framework import mixins 3 | from rest_framework.generics import GenericAPIView 4 | from rest_framework.viewsets import ModelViewSet 5 | 6 | from . import mixins as bulk_mixins 7 | 8 | 9 | __all__ = [ 10 | 'BulkCreateAPIView', 11 | 'BulkDestroyAPIView', 12 | 'BulkModelViewSet', 13 | 'BulkUpdateAPIView', 14 | 'ListBulkCreateAPIView', 15 | 'ListBulkCreateDestroyAPIView', 16 | 'ListBulkCreateUpdateAPIView', 17 | 'ListBulkCreateUpdateDestroyAPIView', 18 | 'ListCreateBulkUpdateAPIView', 19 | 'ListCreateBulkUpdateDestroyAPIView', 20 | ] 21 | 22 | 23 | # ################################################## # 24 | # Concrete view classes that provide method handlers # 25 | # by composing the mixin classes with the base view. # 26 | # ################################################## # 27 | 28 | class BulkCreateAPIView(bulk_mixins.BulkCreateModelMixin, 29 | GenericAPIView): 30 | def post(self, request, *args, **kwargs): 31 | return self.create(request, *args, **kwargs) 32 | 33 | 34 | class BulkUpdateAPIView(bulk_mixins.BulkUpdateModelMixin, 35 | GenericAPIView): 36 | def put(self, request, *args, **kwargs): 37 | return self.bulk_update(request, *args, **kwargs) 38 | 39 | def patch(self, request, *args, **kwargs): 40 | return self.partial_bulk_update(request, *args, **kwargs) 41 | 42 | 43 | class BulkDestroyAPIView(bulk_mixins.BulkDestroyModelMixin, 44 | GenericAPIView): 45 | def delete(self, request, *args, **kwargs): 46 | return self.bulk_destroy(request, *args, **kwargs) 47 | 48 | 49 | class ListBulkCreateAPIView(mixins.ListModelMixin, 50 | bulk_mixins.BulkCreateModelMixin, 51 | GenericAPIView): 52 | def get(self, request, *args, **kwargs): 53 | return self.list(request, *args, **kwargs) 54 | 55 | def post(self, request, *args, **kwargs): 56 | return self.create(request, *args, **kwargs) 57 | 58 | 59 | class ListCreateBulkUpdateAPIView(mixins.ListModelMixin, 60 | mixins.CreateModelMixin, 61 | bulk_mixins.BulkUpdateModelMixin, 62 | GenericAPIView): 63 | def get(self, request, *args, **kwargs): 64 | return self.list(request, *args, **kwargs) 65 | 66 | def post(self, request, *args, **kwargs): 67 | return self.create(request, *args, **kwargs) 68 | 69 | def put(self, request, *args, **kwargs): 70 | return self.bulk_update(request, *args, **kwargs) 71 | 72 | def patch(self, request, *args, **kwargs): 73 | return self.partial_bulk_update(request, *args, **kwargs) 74 | 75 | 76 | class ListCreateBulkUpdateDestroyAPIView(mixins.ListModelMixin, 77 | mixins.CreateModelMixin, 78 | bulk_mixins.BulkUpdateModelMixin, 79 | bulk_mixins.BulkDestroyModelMixin, 80 | GenericAPIView): 81 | def get(self, request, *args, **kwargs): 82 | return self.list(request, *args, **kwargs) 83 | 84 | def post(self, request, *args, **kwargs): 85 | return self.create(request, *args, **kwargs) 86 | 87 | def put(self, request, *args, **kwargs): 88 | return self.bulk_update(request, *args, **kwargs) 89 | 90 | def patch(self, request, *args, **kwargs): 91 | return self.partial_bulk_update(request, *args, **kwargs) 92 | 93 | def delete(self, request, *args, **kwargs): 94 | return self.bulk_destroy(request, *args, **kwargs) 95 | 96 | 97 | class ListBulkCreateUpdateAPIView(mixins.ListModelMixin, 98 | bulk_mixins.BulkCreateModelMixin, 99 | bulk_mixins.BulkUpdateModelMixin, 100 | GenericAPIView): 101 | def get(self, request, *args, **kwargs): 102 | return self.list(request, *args, **kwargs) 103 | 104 | def post(self, request, *args, **kwargs): 105 | return self.create(request, *args, **kwargs) 106 | 107 | def put(self, request, *args, **kwargs): 108 | return self.bulk_update(request, *args, **kwargs) 109 | 110 | def patch(self, request, *args, **kwargs): 111 | return self.partial_bulk_update(request, *args, **kwargs) 112 | 113 | 114 | class ListBulkCreateDestroyAPIView(mixins.ListModelMixin, 115 | bulk_mixins.BulkCreateModelMixin, 116 | bulk_mixins.BulkDestroyModelMixin, 117 | GenericAPIView): 118 | def get(self, request, *args, **kwargs): 119 | return self.list(request, *args, **kwargs) 120 | 121 | def post(self, request, *args, **kwargs): 122 | return self.create(request, *args, **kwargs) 123 | 124 | def delete(self, request, *args, **kwargs): 125 | return self.bulk_destroy(request, *args, **kwargs) 126 | 127 | 128 | class ListBulkCreateUpdateDestroyAPIView(mixins.ListModelMixin, 129 | bulk_mixins.BulkCreateModelMixin, 130 | bulk_mixins.BulkUpdateModelMixin, 131 | bulk_mixins.BulkDestroyModelMixin, 132 | GenericAPIView): 133 | def get(self, request, *args, **kwargs): 134 | return self.list(request, *args, **kwargs) 135 | 136 | def post(self, request, *args, **kwargs): 137 | return self.create(request, *args, **kwargs) 138 | 139 | def put(self, request, *args, **kwargs): 140 | return self.bulk_update(request, *args, **kwargs) 141 | 142 | def patch(self, request, *args, **kwargs): 143 | return self.partial_bulk_update(request, *args, **kwargs) 144 | 145 | def delete(self, request, *args, **kwargs): 146 | return self.bulk_destroy(request, *args, **kwargs) 147 | 148 | 149 | # ########################################################## # 150 | # Concrete viewset classes that provide method handlers # 151 | # by composing the bulk mixin classes with the base viewset. # 152 | # ########################################################## # 153 | 154 | class BulkModelViewSet(bulk_mixins.BulkCreateModelMixin, 155 | bulk_mixins.BulkUpdateModelMixin, 156 | bulk_mixins.BulkDestroyModelMixin, 157 | ModelViewSet): 158 | pass 159 | -------------------------------------------------------------------------------- /rest_framework_bulk/mixins.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function, unicode_literals 2 | import rest_framework 3 | 4 | 5 | # import appropriate mixins depending on the DRF version 6 | # this allows to maintain clean code for each DRF version 7 | # without doing any magic 8 | # a little more code but a lit clearer what is going on 9 | if str(rest_framework.__version__).startswith('2'): 10 | from .drf2.mixins import * # noqa 11 | else: 12 | from .drf3.mixins import * # noqa 13 | -------------------------------------------------------------------------------- /rest_framework_bulk/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miki725/django-rest-framework-bulk/2a59d14e7036660f00996b013ab913678b5974c4/rest_framework_bulk/models.py -------------------------------------------------------------------------------- /rest_framework_bulk/routes.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals, print_function 2 | import copy 3 | from rest_framework.routers import DefaultRouter, SimpleRouter 4 | 5 | 6 | __all__ = [ 7 | 'BulkRouter', 8 | ] 9 | 10 | 11 | class BulkRouter(DefaultRouter): 12 | """ 13 | Map http methods to actions defined on the bulk mixins. 14 | """ 15 | routes = copy.deepcopy(SimpleRouter.routes) 16 | routes[0].mapping.update({ 17 | 'put': 'bulk_update', 18 | 'patch': 'partial_bulk_update', 19 | 'delete': 'bulk_destroy', 20 | }) 21 | -------------------------------------------------------------------------------- /rest_framework_bulk/serializers.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function, unicode_literals 2 | import rest_framework 3 | 4 | 5 | # import appropriate mixins depending on the DRF version 6 | # this allows to maintain clean code for each DRF version 7 | # without doing any magic 8 | # a little more code but a lit clearer what is going on 9 | if str(rest_framework.__version__).startswith('2'): 10 | from .drf2.serializers import * # noqa 11 | else: 12 | from .drf3.serializers import * # noqa 13 | -------------------------------------------------------------------------------- /rest_framework_bulk/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miki725/django-rest-framework-bulk/2a59d14e7036660f00996b013ab913678b5974c4/rest_framework_bulk/tests/__init__.py -------------------------------------------------------------------------------- /rest_framework_bulk/tests/simple_app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miki725/django-rest-framework-bulk/2a59d14e7036660f00996b013ab913678b5974c4/rest_framework_bulk/tests/simple_app/__init__.py -------------------------------------------------------------------------------- /rest_framework_bulk/tests/simple_app/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals, print_function 2 | from django.db import models 3 | 4 | 5 | class SimpleModel(models.Model): 6 | number = models.IntegerField() 7 | contents = models.CharField(max_length=16) 8 | -------------------------------------------------------------------------------- /rest_framework_bulk/tests/simple_app/serializers.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function, unicode_literals 2 | from rest_framework.serializers import ModelSerializer 3 | from rest_framework_bulk.serializers import BulkListSerializer, BulkSerializerMixin 4 | 5 | from .models import SimpleModel 6 | 7 | 8 | class SimpleSerializer(BulkSerializerMixin, # only required in DRF3 9 | ModelSerializer): 10 | class Meta(object): 11 | model = SimpleModel 12 | # only required in DRF3 13 | list_serializer_class = BulkListSerializer 14 | -------------------------------------------------------------------------------- /rest_framework_bulk/tests/simple_app/urls.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function, unicode_literals 2 | from django.conf.urls import patterns, url, include 3 | from rest_framework_bulk.routes import BulkRouter 4 | 5 | from .views import SimpleViewSet 6 | 7 | 8 | router = BulkRouter() 9 | router.register('simple', SimpleViewSet, 'simple') 10 | 11 | urlpatterns = patterns( 12 | '', 13 | 14 | url(r'^api/', include(router.urls, namespace='api')), 15 | ) 16 | -------------------------------------------------------------------------------- /rest_framework_bulk/tests/simple_app/views.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals, print_function 2 | from rest_framework_bulk import generics 3 | 4 | from .models import SimpleModel 5 | from .serializers import SimpleSerializer 6 | 7 | 8 | class SimpleMixin(object): 9 | model = SimpleModel 10 | queryset = SimpleModel.objects.all() 11 | serializer_class = SimpleSerializer 12 | 13 | 14 | class SimpleBulkAPIView(SimpleMixin, generics.ListBulkCreateUpdateDestroyAPIView): 15 | pass 16 | 17 | 18 | class FilteredBulkAPIView(SimpleMixin, generics.ListBulkCreateUpdateDestroyAPIView): 19 | def filter_queryset(self, queryset): 20 | return queryset.filter(number__gt=5) 21 | 22 | 23 | class SimpleViewSet(SimpleMixin, generics.BulkModelViewSet): 24 | def filter_queryset(self, queryset): 25 | return queryset.filter(number__gt=5) 26 | -------------------------------------------------------------------------------- /rest_framework_bulk/tests/test_generics.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals, print_function 2 | import json 3 | 4 | from django.core.urlresolvers import reverse 5 | from django.test import TestCase 6 | from django.test.client import RequestFactory 7 | from rest_framework import status 8 | 9 | from .simple_app.models import SimpleModel 10 | from .simple_app.views import FilteredBulkAPIView, SimpleBulkAPIView 11 | 12 | 13 | class TestBulkAPIView(TestCase): 14 | def setUp(self): 15 | super(TestBulkAPIView, self).setUp() 16 | self.view = SimpleBulkAPIView.as_view() 17 | self.request = RequestFactory() 18 | 19 | def test_get(self): 20 | """ 21 | Test that GET request is successful on bulk view. 22 | """ 23 | response = self.view(self.request.get('')) 24 | 25 | self.assertEqual(response.status_code, status.HTTP_200_OK) 26 | 27 | def test_post_single(self): 28 | """ 29 | Test that POST request with single resource only creates a single resource. 30 | """ 31 | response = self.view(self.request.post( 32 | '', 33 | json.dumps({'contents': 'hello world', 'number': 1}), 34 | content_type='application/json', 35 | )) 36 | 37 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 38 | self.assertEqual(SimpleModel.objects.count(), 1) 39 | self.assertEqual(SimpleModel.objects.get().contents, 'hello world') 40 | 41 | def test_post_bulk(self): 42 | """ 43 | Test that POST request with multiple resources creates all posted resources. 44 | """ 45 | response = self.view(self.request.post( 46 | '', 47 | json.dumps([ 48 | {'contents': 'hello world', 'number': 1}, 49 | {'contents': 'hello mars', 'number': 2}, 50 | ]), 51 | content_type='application/json', 52 | )) 53 | 54 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 55 | self.assertEqual(SimpleModel.objects.count(), 2) 56 | self.assertEqual(list(SimpleModel.objects.all().values_list('contents', flat=True)), [ 57 | 'hello world', 58 | 'hello mars', 59 | ]) 60 | 61 | def test_put(self): 62 | """ 63 | Test that PUT request updates all submitted resources. 64 | """ 65 | obj1 = SimpleModel.objects.create(contents='hello world', number=1) 66 | obj2 = SimpleModel.objects.create(contents='hello mars', number=2) 67 | 68 | response = self.view(self.request.put( 69 | '', 70 | json.dumps([ 71 | {'contents': 'foo', 'number': 3, 'id': obj1.pk}, 72 | {'contents': 'bar', 'number': 4, 'id': obj2.pk}, 73 | ]), 74 | content_type='application/json', 75 | )) 76 | 77 | self.assertEqual(response.status_code, status.HTTP_200_OK) 78 | self.assertEqual(SimpleModel.objects.count(), 2) 79 | self.assertEqual( 80 | list(SimpleModel.objects.all().values_list('id', 'contents', 'number')), 81 | [ 82 | (obj1.pk, 'foo', 3), 83 | (obj2.pk, 'bar', 4), 84 | ] 85 | ) 86 | 87 | def test_put_without_update_key(self): 88 | """ 89 | Test that PUT request updates all submitted resources. 90 | """ 91 | response = self.view(self.request.put( 92 | '', 93 | json.dumps([ 94 | {'contents': 'foo', 'number': 3}, 95 | {'contents': 'rainbows', 'number': 4}, # multiple objects without id 96 | {'contents': 'bar', 'number': 4, 'id': 555}, # non-existing id 97 | ]), 98 | content_type='application/json', 99 | )) 100 | 101 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 102 | 103 | def test_patch(self): 104 | """ 105 | Test that PATCH request partially updates all submitted resources. 106 | """ 107 | obj1 = SimpleModel.objects.create(contents='hello world', number=1) 108 | obj2 = SimpleModel.objects.create(contents='hello mars', number=2) 109 | 110 | response = self.view(self.request.patch( 111 | '', 112 | json.dumps([ 113 | {'contents': 'foo', 'id': obj1.pk}, 114 | {'contents': 'bar', 'id': obj2.pk}, 115 | ]), 116 | content_type='application/json', 117 | )) 118 | 119 | self.assertEqual(response.status_code, status.HTTP_200_OK) 120 | self.assertEqual(SimpleModel.objects.count(), 2) 121 | self.assertEqual( 122 | list(SimpleModel.objects.all().values_list('id', 'contents', 'number')), 123 | [ 124 | (obj1.pk, 'foo', 1), 125 | (obj2.pk, 'bar', 2), 126 | ] 127 | ) 128 | 129 | def test_delete_not_filtered(self): 130 | """ 131 | Test that DELETE is not allowed when results are not filtered. 132 | """ 133 | SimpleModel.objects.create(contents='hello world', number=1) 134 | SimpleModel.objects.create(contents='hello mars', number=10) 135 | 136 | response = self.view(self.request.delete('')) 137 | 138 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 139 | 140 | def test_delete_filtered(self): 141 | """ 142 | Test that DELETE removes all filtered resources. 143 | """ 144 | SimpleModel.objects.create(contents='hello world', number=1) 145 | SimpleModel.objects.create(contents='hello mars', number=10) 146 | 147 | response = FilteredBulkAPIView.as_view()(self.request.delete('')) 148 | 149 | self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) 150 | self.assertEqual(SimpleModel.objects.count(), 1) 151 | self.assertEqual(SimpleModel.objects.get().contents, 'hello world') 152 | 153 | def test_options(self): 154 | """ 155 | Test that OPTIONS request is successful on bulk view. 156 | """ 157 | response = self.view(self.request.options('')) 158 | 159 | self.assertEqual(response.status_code, status.HTTP_200_OK) 160 | 161 | 162 | class TestBulkAPIViewSet(TestCase): 163 | """ 164 | Integration class testing that viewset requests are correctly 165 | routed via bulk router and that expected status code is returned. 166 | """ 167 | 168 | def setUp(self): 169 | super(TestBulkAPIViewSet, self).setUp() 170 | self.url = reverse('api:simple-list') 171 | 172 | def test_get_single(self): 173 | """ 174 | Test that we are still able to query single resource 175 | """ 176 | response = self.client.get(self.url) 177 | 178 | self.assertEqual(response.status_code, status.HTTP_200_OK) 179 | 180 | def test_get(self): 181 | """ 182 | Test that GET returns 200 183 | """ 184 | obj = SimpleModel.objects.create(contents='hello world', number=7) 185 | 186 | response = self.client.get(reverse('api:simple-detail', args=[obj.pk])) 187 | 188 | self.assertEqual(response.status_code, status.HTTP_200_OK) 189 | 190 | def test_post_single(self): 191 | """ 192 | Test that POST with single resource returns 201 193 | """ 194 | response = self.client.post( 195 | self.url, 196 | data=json.dumps({ 197 | 'contents': 'hello world', 198 | 'number': 1, 199 | }), 200 | content_type='application/json', 201 | ) 202 | 203 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 204 | 205 | def test_post_bulk(self): 206 | """ 207 | Test that POST with multiple resources returns 201 208 | """ 209 | response = self.client.post( 210 | self.url, 211 | data=json.dumps([ 212 | { 213 | 'contents': 'hello world', 214 | 'number': 1, 215 | }, 216 | { 217 | 'contents': 'hello mars', 218 | 'number': 2, 219 | }, 220 | ]), 221 | content_type='application/json', 222 | ) 223 | 224 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 225 | 226 | def test_put(self): 227 | """ 228 | Test that PUT with multiple resources returns 200 229 | """ 230 | obj1 = SimpleModel.objects.create(contents='hello world', number=7) 231 | obj2 = SimpleModel.objects.create(contents='hello mars', number=10) 232 | 233 | response = self.client.put( 234 | self.url, 235 | data=json.dumps([ 236 | { 237 | 'contents': 'foo', 238 | 'number': 1, 239 | 'id': obj1.pk, 240 | }, 241 | { 242 | 'contents': 'bar', 243 | 'number': 2, 244 | 'id': obj2.pk, 245 | }, 246 | ]), 247 | content_type='application/json', 248 | ) 249 | 250 | self.assertEqual(response.status_code, status.HTTP_200_OK) 251 | 252 | def test_patch(self): 253 | """ 254 | Test that PATCH with multiple partial resources returns 200 255 | """ 256 | obj1 = SimpleModel.objects.create(contents='hello world', number=7) 257 | obj2 = SimpleModel.objects.create(contents='hello mars', number=10) 258 | 259 | response = self.client.patch( 260 | self.url, 261 | data=json.dumps([ 262 | { 263 | 'contents': 'foo', 264 | 'id': obj1.pk, 265 | }, 266 | { 267 | 'contents': 'bar', 268 | 'id': obj2.pk, 269 | }, 270 | ]), 271 | content_type='application/json', 272 | ) 273 | 274 | self.assertEqual(response.status_code, status.HTTP_200_OK) 275 | 276 | def test_delete(self): 277 | """ 278 | Test that PATCH with multiple partial resources returns 200 279 | """ 280 | SimpleModel.objects.create(contents='hello world', number=7) 281 | SimpleModel.objects.create(contents='hello mars', number=10) 282 | 283 | response = self.client.delete(self.url) 284 | 285 | self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) 286 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from __future__ import unicode_literals 4 | import os 5 | from setuptools import find_packages, setup 6 | 7 | from rest_framework_bulk import __version__, __author__ 8 | 9 | 10 | def read(fname): 11 | return (open(os.path.join(os.path.dirname(__file__), fname), 'rb') 12 | .read().decode('utf-8')) 13 | 14 | 15 | authors = read('AUTHORS.rst') 16 | history = read('HISTORY.rst').replace('.. :changelog:', '') 17 | licence = read('LICENSE.rst') 18 | readme = read('README.rst') 19 | 20 | requirements = read('requirements.txt').splitlines() + [ 21 | 'setuptools', 22 | ] 23 | 24 | test_requirements = ( 25 | read('requirements.txt').splitlines() 26 | + read('requirements-dev.txt').splitlines()[1:] 27 | ) 28 | 29 | setup( 30 | name='djangorestframework-bulk', 31 | version=__version__, 32 | author=__author__, 33 | author_email='miroslav@miki725.com', 34 | description='Django REST Framework bulk CRUD view mixins', 35 | long_description='\n\n'.join([readme, history, authors, licence]), 36 | url='https://github.com/miki725/django-rest-framework-bulk', 37 | license='MIT', 38 | keywords='django', 39 | packages=find_packages(), 40 | install_requires=requirements, 41 | tests_require=test_requirements, 42 | classifiers=[ 43 | 'Development Status :: 3 - Alpha', 44 | 'Framework :: Django', 45 | 'Intended Audience :: Developers', 46 | 'Operating System :: OS Independent', 47 | 'Programming Language :: Python', 48 | 'Programming Language :: Python :: 3', 49 | 'Topic :: Utilities', 50 | 'Topic :: Internet :: WWW/HTTP', 51 | 'License :: OSI Approved :: MIT License', 52 | ], 53 | ) 54 | -------------------------------------------------------------------------------- /tests/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | sys.path.append(os.path.abspath(os.path.join(__file__, '..', '..'))) 6 | 7 | if __name__ == "__main__": 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") 9 | 10 | from django.core.management import execute_from_command_line 11 | 12 | execute_from_command_line(sys.argv) 13 | -------------------------------------------------------------------------------- /tests/runtests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | export DJANGO_MANAGE=manage.py 4 | export args="$@" 5 | 6 | python $DJANGO_MANAGE test rest_framework_bulk "$args" 7 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | # Bare ``settings.py`` for running tests for rest_framework_bulk 2 | 3 | DEBUG = True 4 | 5 | DATABASES = { 6 | 'default': { 7 | 'ENGINE': 'django.db.backends.sqlite3', 8 | 'NAME': 'rest_framework_bulk.sqlite', 9 | } 10 | } 11 | 12 | MIDDLEWARE_CLASSES = () 13 | 14 | INSTALLED_APPS = ( 15 | 'django.contrib.auth', 16 | 'django.contrib.contenttypes', 17 | 'django.contrib.staticfiles', 18 | 'rest_framework', 19 | 'rest_framework_bulk', 20 | 'rest_framework_bulk.tests.simple_app', 21 | ) 22 | 23 | STATIC_URL = '/static/' 24 | SECRET_KEY = 'foo' 25 | 26 | ROOT_URLCONF = 'rest_framework_bulk.tests.simple_app.urls' 27 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | {py27,py34,pypy,pypy3}-drf{2,3} 4 | 5 | [testenv] 6 | basepython = 7 | py27: python2.7 8 | py34: python3.4 9 | pypy: pypy 10 | pypy3: pypy3 11 | setenv = 12 | PYTHONPATH = {toxinidir} 13 | commands = 14 | make install-quite 15 | pip freeze 16 | make check 17 | deps = 18 | drf2: djangorestframework<3 19 | drf3: djangorestframework>=3 20 | whitelist_externals = 21 | make 22 | 23 | [testenv:py27-drf2] 24 | deps = 25 | django<1.8 26 | 27 | [testenv:py34-drf2] 28 | deps = 29 | django<1.8 30 | 31 | [testenv:pypy-drf2] 32 | deps = 33 | django<1.8 34 | 35 | [testenv:pypy3-drf2] 36 | deps = 37 | django<1.8 38 | 39 | [flake8] 40 | max-line-length = 100 41 | --------------------------------------------------------------------------------