├── .gitignore ├── .travis.yml ├── LICENSE.txt ├── Pipfile ├── Pipfile.lock ├── README.md ├── django_model_mutations ├── __init__.py ├── mixins.py ├── mutations.py └── utils.py ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── client.py ├── conftest.py ├── models.py ├── schema.py ├── serializers.py ├── test_mutations.py └── urls.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | .idea/ 107 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - 3.5 5 | - 3.6 6 | - 3.7 7 | - 3.8 8 | 9 | before_install: 10 | - python --version 11 | - pip install -U pip 12 | - pip install -U pytest 13 | - pip install pytest-django 14 | - pip install django-rest-framework 15 | - pip install codecov 16 | 17 | install: 18 | - pip install ".[test]" . 19 | 20 | script: pytest 21 | 22 | after_success: 23 | - codecov # submit coverage 24 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Tomáš Opletal 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | django-rest-framework = "*" 8 | pytest-django = "*" 9 | 10 | [packages] 11 | django-rest-framework = "*" 12 | graphene-django = "*" 13 | 14 | [requires] 15 | python_version = "3.8" 16 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "a9e6b67bbdfcee90833a89aed8fc1d1b2ff3d9e0cfb9033dc5670ee16fc3ad96" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.8" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "aniso8601": { 20 | "hashes": [ 21 | "sha256:513d2b6637b7853806ae79ffaca6f3e8754bdd547048f5ccc1420aec4b714f1e", 22 | "sha256:d10a4bf949f619f719b227ef5386e31f49a2b6d453004b21f02661ccc8670c7b" 23 | ], 24 | "version": "==7.0.0" 25 | }, 26 | "django": { 27 | "hashes": [ 28 | "sha256:16040e1288c6c9f68c6da2fe75ebde83c0a158f6f5d54f4c5177b0c1478c5b86", 29 | "sha256:89c2007ca4fa5b351a51a279eccff298520783b713bf28efb89dfb81c80ea49b" 30 | ], 31 | "version": "==2.2.7" 32 | }, 33 | "django-rest-framework": { 34 | "hashes": [ 35 | "sha256:47a8f496fa69e3b6bd79f68dd7a1527d907d6b77f009e9db7cf9bb21cc565e4a" 36 | ], 37 | "index": "pypi", 38 | "version": "==0.1.0" 39 | }, 40 | "djangorestframework": { 41 | "hashes": [ 42 | "sha256:5488aed8f8df5ec1d70f04b2114abc52ae6729748a176c453313834a9ee179c8", 43 | "sha256:dc81cbf9775c6898a580f6f1f387c4777d12bd87abf0f5406018d32ccae71090" 44 | ], 45 | "version": "==3.10.3" 46 | }, 47 | "graphene": { 48 | "hashes": [ 49 | "sha256:09165f03e1591b76bf57b133482db9be6dac72c74b0a628d3c93182af9c5a896", 50 | "sha256:2cbe6d4ef15cfc7b7805e0760a0e5b80747161ce1b0f990dfdc0d2cf497c12f9" 51 | ], 52 | "version": "==2.1.8" 53 | }, 54 | "graphene-django": { 55 | "hashes": [ 56 | "sha256:0d1404ab1ba7873a8e848d87d138f615391d100ed0c046482c164429cfb992ef", 57 | "sha256:fa9e815f63e3c972773adc96c3f3c81e537dba0edcd0f1be92c799bc0b6ed0a8" 58 | ], 59 | "index": "pypi", 60 | "version": "==2.6.0" 61 | }, 62 | "graphql-core": { 63 | "hashes": [ 64 | "sha256:1488f2a5c2272dc9ba66e3042a6d1c30cea0db4c80bd1e911c6791ad6187d91b", 65 | "sha256:da64c472d720da4537a2e8de8ba859210b62841bd47a9be65ca35177f62fe0e4" 66 | ], 67 | "version": "==2.2.1" 68 | }, 69 | "graphql-relay": { 70 | "hashes": [ 71 | "sha256:0e94201af4089e1f81f07d7bd8f84799768e39d70fa1ea16d1df505b46cc6335", 72 | "sha256:75aa0758971e252964cb94068a4decd472d2a8295229f02189e3cbca1f10dbb5", 73 | "sha256:7fa74661246e826ef939ee92e768f698df167a7617361ab399901eaebf80dce6" 74 | ], 75 | "version": "==2.0.0" 76 | }, 77 | "promise": { 78 | "hashes": [ 79 | "sha256:2ebbfc10b7abf6354403ed785fe4f04b9dfd421eb1a474ac8d187022228332af", 80 | "sha256:348f5f6c3edd4fd47c9cd65aed03ac1b31136d375aa63871a57d3e444c85655c" 81 | ], 82 | "version": "==2.2.1" 83 | }, 84 | "pytz": { 85 | "hashes": [ 86 | "sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d", 87 | "sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be" 88 | ], 89 | "version": "==2019.3" 90 | }, 91 | "rx": { 92 | "hashes": [ 93 | "sha256:13a1d8d9e252625c173dc795471e614eadfe1cf40ffc684e08b8fff0d9748c23", 94 | "sha256:7357592bc7e881a95e0c2013b73326f704953301ab551fbc8133a6fadab84105" 95 | ], 96 | "version": "==1.6.1" 97 | }, 98 | "singledispatch": { 99 | "hashes": [ 100 | "sha256:5b06af87df13818d14f08a028e42f566640aef80805c3b50c5056b086e3c2b9c", 101 | "sha256:833b46966687b3de7f438c761ac475213e53b306740f1abfaa86e1d1aae56aa8" 102 | ], 103 | "version": "==3.4.0.3" 104 | }, 105 | "six": { 106 | "hashes": [ 107 | "sha256:1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd", 108 | "sha256:30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66" 109 | ], 110 | "version": "==1.13.0" 111 | }, 112 | "sqlparse": { 113 | "hashes": [ 114 | "sha256:40afe6b8d4b1117e7dff5504d7a8ce07d9a1b15aeeade8a2d10f130a834f8177", 115 | "sha256:7c3dca29c022744e95b547e867cee89f4fce4373f3549ccd8797d8eb52cdb873" 116 | ], 117 | "version": "==0.3.0" 118 | } 119 | }, 120 | "develop": { 121 | "atomicwrites": { 122 | "hashes": [ 123 | "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4", 124 | "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6" 125 | ], 126 | "version": "==1.3.0" 127 | }, 128 | "attrs": { 129 | "hashes": [ 130 | "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", 131 | "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" 132 | ], 133 | "version": "==19.3.0" 134 | }, 135 | "django-rest-framework": { 136 | "hashes": [ 137 | "sha256:47a8f496fa69e3b6bd79f68dd7a1527d907d6b77f009e9db7cf9bb21cc565e4a" 138 | ], 139 | "index": "pypi", 140 | "version": "==0.1.0" 141 | }, 142 | "djangorestframework": { 143 | "hashes": [ 144 | "sha256:5488aed8f8df5ec1d70f04b2114abc52ae6729748a176c453313834a9ee179c8", 145 | "sha256:dc81cbf9775c6898a580f6f1f387c4777d12bd87abf0f5406018d32ccae71090" 146 | ], 147 | "version": "==3.10.3" 148 | }, 149 | "more-itertools": { 150 | "hashes": [ 151 | "sha256:409cd48d4db7052af495b09dec721011634af3753ae1ef92d2b32f73a745f832", 152 | "sha256:92b8c4b06dac4f0611c0729b2f2ede52b2e1bac1ab48f089c7ddc12e26bb60c4" 153 | ], 154 | "version": "==7.2.0" 155 | }, 156 | "packaging": { 157 | "hashes": [ 158 | "sha256:28b924174df7a2fa32c1953825ff29c61e2f5e082343165438812f00d3a7fc47", 159 | "sha256:d9551545c6d761f3def1677baf08ab2a3ca17c56879e70fecba2fc4dde4ed108" 160 | ], 161 | "version": "==19.2" 162 | }, 163 | "pluggy": { 164 | "hashes": [ 165 | "sha256:0db4b7601aae1d35b4a033282da476845aa19185c1e6964b25cf324b5e4ec3e6", 166 | "sha256:fa5fa1622fa6dd5c030e9cad086fa19ef6a0cf6d7a2d12318e10cb49d6d68f34" 167 | ], 168 | "version": "==0.13.0" 169 | }, 170 | "py": { 171 | "hashes": [ 172 | "sha256:64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa", 173 | "sha256:dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53" 174 | ], 175 | "version": "==1.8.0" 176 | }, 177 | "pyparsing": { 178 | "hashes": [ 179 | "sha256:20f995ecd72f2a1f4bf6b072b63b22e2eb457836601e76d6e5dfcd75436acc1f", 180 | "sha256:4ca62001be367f01bd3e92ecbb79070272a9d4964dce6a48a82ff0b8bc7e683a" 181 | ], 182 | "version": "==2.4.5" 183 | }, 184 | "pytest": { 185 | "hashes": [ 186 | "sha256:8e256fe71eb74e14a4d20a5987bb5e1488f0511ee800680aaedc62b9358714e8", 187 | "sha256:ff0090819f669aaa0284d0f4aad1a6d9d67a6efdc6dd4eb4ac56b704f890a0d6" 188 | ], 189 | "version": "==5.2.4" 190 | }, 191 | "pytest-django": { 192 | "hashes": [ 193 | "sha256:17592f06d51c2ef4b7a0fb24aa32c8b6998506a03c8439606cb96db160106659", 194 | "sha256:ef3d15b35ed7e46293475e6f282e71a53bcd8c6f87bdc5d5e7ad0f4d49352127" 195 | ], 196 | "index": "pypi", 197 | "version": "==3.7.0" 198 | }, 199 | "six": { 200 | "hashes": [ 201 | "sha256:1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd", 202 | "sha256:30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66" 203 | ], 204 | "version": "==1.13.0" 205 | }, 206 | "wcwidth": { 207 | "hashes": [ 208 | "sha256:3df37372226d6e63e1b1e1eda15c594bca98a22d33a23832a90998faa96bc65e", 209 | "sha256:f4ebe71925af7b40a864553f761ed559b43544f8f71746c2d756c7fe788ade7c" 210 | ], 211 | "version": "==0.1.7" 212 | } 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django Model Mutations 2 | 3 | [![build status]( 4 | http://img.shields.io/travis/topletal/django-model-mutations/master.svg?style=flat)](https://travis-ci.org/topletal/django-model-mutations) 5 | [![PyPI version](https://badge.fury.io/py/django-model-mutations.svg)](https://badge.fury.io/py/django-model-mutations) 6 | 7 | This package adds Mutation classes that make creating graphene mutations with django models easier using Django Rest Framework serializers. It extends graphene Mutation class in a similar way to Django Rest Framework views or original Django views. 8 | 9 | It also provides easy way to add permissions checks or ensure logged-in user, as well as easy way to override or add funcionality similar to django forms or rest framework views - such as ```get_queryset()``` or ```save()``` functions. 10 | 11 | There is also advanced error reporting from rest framework, that returns non-valid fields and error messages. 12 | 13 | Inspired by [Saleor](https://github.com/mirumee/saleor), [graphene-django-extras](https://github.com/eamigo86/graphene-django-extras/tree/master/graphene_django_extras) and [django-rest-framework](https://github.com/encode/django-rest-framework) 14 | 15 | ## Installation 16 | ``` 17 | pip install django-model-mutations 18 | ``` 19 | 20 | 21 | ## Basic Usage 22 | Main classes that this package provides: 23 | 24 | | mutations | mixins | 25 | | ------ | ------ | 26 | |CreateModelMutation|LoginRequiredMutationMixin| 27 | |CreateBulkModelMutation| 28 | |UpdateModelMutation| 29 | |UpdateBulkModelMutation| 30 | |DeleteModelMutation| 31 | |DeleteBulkModelMutation| 32 | 33 | 34 | #### Django usage 35 | Input type (Arguments) is generated from serializer fields 36 | Return type is retrieved by model from global graphene registry, you just have to import it as in example 37 | ```python 38 | from django_model_mutations import mutations, mixins 39 | from your_app.types import UserType # important to import types to register in global registry 40 | from your_app.serializers import UserSerializer 41 | 42 | 43 | # Create Mutations 44 | # use mixins.LoginRequiredMutationMixin to ensure only logged-in user can perform this mutation 45 | # MAKE SURE this mixin is FIRST in inheritance order 46 | class UserCreateMutation(mixins.LoginRequiredMutationMixin, mutations.CreateModelMutation): 47 | class Meta: 48 | serializer_class = UserSerializer 49 | # OPTIONAL META FIELDS: 50 | permissions = ('your_app.user_permission',) # OPTIONAL: specify user permissions 51 | lookup_field = 'publicId' # OPTIONAL: specify database lookup column, default is 'id' or 'ids' 52 | return_field_name = 'myUser' # OPTIONAL: specify return field name, default is model name 53 | input_field_name = 'myUser' # OPTIONAL: specify input field name, defauls is 'input' 54 | 55 | 56 | class UserBulkCreateMutation(mutations.CreateBulkModelMutation): 57 | class Meta: 58 | serializer_class = UserSerializer 59 | 60 | 61 | # Update Mutations 62 | class UserUpdateMutation(mutations.UpdateModelMutation): 63 | class Meta: 64 | serializer_class = UserSerializer 65 | 66 | # WARNING: Bulk update DOES NOT USE serializer, due to limitations of rest framework serializer. 67 | # Instead specify model and argument fields by yourself. 68 | class UserBulkUpdateMutation(mutations.UpdateBulkModelMutation): 69 | class Arguments: 70 | is_active = graphene.Boolean() 71 | 72 | class Meta: 73 | model = User 74 | 75 | # Delete Mutations 76 | # delete mutations doesn't use serializers, as there is no need 77 | class UserDeleteMutation(mutations.DeleteModelMutation): 78 | class Meta: 79 | model = User 80 | 81 | class UserBulkDeleteMutation(mutations.DeleteBulkModelMutation): 82 | class Meta: 83 | model = User 84 | 85 | 86 | # Add to graphene schema as usual 87 | class Mutation(graphene.ObjectType): 88 | user_create = UserCreateMutation.Field() 89 | .... 90 | 91 | schema = graphene.Schema(mutation=Mutation) 92 | ``` 93 | 94 | 95 | #### GraphQl usage 96 | The generated GraphQl schema can be modified with ```Meta``` fields as described above in ```UserCreateMutation```. 97 | 98 | By default all mutations have ```errors``` field with ```field``` and ```messages``` that contain validation errors from rest-framework serializer or lookup errors. For now permission denied and other exceptions will not use this error reporting, but a default one, for usage see tests. 99 | ```graphql 100 | # default argument name is input 101 | # default return field name is model name 102 | mutation userCreate (input: {username: "myUsername"}) { 103 | user { 104 | id 105 | username 106 | } 107 | errors { 108 | field 109 | messages 110 | } 111 | } 112 | 113 | 114 | # Bulk operations return 'count' and errors 115 | mutation userBulkCreate (input: [{username: "myUsername"}, {username:"myusername2"}]) { 116 | count 117 | errors { 118 | field 119 | messages 120 | } 121 | } 122 | 123 | # update mutations 124 | # update and delete mutations by default specify lookup field 'id' or 'ids' for bulk mutations 125 | mutation { 126 | userUpdate (id: 2, input: {username:"newUsername"} ) { 127 | user { 128 | id 129 | username 130 | } 131 | errors { 132 | field 133 | messages 134 | } 135 | } 136 | } 137 | 138 | 139 | mutation { 140 | userBulkUpdate (ids: [2, 3], isActive: false ) { 141 | count 142 | errors { 143 | field 144 | messages 145 | } 146 | } 147 | } 148 | 149 | 150 | # delete mutations 151 | mutation { 152 | userDelete (id: 1) { 153 | user { 154 | id 155 | } 156 | errors { 157 | field 158 | messages 159 | } 160 | } 161 | } 162 | 163 | 164 | mutation { 165 | userBulkDelete (ids: [1, 2, 3]) { 166 | count 167 | errors { 168 | field 169 | messages 170 | } 171 | } 172 | } 173 | ``` 174 | 175 | ### Adding funcionality 176 | All classes are derived from ```graphene.Mutation```. When you want to override some major functionality, the best place probabably is ```perform_mutate```, which is called after permission checks from graphene ```mutate```. 177 | 178 | In general probably the main functions that you want to override are: ```save()``` and ```get_object()``` for single object mutations or ```get_queryset()``` for bulk mutations. 179 | ```get_object``` or ```get_queryset``` you should override to add more filters for fetching the object. 180 | ```save``` performs final save/update/delete to database and you can add additional fields there. 181 | 182 | Examples: 183 | ```python 184 | # lets only update users that are inactive and add some random field 185 | class UserUpdateInactiveMutation(mutations.UpdateModelMutation): 186 | class Meta: 187 | model = User 188 | 189 | @classmethod 190 | def get_object(cls, object_id, info, **input): 191 | # can get the object first and then check 192 | obj = super(UserUpdateInactiveMutation, cls).get_object(object_id, info, **input) 193 | if obj.is_active: 194 | return None 195 | return obj 196 | 197 | @classmethod 198 | def save(cls, serializer, root, info, **input): 199 | saved_object = serializer.save(updated_by=info.context.user) 200 | return cls.return_success(saved_object) 201 | 202 | 203 | # same but for bulk mutation we have to override get_queryset 204 | class UserBulkUpdateInactiveMutation(mutations.UpdateBulkModelMutation): 205 | class Meta: 206 | model = User 207 | 208 | @classmethod 209 | def get_queryset(cls, object_ids, info, **input): 210 | qs = super(UserBulkUpdateInactiveMutation, cls).get_queryset(object_ids, info, **input) 211 | qs.filter(is_active=False) 212 | return qs 213 | ``` 214 | 215 | For the whole function flow, please check the Base models in ```django_model_mutations\mutations.py```. 216 | It was inspired by rest framework, so you can find functions like ```get_serializer_kwargs```, ```get_serializer```, ```validate_instance``` (for example here you can override default ```ValidationError``` exception and return None if you don't want exception of non existing id lookup etc.) 217 | 218 | ## Contributing 219 | Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. 220 | 221 | Please make sure to update tests as appropriate. 222 | 223 | ## License 224 | [MIT](https://choosealicense.com/licenses/mit/) 225 | -------------------------------------------------------------------------------- /django_model_mutations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topletal/django-model-mutations/ea55bfc495fa6058669f1f5c66e16833d185a4ca/django_model_mutations/__init__.py -------------------------------------------------------------------------------- /django_model_mutations/mixins.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import gettext_lazy as _ 2 | 3 | 4 | class LoginRequiredMutationMixin: 5 | @classmethod 6 | def mutate(cls, root, info, **input): 7 | if not info.context.user.is_authenticated: 8 | raise PermissionError(_("Login required")) 9 | return super().mutate(root, info, **input) -------------------------------------------------------------------------------- /django_model_mutations/mutations.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | 3 | import graphene 4 | from django.core.exceptions import ValidationError, ImproperlyConfigured, ObjectDoesNotExist 5 | from graphene.types.mutation import MutationOptions 6 | from graphene_django.types import ErrorType 7 | from django.utils.translation import gettext_lazy as _ 8 | 9 | from .utils import get_errors, get_output_fields, get_model_name, convert_serializer_to_input_type, serialize_errors 10 | 11 | 12 | #################### 13 | # BASE MUTATIONS # 14 | class BaseModelMutationOptions(MutationOptions): 15 | model = None 16 | lookup_field = None 17 | permissions = None 18 | 19 | 20 | class BaseModelMutation(graphene.Mutation): 21 | class Meta: 22 | abstract = True 23 | 24 | errors = graphene.List(ErrorType, description="List of errors") 25 | 26 | @classmethod 27 | def __init_subclass_with_meta__( 28 | cls, 29 | model=None, 30 | lookup_field=None, 31 | arguments=None, 32 | return_field_name=None, 33 | _meta=None, 34 | permissions=None, 35 | **options 36 | ): 37 | 38 | if not _meta: 39 | _meta = BaseModelMutationOptions(cls) 40 | 41 | if not model and not _meta.model: 42 | raise ImproperlyConfigured("model is required for {}".format(cls.__name__)) 43 | 44 | if not lookup_field and not _meta.lookup_field: 45 | lookup_field = model._meta.pk.name 46 | 47 | _meta.lookup_field = lookup_field 48 | _meta.model = model 49 | _meta.permissions = permissions 50 | super(BaseModelMutation, cls).__init_subclass_with_meta__(_meta=_meta, **options) 51 | arguments = cls.get_arguments(arguments) 52 | if arguments: 53 | _meta.arguments.update(arguments) 54 | 55 | @classmethod 56 | def get_arguments(cls, arguments): 57 | if not arguments: 58 | arguments = OrderedDict() 59 | return arguments 60 | 61 | @classmethod 62 | def mutate(cls, root, info, **input): 63 | if not cls.check_permissions(root, info, **input): 64 | raise PermissionError(_("Permission denied")) 65 | 66 | try: 67 | mutation_object = cls.get_mutation_object(root, info, **input) 68 | return cls.perform_mutate(mutation_object, root, info, **input) 69 | except ValidationError as e: 70 | errs = get_errors(e.error_dict) 71 | return cls(errors=errs) 72 | 73 | @classmethod 74 | def check_permissions(cls, root, info, **input): 75 | if not cls._meta.permissions: 76 | return True 77 | if info.context.user.has_perms(cls._meta.permissions): 78 | return True 79 | return False 80 | 81 | @classmethod 82 | def get_mutation_object(cls, root, info, **input): 83 | raise NotImplementedError() 84 | 85 | @classmethod 86 | def perform_mutate(cls, mutation_object, root, info, **input): 87 | return cls.save(mutation_object, root, info, **input) 88 | 89 | @classmethod 90 | def save(cls, mutation_object, root, info, **input): 91 | raise NotImplementedError() 92 | 93 | 94 | class BaseSingleModelMutationOptions(BaseModelMutationOptions): 95 | return_field_name = None 96 | 97 | 98 | class BaseSingleModelMutation(BaseModelMutation): 99 | class Meta: 100 | abstract = True 101 | 102 | @classmethod 103 | def __init_subclass_with_meta__( 104 | cls, 105 | model=None, 106 | lookup_field=None, 107 | arguments=None, 108 | return_field_name=None, 109 | _meta=None, 110 | **options 111 | ): 112 | if not _meta: 113 | _meta = BaseSingleModelMutationOptions(cls) 114 | 115 | if not return_field_name: 116 | return_field_name = get_model_name(model or _meta.model) 117 | _meta.return_field_name = return_field_name 118 | _meta.fields = get_output_fields(model, return_field_name) 119 | super(BaseSingleModelMutation, cls).__init_subclass_with_meta__(model=model, arguments=arguments, 120 | lookup_field=lookup_field, 121 | _meta=_meta, **options) 122 | 123 | @classmethod 124 | def get_arguments(cls, arguments): 125 | if not arguments: 126 | arguments = OrderedDict() 127 | arguments[cls._meta.lookup_field] = graphene.ID(required=True, description="Object identifier") 128 | return super(BaseSingleModelMutation, cls).get_arguments(arguments) 129 | 130 | @classmethod 131 | def get_mutation_object(cls, root, info, **input): 132 | lookup_id = input.get(cls._meta.lookup_field, None) 133 | instance = None 134 | if lookup_id: 135 | try: 136 | instance = cls.get_object(lookup_id, info, **input) 137 | except ObjectDoesNotExist: 138 | pass 139 | return cls.validate_instance(instance, info, **input) 140 | 141 | @classmethod 142 | def get_object(cls, object_id, info, **input): 143 | return cls._meta.model.objects.get(**{cls._meta.lookup_field: object_id}) 144 | 145 | @classmethod 146 | def validate_instance(cls, instance, info, **input): 147 | if instance is None: 148 | raise ValidationError({cls._meta.lookup_field: _("Object does not exist")}) 149 | return instance 150 | 151 | @classmethod 152 | def return_success(cls, instance): 153 | kwargs = {cls._meta.return_field_name: instance} 154 | return cls(errors=[], **kwargs) 155 | 156 | @classmethod 157 | def save(cls, mutation_object, root, info, **input): 158 | raise NotImplementedError() 159 | 160 | 161 | class BaseBulkModelMutation(BaseModelMutation): 162 | class Meta: 163 | abstract = True 164 | 165 | count = graphene.Int(description="Number of objects mutation was performed on") 166 | 167 | @classmethod 168 | def __init_subclass_with_meta__( 169 | cls, 170 | model=None, 171 | lookup_field=None, 172 | arguments=None, 173 | input_field_name='input', 174 | _meta=None, 175 | **options 176 | ): 177 | if not _meta: 178 | _meta = BaseModelMutationOptions(cls) 179 | super(BaseBulkModelMutation, cls).__init_subclass_with_meta__(model=model, arguments=arguments, 180 | lookup_field=lookup_field, 181 | _meta=_meta, **options) 182 | 183 | @classmethod 184 | def get_arguments(cls, arguments): 185 | if not arguments: 186 | arguments = OrderedDict() 187 | 188 | arguments[cls.get_input_lookup_field()] = graphene.List(graphene.ID, required=True, 189 | description="Object identifiers") 190 | return super(BaseBulkModelMutation, cls).get_arguments(arguments) 191 | 192 | @classmethod 193 | def get_mutation_object(cls, root, info, **input): 194 | lookup_field = cls.get_input_lookup_field() 195 | lookup_ids = input.get(lookup_field, None) 196 | queryset = cls.get_queryset(lookup_ids, info, **input) 197 | return queryset 198 | 199 | @classmethod 200 | def get_queryset(cls, object_ids, info, **input): 201 | return cls._meta.model.objects.filter(**{"{}__in".format(cls._meta.lookup_field): object_ids}) 202 | 203 | @classmethod 204 | def return_success(cls, count): 205 | kwargs = {"count": count} 206 | return cls(errors=[], **kwargs) 207 | 208 | @classmethod 209 | def get_input_lookup_field(cls): 210 | return "{}s".format(cls._meta.lookup_field) 211 | 212 | @classmethod 213 | def save(cls, mutation_object, root, info, **input): 214 | raise NotImplementedError() 215 | 216 | 217 | class ModelSerializerMutationOptions(BaseModelMutationOptions): 218 | serializer_class = None 219 | input_field_name = None 220 | 221 | 222 | class ModelSerializerMutation(BaseModelMutation): 223 | class Meta: 224 | abstract = True 225 | 226 | @classmethod 227 | def __init_subclass_with_meta__( 228 | cls, 229 | serializer_class=None, 230 | model=None, 231 | lookup_field=None, 232 | input_field_name='input', 233 | fields=(), 234 | exclude=(), 235 | arguments=None, 236 | _meta=None, 237 | **options 238 | ): 239 | 240 | if not _meta: 241 | _meta = ModelSerializerMutationOptions(cls) 242 | 243 | if not serializer_class: 244 | raise ImproperlyConfigured("serializer_class is required for {}".format(cls.__name__)) 245 | 246 | if not model: 247 | model = serializer_class.Meta.model 248 | 249 | if not model: 250 | raise ImproperlyConfigured("model is required for {}".format(cls.__name__)) 251 | 252 | _meta.serializer_class = serializer_class 253 | _meta.input_field_name = input_field_name 254 | super(ModelSerializerMutation, cls).__init_subclass_with_meta__( 255 | _meta=_meta, model=model, lookup_field=lookup_field, arguments=arguments, **options 256 | ) 257 | 258 | @classmethod 259 | def is_input_required(cls): 260 | return True 261 | 262 | @classmethod 263 | def get_arguments(cls, arguments): 264 | if not arguments: 265 | arguments = OrderedDict() 266 | input_type = convert_serializer_to_input_type(cls._meta.serializer_class, cls.is_input_required()) 267 | arguments[cls._meta.input_field_name] = graphene.Argument(input_type, required=True) 268 | return super(ModelSerializerMutation, cls).get_arguments(arguments) 269 | 270 | @classmethod 271 | def get_serializer_kwargs(cls, serializer_object, serializer_input): 272 | kwargs = {"instance": serializer_object, "data": serializer_input, "partial": not cls.is_input_required()} 273 | return kwargs 274 | 275 | @classmethod 276 | def get_serializer(cls, serializer_object, info, **input): 277 | serializer_input = input[cls._meta.input_field_name] 278 | serializer_kwargs = cls.get_serializer_kwargs(serializer_object, serializer_input) 279 | serializer = cls._meta.serializer_class(**serializer_kwargs) 280 | return serializer 281 | 282 | @classmethod 283 | def perform_mutate(cls, mutation_object, root, info, **input): 284 | serializer = cls.get_serializer(mutation_object, info, **input) 285 | if not serializer.is_valid(): 286 | raise ValidationError(serialize_errors(serializer.errors)) 287 | return cls.save(serializer, root, info, **input) 288 | 289 | @classmethod 290 | def save(cls, serializer, root, info, **input): 291 | raise NotImplementedError() 292 | 293 | 294 | class SingleModelSerializerMutation(ModelSerializerMutation, BaseSingleModelMutation): 295 | class Meta: 296 | abstract = True 297 | 298 | @classmethod 299 | def save(cls, serializer, root, info, **input): 300 | saved_object = serializer.save() 301 | return cls.return_success(saved_object) 302 | 303 | 304 | class BulkModelSerializerMutation(ModelSerializerMutation, BaseBulkModelMutation): 305 | class Meta: 306 | abstract = True 307 | 308 | @classmethod 309 | def save(cls, serializer, root, info, **input): 310 | raise NotImplementedError 311 | 312 | @classmethod 313 | def get_serializer_kwargs(cls, serializer_object, serializer_input): 314 | kwargs = super(BulkModelSerializerMutation, cls).get_serializer_kwargs(serializer_object, serializer_input) 315 | kwargs['many'] = True 316 | return kwargs 317 | 318 | 319 | #################### 320 | # CREATE MUTATIONS # 321 | class CreateModelMutation(SingleModelSerializerMutation): 322 | class Meta: 323 | abstract = True 324 | 325 | @classmethod 326 | def get_mutation_object(cls, root, info, **input): 327 | return None 328 | 329 | @classmethod 330 | def get_arguments(cls, arguments): 331 | arguments = super(CreateModelMutation, cls).get_arguments(arguments) 332 | if arguments and cls._meta.lookup_field in arguments: 333 | del arguments[cls._meta.lookup_field] 334 | return arguments 335 | 336 | 337 | class CreateBulkModelMutation(BulkModelSerializerMutation): 338 | class Meta: 339 | abstract = True 340 | 341 | @classmethod 342 | def get_mutation_object(cls, root, info, **input): 343 | return None 344 | 345 | @classmethod 346 | def get_arguments(cls, arguments): 347 | arguments = super(CreateBulkModelMutation, cls).get_arguments(arguments) 348 | lookup_field = cls.get_input_lookup_field() 349 | if arguments and lookup_field: 350 | del arguments[lookup_field] 351 | 352 | arguments[cls._meta.input_field_name] = graphene.List(arguments[cls._meta.input_field_name].type.of_type) 353 | return arguments 354 | 355 | @classmethod 356 | def save(cls, serializer, root, info, **input): 357 | saved = serializer.save() 358 | return cls.return_success(len(saved)) 359 | 360 | 361 | #################### 362 | # UPDATE MUTATIONS # 363 | class UpdateModelMutation(SingleModelSerializerMutation): 364 | class Meta: 365 | abstract = True 366 | 367 | @classmethod 368 | def is_input_required(cls): 369 | return False 370 | 371 | 372 | class UpdateBulkModelMutation(BaseBulkModelMutation): 373 | class Meta: 374 | abstract = True 375 | 376 | @classmethod 377 | def save(cls, queryset, root, info, **input): 378 | input.pop(cls.get_input_lookup_field()) 379 | saved = queryset.update(**input) 380 | return cls.return_success(saved) 381 | 382 | 383 | #################### 384 | # DELETE MUTATIONS # 385 | class DeleteModelMutation(BaseSingleModelMutation): 386 | class Meta: 387 | abstract = True 388 | 389 | @classmethod 390 | def save(cls, instance, root, info, **input): 391 | saved_id = getattr(instance, cls._meta.model._meta.pk.name) 392 | instance.delete() 393 | setattr(instance, cls._meta.model._meta.pk.name, saved_id) 394 | return cls.return_success(instance) 395 | 396 | 397 | class DeleteBulkModelMutation(BaseBulkModelMutation): 398 | class Meta: 399 | abstract = True 400 | 401 | @classmethod 402 | def save(cls, queryset, root, info, **input): 403 | operation = queryset.delete() 404 | return cls.return_success(operation[0]) 405 | -------------------------------------------------------------------------------- /django_model_mutations/utils.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ImproperlyConfigured 2 | from graphene import Field, InputObjectType 3 | from graphene_django.registry import get_global_registry 4 | from graphene_django.types import ErrorType 5 | from graphene_django.utils import camelize 6 | from graphene_django.rest_framework.serializer_converter import convert_serializer_field 7 | 8 | 9 | # HELPER FUNCTIONS 10 | def convert_serializer_to_input_type(serializer_class, is_input=True): 11 | input_type_name = '{}{}'.format('Create' if is_input else 'Update', serializer_class.__name__) 12 | cached_type = convert_serializer_to_input_type.cache.get(input_type_name, None) 13 | if cached_type: 14 | return cached_type 15 | serializer = serializer_class() 16 | 17 | items = { 18 | name: convert_serializer_field(field, is_input=is_input) 19 | for name, field in serializer.fields.items() 20 | } 21 | ret_type = type( 22 | "{}Input".format(input_type_name), 23 | (InputObjectType,), 24 | items, 25 | ) 26 | convert_serializer_to_input_type.cache[input_type_name] = ret_type 27 | return ret_type 28 | 29 | 30 | convert_serializer_to_input_type.cache = {} 31 | 32 | 33 | def get_model_name(model): 34 | """Return name of the model with first letter lowercase.""" 35 | model_name = model.__name__ 36 | return model_name[:1].lower() + model_name[1:] 37 | 38 | 39 | def get_errors(errors): 40 | error_list = list() 41 | errors = camelize(errors) 42 | for key, value in errors.items(): 43 | error_list.append(ErrorType(field=key, messages=value[0])) 44 | return error_list 45 | 46 | 47 | def get_output_fields(model, return_field_name): 48 | """Return mutation output field for model instance.""" 49 | model_type = get_global_registry().get_type_for_model(model) 50 | if not model_type: 51 | raise ImproperlyConfigured( 52 | "Unable to find type for model %s in graphene registry" % model.__name__ 53 | ) 54 | fields = {return_field_name: Field(model_type)} 55 | return fields 56 | 57 | 58 | def serialize_errors(errors): 59 | if isinstance(errors, list): 60 | err_dict = dict() 61 | for error in errors: 62 | for key, value in error.items(): 63 | if key not in err_dict: 64 | err_dict[key] = value 65 | else: 66 | err_dict[key].append(value) 67 | return err_dict 68 | else: 69 | return errors 70 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | license_file = LICENSE.txt 4 | 5 | [tool:pytest] 6 | testspath = tests 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | with open('README.md') as f: 4 | long_description = f.read() 5 | 6 | setup( 7 | name = 'django-model-mutations', 8 | packages = ['django_model_mutations'], 9 | version = '0.1.1', 10 | license='MIT', 11 | description = 'Graphene Django mutations for Django models made easier', 12 | author = 'Tomáš Opletal', 13 | author_email = 't.opletal@gmail.com', 14 | url = 'https://github.com/topletal/django-model-mutations', 15 | keywords = ['GRAPHENE', 'GRAPHENE-DJANGO', 'GRAPHQL', 'DJANGO', 'MODELS', 'API'], 16 | install_requires = [ 17 | 'graphene', 18 | 'graphene-django', 19 | 'django' 20 | ], 21 | long_description=long_description, 22 | long_description_content_type='text/markdown', 23 | classifiers=[ 24 | 'Development Status :: 3 - Alpha', 25 | 'Intended Audience :: Developers', 26 | 'Topic :: Software Development :: Build Tools', 27 | 'License :: OSI Approved :: MIT License', 28 | 'Environment :: Web Environment', 29 | 'Framework :: Django', 30 | 'Programming Language :: Python :: 3', 31 | 'Programming Language :: Python :: 3.6', 32 | 'Programming Language :: Python :: 3.7', 33 | 'Programming Language :: Python :: 3.8', 34 | ], 35 | ) 36 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topletal/django-model-mutations/ea55bfc495fa6058669f1f5c66e16833d185a4ca/tests/__init__.py -------------------------------------------------------------------------------- /tests/client.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.contrib.auth.models import User, Permission 3 | from django.shortcuts import get_object_or_404 4 | from django.test import Client 5 | from django.urls import reverse 6 | from pytest_django.fixtures import db 7 | 8 | 9 | class ApiClient(Client): 10 | url = reverse("graphql") 11 | 12 | def query(self, query): 13 | response = self.post(path=self.url, data={"query": query}, content_type='application/json') 14 | return response 15 | 16 | 17 | class UserApiClient(ApiClient): 18 | @pytest.mark.django_db 19 | def query_with_permissions(self, query): 20 | user = User.objects.create_user(username='user', password='pw') 21 | perm = Permission.objects.get(codename='change_active') 22 | user.user_permissions.add(perm) 23 | self.force_login(user) 24 | return self.query(query) 25 | 26 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import django 3 | import pytest 4 | 5 | 6 | def pytest_configure(): 7 | from django.conf import settings 8 | 9 | settings.configure( 10 | ALLOWED_HOSTS=["*"], 11 | DEBUG_PROPAGATE_EXCEPTIONS=True, 12 | DATABASES={ 13 | "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"} 14 | }, 15 | SECRET_KEY="secret key", 16 | USE_I18N=True, 17 | USE_L10N=True, 18 | STATIC_URL="/static/", 19 | ROOT_URLCONF="tests.urls", 20 | TEMPLATES=[ 21 | { 22 | "BACKEND": "django.template.backends.django.DjangoTemplates", 23 | "APP_DIRS": True, 24 | "OPTIONS": {"debug": True}, # We want template errors to raise 25 | } 26 | ], 27 | MIDDLEWARE=( 28 | "django.middleware.common.CommonMiddleware", 29 | "django.contrib.sessions.middleware.SessionMiddleware", 30 | "django.contrib.auth.middleware.AuthenticationMiddleware", 31 | "django.contrib.messages.middleware.MessageMiddleware", 32 | "django.middleware.csrf.CsrfViewMiddleware", 33 | ), 34 | INSTALLED_APPS=( 35 | "django.contrib.admin", 36 | "django.contrib.auth", 37 | "django.contrib.contenttypes", 38 | "django.contrib.sessions", 39 | "django.contrib.sites", 40 | "django.contrib.staticfiles", 41 | "graphene_django", 42 | "tests", 43 | ), 44 | PASSWORD_HASHERS=("django.contrib.auth.hashers.MD5PasswordHasher",), 45 | GRAPHENE={"SCHEMA": "tests.schema.schema"}, 46 | AUTHENTICATION_BACKENDS=( 47 | "django.contrib.auth.backends.ModelBackend", 48 | ), 49 | ) 50 | 51 | django.setup() 52 | 53 | 54 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.db import models 4 | 5 | 6 | class Author(models.Model): 7 | public_id = models.CharField(max_length=100, blank=False, unique=True, default=uuid.uuid4) 8 | name = models.CharField(max_length=150, blank=False) 9 | is_active = models.BooleanField(default=True) 10 | 11 | class Meta: 12 | permissions = [("change_active", "Can change active author")] 13 | 14 | -------------------------------------------------------------------------------- /tests/schema.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | from graphene_django import DjangoObjectType 3 | 4 | from django_model_mutations import mutations, mixins 5 | from tests.models import Author 6 | from tests.serializers import AuthorSerializer 7 | 8 | 9 | class AuthorType(DjangoObjectType): 10 | class Meta: 11 | model = Author 12 | 13 | 14 | class AuthorCreateMutation(mutations.CreateModelMutation): 15 | class Meta: 16 | serializer_class = AuthorSerializer 17 | 18 | 19 | class AuthorBulkCreateMutation(mutations.CreateBulkModelMutation): 20 | class Meta: 21 | serializer_class = AuthorSerializer 22 | 23 | 24 | class AuthorDeleteMutation(mutations.DeleteModelMutation): 25 | class Meta: 26 | model = Author 27 | 28 | 29 | class AuthorBulkDeleteMutation(mutations.DeleteBulkModelMutation): 30 | class Meta: 31 | model = Author 32 | 33 | 34 | class AuthorUpdateMutation(mutations.UpdateModelMutation): 35 | class Meta: 36 | serializer_class = AuthorSerializer 37 | 38 | 39 | class AuthorBulkUpdateMutation(mutations.UpdateBulkModelMutation): 40 | class Arguments: 41 | is_active = graphene.Boolean() 42 | 43 | class Meta: 44 | model = Author 45 | 46 | 47 | class AuthorPermissionUpdateMutation(mutations.UpdateModelMutation): 48 | class Meta: 49 | serializer_class = AuthorSerializer 50 | permissions = ('tests.change_active',) 51 | 52 | 53 | class AuthorLookupUpdateMutation(mutations.UpdateModelMutation): 54 | class Meta: 55 | serializer_class = AuthorSerializer 56 | lookup_field = 'public_id' 57 | 58 | 59 | class AuthorLookupBulkMutation(mutations.DeleteBulkModelMutation): 60 | class Meta: 61 | lookup_field = 'public_id' 62 | model = Author 63 | 64 | 65 | class AuthorLoginRequiredMutation(mixins.LoginRequiredMutationMixin, mutations.UpdateModelMutation): 66 | class Meta: 67 | serializer_class = AuthorSerializer 68 | 69 | 70 | class AuthorCustomFieldCreateMutation(mutations.CreateModelMutation): 71 | class Meta: 72 | serializer_class = AuthorSerializer 73 | return_field_name = 'customAuthor' 74 | input_field_name = 'newAuthor' 75 | 76 | 77 | class Mutation(graphene.ObjectType): 78 | author_create = AuthorCreateMutation.Field() 79 | author_bulk_create = AuthorBulkCreateMutation.Field() 80 | author_delete = AuthorDeleteMutation.Field() 81 | author_bulk_delete = AuthorBulkDeleteMutation.Field() 82 | author_update = AuthorUpdateMutation.Field() 83 | author_bulk_update = AuthorBulkUpdateMutation.Field() 84 | author_permission_update = AuthorPermissionUpdateMutation.Field() 85 | author_lookup_update = AuthorLookupUpdateMutation.Field() 86 | author_lookup_bulk_delete = AuthorLookupBulkMutation.Field() 87 | author_login_required_update = AuthorLoginRequiredMutation.Field() 88 | author_custom_field_create = AuthorCustomFieldCreateMutation.Field() 89 | 90 | 91 | schema = graphene.Schema(mutation=Mutation) 92 | -------------------------------------------------------------------------------- /tests/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from .models import Author 4 | 5 | 6 | class AuthorSerializer(serializers.ModelSerializer): 7 | class Meta: 8 | model = Author 9 | fields = ('name', 'is_active') 10 | 11 | -------------------------------------------------------------------------------- /tests/test_mutations.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.contrib.auth.models import Permission 3 | 4 | from .client import ApiClient, UserApiClient 5 | 6 | from .models import Author 7 | 8 | 9 | @pytest.fixture 10 | def create_authors(db): 11 | return Author.objects.bulk_create( 12 | [ 13 | Author(name='Mark Steven', public_id='id1'), 14 | Author(name='John Sunny', public_id='id2'), 15 | Author(name='Peter Jacobs', public_id='id3') 16 | ] 17 | ) 18 | 19 | 20 | @pytest.mark.django_db 21 | def test_simple_create_mutation(): 22 | query = '''mutation { 23 | authorCreate (input: {name:"John Doe"}) { 24 | author { 25 | id 26 | name 27 | } 28 | errors { 29 | field 30 | messages 31 | } 32 | } 33 | } 34 | ''' 35 | client = ApiClient() 36 | response = client.query(query) 37 | data = response.json() 38 | assert data['data']['authorCreate']['author']['name'] == 'John Doe' 39 | assert data['data']['authorCreate']['author']['id'] == '1' 40 | assert data['data']['authorCreate']['errors'] == [] 41 | 42 | 43 | @pytest.mark.django_db 44 | def test_simple_bulk_create_mutation(): 45 | query = '''mutation { 46 | authorBulkCreate (input: [{name:"John Doe"}, {name:"Mark Steven"}]) { 47 | count 48 | errors { 49 | field 50 | messages 51 | } 52 | } 53 | } 54 | ''' 55 | client = ApiClient() 56 | response = client.query(query) 57 | data = response.json() 58 | assert data['data']['authorBulkCreate']['count'] == 2 59 | assert data['data']['authorBulkCreate']['errors'] == [] 60 | 61 | 62 | @pytest.mark.django_db 63 | def test_simple_bulk_delete_mutation(create_authors): 64 | query = '''mutation { 65 | authorBulkDelete (ids: [2, 3]) { 66 | count 67 | errors { 68 | field 69 | messages 70 | } 71 | } 72 | } 73 | ''' 74 | client = ApiClient() 75 | response = client.query(query) 76 | data = response.json() 77 | assert data['data']['authorBulkDelete']['count'] == 2 78 | assert data['data']['authorBulkDelete']['errors'] == [] 79 | 80 | 81 | @pytest.mark.django_db 82 | def test_simple_delete_mutation(create_authors): 83 | query = '''mutation { 84 | authorDelete (id: 2) { 85 | author { 86 | id 87 | name 88 | } 89 | errors { 90 | field 91 | messages 92 | } 93 | } 94 | } 95 | ''' 96 | client = ApiClient() 97 | response = client.query(query) 98 | data = response.json() 99 | assert data['data']['authorDelete']['author']['id'] == '2' 100 | assert data['data']['authorDelete']['author']['name'] == create_authors[1].name 101 | assert data['data']['authorDelete']['errors'] == [] 102 | 103 | 104 | @pytest.mark.django_db 105 | def test_simple_update_mutation(create_authors): 106 | query = '''mutation { 107 | authorUpdate (id: 2, input: {name:"Bart Stevens"} ) { 108 | author { 109 | id 110 | name 111 | } 112 | errors { 113 | field 114 | messages 115 | } 116 | } 117 | } 118 | ''' 119 | client = ApiClient() 120 | response = client.query(query) 121 | data = response.json() 122 | assert data['data']['authorUpdate']['author']['id'] == '2' 123 | assert data['data']['authorUpdate']['author']['name'] == 'Bart Stevens' 124 | assert data['data']['authorUpdate']['errors'] == [] 125 | 126 | 127 | @pytest.mark.django_db 128 | def test_simple_bulk_update_mutation(create_authors): 129 | query = '''mutation { 130 | authorBulkUpdate (ids: [2, 3], isActive: false ) { 131 | count 132 | errors { 133 | field 134 | messages 135 | } 136 | } 137 | } 138 | ''' 139 | 140 | client = ApiClient() 141 | response = client.query(query) 142 | data = response.json() 143 | assert data['data']['authorBulkUpdate']['count'] == 2 144 | assert data['data']['authorBulkUpdate']['errors'] == [] 145 | 146 | 147 | @pytest.mark.django_db 148 | def test_update_no_permissions(create_authors): 149 | query = '''mutation { 150 | authorPermissionUpdate (id: 2, input: {isActive: false} ) { 151 | author { 152 | id 153 | name 154 | } 155 | errors { 156 | field 157 | messages 158 | } 159 | } 160 | } 161 | ''' 162 | 163 | client = UserApiClient() 164 | response = client.query(query) 165 | data = response.json() 166 | assert data['data']['authorPermissionUpdate'] is None 167 | assert data['errors'][0]['message'] == 'Permission denied' 168 | 169 | 170 | def test_update_permissions(create_authors): 171 | query = '''mutation { 172 | authorPermissionUpdate (id: 2, input: {isActive: false} ) { 173 | author { 174 | id 175 | name 176 | isActive 177 | } 178 | errors { 179 | field 180 | messages 181 | } 182 | } 183 | } 184 | ''' 185 | 186 | client = UserApiClient() 187 | response = client.query_with_permissions(query) 188 | data = response.json() 189 | assert data['data']['authorPermissionUpdate']['author']['isActive'] == False 190 | assert data['data']['authorPermissionUpdate']['errors'] == [] 191 | 192 | 193 | 194 | @pytest.mark.django_db 195 | def test_error_create_mutation(): 196 | query = '''mutation { 197 | authorCreate (input: {name: ""}) { 198 | author { 199 | id 200 | name 201 | } 202 | errors { 203 | field 204 | messages 205 | } 206 | } 207 | } 208 | ''' 209 | client = ApiClient() 210 | response = client.query(query) 211 | data = response.json() 212 | assert data['data']['authorCreate']['author'] == None 213 | assert data['data']['authorCreate']['errors'][0]['field'] == 'name' 214 | 215 | 216 | 217 | @pytest.mark.django_db 218 | def test_simple_update_mutation(create_authors): 219 | query = '''mutation { 220 | authorUpdate (id: 100, input: {name:"Bart Stevens"} ) { 221 | author { 222 | id 223 | name 224 | } 225 | errors { 226 | field 227 | messages 228 | } 229 | } 230 | } 231 | ''' 232 | client = ApiClient() 233 | response = client.query(query) 234 | data = response.json() 235 | assert data['data']['authorUpdate']['author'] == None 236 | assert data['data']['authorUpdate']['errors'][0]['field'] == 'id' 237 | 238 | 239 | @pytest.mark.django_db 240 | def test_lookup_field_mutation(create_authors): 241 | query = '''mutation { 242 | authorLookupUpdate (publicId: "id2", input: {name:"Bart Stevens"} ) { 243 | author { 244 | publicId 245 | id 246 | name 247 | } 248 | errors { 249 | field 250 | messages 251 | } 252 | } 253 | } 254 | ''' 255 | 256 | client = ApiClient() 257 | response = client.query(query) 258 | data = response.json() 259 | assert data['data']['authorLookupUpdate']['author']['publicId'] == 'id2' 260 | assert data['data']['authorLookupUpdate']['author']['id'] == '2' 261 | assert data['data']['authorLookupUpdate']['author']['name'] == 'Bart Stevens' 262 | assert data['data']['authorLookupUpdate']['errors'] == [] 263 | 264 | 265 | @pytest.mark.django_db 266 | def test_lookup_field_bulk_mutation(create_authors): 267 | query = '''mutation { 268 | authorLookupBulkDelete (publicIds: ["id2", "id3"]) { 269 | count 270 | errors { 271 | field 272 | messages 273 | } 274 | } 275 | } 276 | ''' 277 | 278 | client = ApiClient() 279 | response = client.query(query) 280 | data = response.json() 281 | assert data['data']['authorLookupBulkDelete']['count'] == 2 282 | assert data['data']['authorLookupBulkDelete']['errors'] == [] 283 | 284 | 285 | @pytest.mark.django_db 286 | def test_no_login_update_mutation(create_authors): 287 | query = '''mutation { 288 | authorLoginRequiredUpdate (id: 2, input: {name:"Bart Stevens"} ) { 289 | author { 290 | id 291 | name 292 | } 293 | errors { 294 | field 295 | messages 296 | } 297 | } 298 | } 299 | ''' 300 | client = ApiClient() 301 | response = client.query(query) 302 | data = response.json() 303 | assert data['data']['authorLoginRequiredUpdate'] is None 304 | assert data['errors'][0]['message'] == 'Login required' 305 | 306 | 307 | @pytest.mark.django_db 308 | def test_login_update_mutation(create_authors): 309 | query = '''mutation { 310 | authorLoginRequiredUpdate (id: 2, input: {name:"Bart Stevens"} ) { 311 | author { 312 | id 313 | name 314 | } 315 | errors { 316 | field 317 | messages 318 | } 319 | } 320 | } 321 | ''' 322 | client = UserApiClient() 323 | response = client.query_with_permissions(query) 324 | data = response.json() 325 | assert data['data']['authorLoginRequiredUpdate']['author']['id'] == '2' 326 | assert data['data']['authorLoginRequiredUpdate']['author']['name'] == 'Bart Stevens' 327 | assert data['data']['authorLoginRequiredUpdate']['errors'] == [] 328 | 329 | 330 | @pytest.mark.django_db 331 | def test_custom_fields_create_mutation(): 332 | query = '''mutation { 333 | authorCustomFieldCreate (newAuthor: {name:"John Doe"}) { 334 | customAuthor { 335 | id 336 | name 337 | } 338 | errors { 339 | field 340 | messages 341 | } 342 | } 343 | } 344 | ''' 345 | client = ApiClient() 346 | response = client.query(query) 347 | data = response.json() 348 | assert data['data']['authorCustomFieldCreate']['customAuthor']['name'] == 'John Doe' 349 | assert data['data']['authorCustomFieldCreate']['errors'] == [] 350 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from django.views.decorators.csrf import csrf_exempt 3 | from graphene_django.views import GraphQLView 4 | 5 | urlpatterns = [ 6 | path("graphql", csrf_exempt(GraphQLView.as_view(graphiql=True)), name="graphql"), 7 | ] --------------------------------------------------------------------------------