├── .coveragerc ├── .flake8 ├── .github └── workflows │ └── cicd.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yml ├── .vim └── coc-settings.json ├── LICENSE ├── README.md ├── docs ├── Makefile ├── conf.py ├── index.rst └── make.bat ├── graphene_django_plus ├── __init__.py ├── exceptions.py ├── fields.py ├── input_types.py ├── models.py ├── mutations.py ├── perms.py ├── queries.py ├── schema.py ├── settings.py ├── types.py ├── utils.py └── views.py ├── manage.py ├── poetry.lock ├── pyproject.toml ├── pytest.ini └── tests ├── __init__.py ├── base.py ├── conftest.py ├── models.py ├── schema.py ├── settings.py ├── test_fields.py ├── test_input_types.py ├── test_models.py ├── test_mutations.py ├── test_queries.py ├── test_settings.py ├── test_types.py ├── test_utils.py └── urls.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = graphene_django_plus 3 | omit = 4 | *urls.py, 5 | .venv/** 6 | 7 | [report] 8 | precision = 2 9 | exclude_lines = 10 | pragma: nocover 11 | pragma:nocover 12 | if TYPE_CHECKING: 13 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | extend-ignore = E731,SIM106,R504 3 | max-line-length = 100 4 | exclude = .eggs,.git,.hg,.mypy_cache,.tox,.venv,venv,__pycached__,_build,buck-out,build,dist 5 | -------------------------------------------------------------------------------- /.github/workflows/cicd.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Run Tests 3 | 4 | on: 5 | push: 6 | branches: 7 | - master 8 | pull_request: 9 | branches: 10 | - master 11 | release: 12 | types: 13 | - released 14 | 15 | jobs: 16 | lint: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v2 20 | - uses: actions/setup-python@v2 21 | - uses: pre-commit/action@v2.0.3 22 | with: 23 | extra_args: -a 24 | tests: 25 | runs-on: ubuntu-latest 26 | strategy: 27 | matrix: 28 | django-version: 29 | - 3.2.* 30 | - 4.0.* 31 | - 4.1.* 32 | - 4.2.* 33 | python-version: 34 | - "3.8" 35 | - "3.9" 36 | - "3.10" 37 | - "3.11" 38 | steps: 39 | - name: Checkout 40 | uses: actions/checkout@v2 41 | - name: Set up Python ${{ matrix.python-version }} 42 | uses: actions/setup-python@v2 43 | with: 44 | python-version: ${{ matrix.python-version }} 45 | - name: Install dependencies 46 | run: | 47 | python -m pip install --upgrade pip wheel setuptools 48 | pip install poetry 49 | poetry install 50 | poetry run pip install "Django==${{ matrix.django-version }}" 51 | - name: Run tests 52 | run: | 53 | poetry run pytest --cov-report=xml 54 | - name: Upload coverage to Codecov 55 | uses: codecov/codecov-action@v3 56 | publish: 57 | runs-on: ubuntu-latest 58 | needs: 59 | - lint 60 | - tests 61 | if: ${{ needs.lint.result == 'success' && needs.tests.result == 'success' && github.event.action == 'released' }} 62 | steps: 63 | - uses: actions/checkout@v2 64 | - name: Build and publish to pypi 65 | uses: JRubics/poetry-publish@v1.6 66 | with: 67 | pypi_token: ${{ secrets.PYPI_TOKEN }} 68 | -------------------------------------------------------------------------------- /.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 | # ide files 29 | .idea/* 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | media 62 | celerybeat-schedule.db 63 | celerybeat.pid 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # pyenv 82 | .python-version 83 | 84 | # celery beat schedule file 85 | celerybeat-schedule 86 | 87 | # SageMath parsed files 88 | *.sage.py 89 | 90 | # Environments 91 | .env 92 | .venv 93 | env/ 94 | venv/ 95 | ENV/ 96 | env.bak/ 97 | venv.bak/ 98 | 99 | # Spyder project settings 100 | .spyderproject 101 | .spyproject 102 | 103 | # Rope project settings 104 | .ropeproject 105 | 106 | # mkdocs documentation 107 | /site 108 | 109 | # mypy 110 | .mypy_cache/ 111 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v4.2.0 5 | hooks: 6 | - id: trailing-whitespace 7 | - id: end-of-file-fixer 8 | - id: check-docstring-first 9 | - id: check-merge-conflict 10 | - id: check-yaml 11 | - id: check-toml 12 | - id: check-json 13 | - id: check-xml 14 | - id: check-added-large-files 15 | args: ["--maxkb=1024"] 16 | 17 | - repo: https://github.com/codespell-project/codespell 18 | rev: v2.1.0 19 | hooks: 20 | - id: codespell 21 | 22 | - repo: https://github.com/pre-commit/mirrors-prettier 23 | rev: v2.6.2 24 | hooks: 25 | - id: prettier 26 | 27 | - repo: https://github.com/asottile/pyupgrade 28 | rev: v2.32.0 29 | hooks: 30 | - id: pyupgrade 31 | args: ["--py38-plus"] 32 | 33 | - repo: https://github.com/myint/docformatter 34 | rev: v1.4 35 | hooks: 36 | - id: docformatter 37 | args: ["-i", "--wrap-summaries", "0", "--blank"] 38 | 39 | - repo: https://github.com/myint/autoflake 40 | rev: v1.4 41 | hooks: 42 | - id: autoflake 43 | args: ["--in-place", "--remove-all-unused-imports"] 44 | 45 | - repo: https://github.com/pycqa/isort 46 | rev: 5.12.0 47 | hooks: 48 | - id: isort 49 | name: isort (python) 50 | - id: isort 51 | name: isort (cython) 52 | types: [cython] 53 | - id: isort 54 | name: isort (pyi) 55 | types: [pyi] 56 | 57 | - repo: https://github.com/psf/black 58 | rev: 22.3.0 59 | hooks: 60 | - id: black 61 | args: ["--config", "pyproject.toml"] 62 | 63 | - repo: https://github.com/PyCQA/flake8 64 | rev: 4.0.1 65 | hooks: 66 | - id: flake8 67 | additional_dependencies: 68 | [ 69 | "flake8-broken-line==0.4.0", 70 | "flake8-bugbear==22.4.25", 71 | "flake8-builtins==1.5.3", 72 | "flake8-comprehensions==3.8.0", 73 | "flake8-polyfill==1.0.2", 74 | "flake8-return==1.1.3", 75 | "flake8-simplify==0.19.2", 76 | ] 77 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Build documentation in the docs/ directory with Sphinx 9 | sphinx: 10 | configuration: docs/conf.py 11 | 12 | # Build documentation with MkDocs 13 | #mkdocs: 14 | # configuration: mkdocs.yml 15 | 16 | # Optionally build your docs in additional formats such as PDF and ePub 17 | formats: all 18 | 19 | # Optionally set the version of Python and requirements required to build your docs 20 | python: 21 | version: 3.9 22 | install: 23 | - method: pip 24 | path: . 25 | -------------------------------------------------------------------------------- /.vim/coc-settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "pyright.organizeimports.provider": "isort", 3 | "python.formatting.provider": "black", 4 | "python.linting.enabled": true, 5 | "python.linting.flake8Enabled": true, 6 | "python.linting.pylintEnabled": false, 7 | "python.pythonPath": ".venv/python3" 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Tomás Fox 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # graphene-django-plus 2 | 3 | [![build status](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2F0soft%2Fgraphene-django-plus%2Fbadge%3Fref%3Dmaster&style=flat)](https://actions-badge.atrox.dev/0soft/graphene-django-plus/goto?ref=master) 4 | [![docs status](https://img.shields.io/readthedocs/graphene-django-plus.svg)](https://graphene-django-plus.readthedocs.io) 5 | [![coverage](https://img.shields.io/codecov/c/github/0soft/graphene-django-plus.svg)](https://codecov.io/gh/0soft/graphene-django-plus) 6 | [![PyPI version](https://img.shields.io/pypi/v/graphene-django-plus.svg)](https://pypi.org/project/graphene-django-plus/) 7 | ![python version](https://img.shields.io/pypi/pyversions/graphene-django-plus.svg) 8 | ![django version](https://img.shields.io/pypi/djversions/graphene-django-plus.svg) 9 | 10 | > ## DEPRECATION WARNING 11 | > 12 | > Graphene itself is abandoned and most users are migrating to other better alternatives, like 13 | > [strawberry](https://github.com/strawberry-graphql/strawberry). 14 | > 15 | > For that reason this lib is being deprecated and new features will no longer be developed for it. 16 | > Maintenance is still going to happen and PRs are still welcomed though. 17 | > 18 | > For anyone looking for alternatives, I created 19 | > [strawberry-django-plus](https://github.com/blb-ventures/strawberry-django-plus) to use not 20 | > only as a migration path to the projects I maintain, but also to add even more awesome features. 21 | > Be sure to check it out! 22 | 23 | Tools to easily create permissioned CRUD endpoints in [graphene-django](https://github.com/graphql-python/graphene-django). 24 | 25 | ## Install 26 | 27 | ```bash 28 | pip install graphene-django-plus 29 | ``` 30 | 31 | To make use of everything this lib has to offer, it is recommended to install 32 | both [graphene-django-optimizer](https://github.com/tfoxy/graphene-django-optimizer) 33 | and [django-guardian](https://github.com/django-guardian/django-guardian). 34 | 35 | ```bash 36 | pip install graphene-django-optimizer django-guardian 37 | ``` 38 | 39 | ## What it does 40 | 41 | - Provides some base types for Django Models to improve querying them with: 42 | - Unauthenticated user handling 43 | - Automatic optimization using [graphene-django-optimizer](https://github.com/tfoxy/graphene-django-optimizer) 44 | - Permission handling for queries using the default [django permission system](https://docs.djangoproject.com/en/2.2/topics/auth/default/#topic-authorization) 45 | - Object permission handling for queries using [django guardian](https://github.com/django-guardian/django-guardian) 46 | - Relay id conversion so querying can use the global id instead of the model's id 47 | - Provides a set of complete and simple CRUD mutations with: 48 | - Unauthenticated user handling 49 | - Permission handling using the default [django permission system](https://docs.djangoproject.com/en/2.2/topics/auth/default/#topic-authorization) 50 | - Object permission handling using [django guardian](https://github.com/django-guardian/django-guardian) 51 | - Automatic input generation based on the model (no need to write your own input type or use `django forms` and `drf serializers`) 52 | - Automatic model validation based on the model's validators 53 | - Very simple to create some quick CRUD endpoints for your models 54 | - Easy to extend and override functionalities 55 | - File upload handling 56 | 57 | ## What is included 58 | 59 | Check the [docs](https://graphene-django-plus.readthedocs.io) for a complete 60 | api documentation. 61 | 62 | ### Models 63 | 64 | - `graphene_django_plus.models.GuardedModel`: A django model that can be used 65 | either directly or as a mixin. It will provide a `.has_perm` method and a 66 | `.objects.for_user` that will be used by `ModelType` described below to 67 | check for object permissions. 68 | 69 | ### Types and Queries 70 | 71 | - `graphene_django_plus.types.ModelType`: This enchances 72 | `graphene_django_plus.DjangoModelType` by doing some automatic `prefetch` 73 | optimization on setup and also checking for objects permissions on queries 74 | when it inherits from `GuardedModel`. 75 | 76 | - `graphene_django_plus.fields.CountableConnection`: This enchances 77 | `graphene.relay.Connection` to provide a `total_count` attribute. 78 | 79 | Here is an example describing how to use those: 80 | 81 | ```py 82 | import graphene 83 | from graphene import relay 84 | from graphene_django.fields import DjangoConnectionField 85 | 86 | from graphene_django_plus.models import GuardedModel 87 | from graphene_django_plus.types import ModelType 88 | from graphene_django_plus.fields import CountableConnection 89 | 90 | 91 | class MyModel(GuardedModel): 92 | class Meta: 93 | # guardian permissions for this model 94 | permissions = [ 95 | ('can_read', "Can read the this object's info."), 96 | ] 97 | 98 | name = models.CharField(max_length=255) 99 | 100 | 101 | class MyModelType(ModelType): 102 | class Meta: 103 | model = MyModel 104 | interfaces = [relay.Node] 105 | 106 | # Use our CountableConnection 107 | connection_class = CountableConnection 108 | 109 | # When adding this to a query, only objects with a `can_read` 110 | # permission to the request's user will be allowed to return to him 111 | # Note that `can_read` was defined in the model. 112 | # If the model doesn't inherid from `GuardedModel`, `guardian` is not 113 | # installed or this list is empty, any object will be allowed. 114 | # This is empty by default 115 | object_permissions = [ 116 | 'can_read', 117 | ] 118 | 119 | # If unauthenticated users should be allowed to retrieve any object 120 | # of this type. This is not dependent on `GuardedModel` and neither 121 | # `guardian` and is defined as `False` by default 122 | public = False 123 | 124 | # A list of Django model permissions to check. Different from 125 | # object_permissions, this uses the basic Django's permission system 126 | # and thus is not dependent on `GuardedModel` and neither `guardian`. 127 | # This is an empty list by default. 128 | permissions = [] 129 | 130 | 131 | class Query(graphene.ObjectType): 132 | my_models = DjangoConnectionField(MyModelType) 133 | my_model = relay.Node.Field(MyModelType) 134 | ``` 135 | 136 | This can be queried like: 137 | 138 | ```graphql 139 | # All objects that the user has permission to see 140 | query { 141 | myModels { 142 | totalCount 143 | edges { 144 | node { 145 | id 146 | name 147 | } 148 | } 149 | } 150 | } 151 | 152 | # Single object if the user has permission to see it 153 | query { 154 | myModel(id: "") { 155 | id 156 | name 157 | } 158 | } 159 | ``` 160 | 161 | ### Mutations 162 | 163 | - `graphene_django_plus.mutations.BaseMutation`: Base mutation using `relay` 164 | and some basic permission checking. Just override its `.perform_mutation` to 165 | perform the mutation. 166 | 167 | - `graphene_django_plus.mutations.ModelMutation`: Model mutation capable of 168 | both creating and updating a model based on the existence of an `id` 169 | attribute in the input. All the model's fields will be automatically read 170 | from Django, inserted in the input type and validated. 171 | 172 | - `graphene_django_plus.mutations.ModelCreateMutation`: A `ModelMutation` 173 | enforcing a "create only" rule by excluding the `id` field from the input. 174 | 175 | - `graphene_django_plus.mutations.ModelUpdateMutation`: A `ModelMutation` 176 | enforcing a "update only" rule by making the `id` field required in the 177 | input. 178 | 179 | - `graphene_django_plus.mutations.ModelDeleteMutation`: A mutation that will 180 | receive only the model's id and will delete it (if given permission, of 181 | course). 182 | 183 | Here is an example describing how to use those: 184 | 185 | ```py 186 | import graphene 187 | from graphene import relay 188 | 189 | from graphene_django_plus.models import GuardedModel 190 | from graphene_django_plus.types import ModelType 191 | from graphene_django_plus.mutations import ( 192 | ModelCreateMutation, 193 | ModelUpdateMutation, 194 | ModelDeleteMutation, 195 | ) 196 | 197 | 198 | class MyModel(GuardedModel): 199 | class Meta: 200 | # guardian permissions for this model 201 | permissions = [ 202 | ('can_write', "Can update this object's info."), 203 | ] 204 | 205 | name = models.CharField(max_length=255) 206 | 207 | 208 | class MyModelType(ModelType): 209 | class Meta: 210 | model = MyModel 211 | interfaces = [relay.Node] 212 | 213 | 214 | class MyModelUpdateMutation(ModelUpdateMutation): 215 | class Meta: 216 | model = MyModel 217 | 218 | # Make sure only users with the given permissions can modify the 219 | # object. 220 | # If the model doesn't inherid from `GuardedModel`, `guardian` is not 221 | # installed on this list is empty, any object will be allowed. 222 | # This is empty by default. 223 | object_permissions = [ 224 | 'can_write', 225 | ] 226 | 227 | # If unauthenticated users should be allowed to retrieve any object 228 | # of this type. This is not dependent on `GuardedModel` and neither 229 | # `guardian` and is defined as `False` by default 230 | public = False 231 | 232 | # A list of Django model permissions to check. Different from 233 | # object_permissions, this uses the basic Django's permission system 234 | # and thus is not dependent on `GuardedModel` and neither `guardian`. 235 | # This is an empty list by default. 236 | permissions = [] 237 | 238 | 239 | class MyModelDeleteMutation(ModelDeleteMutation): 240 | class Meta: 241 | model = MyModel 242 | object_permissions = [ 243 | 'can_write', 244 | ] 245 | 246 | 247 | class MyModelCreateMutation(ModelCreateMutation): 248 | class Meta: 249 | model = MyModel 250 | 251 | @classmethod 252 | def after_save(cls, info, instance, cleaned_input=None): 253 | # If the user created the object, allow him to modify it 254 | assign_perm('can_write', info.context.user, instance) 255 | 256 | 257 | class Mutation(graphene.ObjectType): 258 | my_model_create = MyModelCreateMutation.Field() 259 | my_model_update = MyModelUpdateMutation.Field() 260 | my_model_delete = MyModelDeleteMutation.Field() 261 | ``` 262 | 263 | This can be used to create/update/delete like: 264 | 265 | ```graphql 266 | # Create mutation 267 | mutation { 268 | myModelCreate(input: { name: "foobar" }) { 269 | myModel { 270 | name 271 | } 272 | errors { 273 | field 274 | message 275 | } 276 | } 277 | } 278 | 279 | # Update mutation 280 | mutation { 281 | myModelUpdate(input: { id: "", name: "foobar" }) { 282 | myModel { 283 | name 284 | } 285 | errors { 286 | field 287 | message 288 | } 289 | } 290 | } 291 | 292 | # Delete mutation 293 | mutation { 294 | myModelDelete(input: { id: "" }) { 295 | myModel { 296 | name 297 | } 298 | errors { 299 | field 300 | message 301 | } 302 | } 303 | } 304 | ``` 305 | 306 | Any validation errors will be presented in the `errors` return value. 307 | 308 | To turn off auto related relations addition to the mutation input - set global 309 | `MUTATIONS_INCLUDE_REVERSE_RELATIONS` parameter to `False` in your 310 | `settings.py`: 311 | 312 | ``` 313 | GRAPHENE_DJANGO_PLUS = { 314 | 'MUTATIONS_INCLUDE_REVERSE_RELATIONS': False 315 | } 316 | ``` 317 | 318 | Note: in case reverse relation does not have `related_name` attribute set - 319 | mutation input will be generated as Django itself is generating by appending 320 | `_set` to the lower cased model name - `modelname_set` 321 | 322 | ## License 323 | 324 | This project is licensed under MIT licence (see `LICENSE` for more info) 325 | 326 | ## Contributing 327 | 328 | Make sure to have [poetry](https://python-poetry.org/) installed. 329 | 330 | Install dependencies with: 331 | 332 | ```bash 333 | poetry install 334 | ``` 335 | 336 | Run the testsuite with: 337 | 338 | ```bash 339 | poetry run pytest 340 | ``` 341 | 342 | Feel free to fork the project and send me pull requests with new features, 343 | corrections and translations. We'll gladly merge them and release new versions 344 | ASAP. 345 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # http://www.sphinx-doc.org/en/master/config 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | 16 | sys.path.insert(0, os.path.abspath("../")) 17 | sys.setrecursionlimit(1500) 18 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 19 | 20 | 21 | # -- Project information ----------------------------------------------------- 22 | 23 | project = "graphene-django-plus" 24 | copyright = "2019-2022, Zerosoft Tecnologia LTDA" # noqa: A001 25 | author = "Zerosoft Tecnologia LTDA" 26 | 27 | # The full version, including alpha/beta/rc tags 28 | release = "4.5" 29 | 30 | 31 | # -- General configuration --------------------------------------------------- 32 | 33 | # Add any Sphinx extension module names here, as strings. They can be 34 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 35 | # ones. 36 | extensions = [ 37 | "sphinx.ext.todo", 38 | "sphinx.ext.viewcode", 39 | "sphinx.ext.autodoc", 40 | ] 41 | 42 | # Add any paths that contain templates here, relative to this directory. 43 | templates_path = ["_templates"] 44 | 45 | # List of patterns, relative to source directory, that match files and 46 | # directories to ignore when looking for source files. 47 | # This pattern also affects html_static_path and html_extra_path. 48 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 49 | 50 | 51 | # -- Options for HTML output ------------------------------------------------- 52 | 53 | # The theme to use for HTML and HTML Help pages. See the documentation for 54 | # a list of builtin themes. 55 | # 56 | html_theme = "sphinx_rtd_theme" 57 | 58 | # Add any paths that contain custom static files (such as style sheets) here, 59 | # relative to this directory. They are copied after the builtin static files, 60 | # so a file named "default.css" will overwrite the builtin "default.css". 61 | html_static_path = ["_static"] 62 | 63 | master_doc = "index" 64 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. graphene-django-plus documentation master file, created by 2 | sphinx-quickstart on Tue Aug 6 10:42:28 2019. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to graphene-django-plus's documentation! 7 | ================================================ 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :caption: Contents: 12 | 13 | 14 | API 15 | === 16 | .. automodule:: graphene_django_plus 17 | :members: 18 | 19 | 20 | Types 21 | ===== 22 | .. automodule:: graphene_django_plus.types 23 | :members: 24 | 25 | 26 | Fields 27 | ====== 28 | .. automodule:: graphene_django_plus.fields 29 | :members: 30 | 31 | 32 | Mutations 33 | ========= 34 | .. automodule:: graphene_django_plus.mutations 35 | :members: 36 | 37 | 38 | Utils 39 | ===== 40 | .. automodule:: graphene_django_plus.utils 41 | :members: 42 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /graphene_django_plus/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0soft/graphene-django-plus/f39ae238b17df85e5caca747dc7f68bc2e3df79d/graphene_django_plus/__init__.py -------------------------------------------------------------------------------- /graphene_django_plus/exceptions.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import PermissionDenied as _PermissionDenied 2 | 3 | 4 | class PermissionDenied(_PermissionDenied): 5 | 6 | default_message = "You do not have permission to perform this action" 7 | 8 | def __init__(self, message=None): 9 | if message is None: 10 | message = self.default_message 11 | 12 | super().__init__(message) 13 | -------------------------------------------------------------------------------- /graphene_django_plus/fields.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | from graphene import relay 3 | from graphene.utils.str_converters import to_snake_case 4 | from graphene_django.filter import DjangoFilterConnectionField 5 | 6 | 7 | class CountableConnection(relay.Connection): 8 | """Connection that provides a total_count attribute.""" 9 | 10 | class Meta: 11 | abstract = True 12 | 13 | #: Total objects count in the query. 14 | total_count = graphene.Int( 15 | description="The total count of objects in this query.", 16 | ) 17 | 18 | @staticmethod 19 | def resolve_total_count(parent, info, **kwargs): 20 | if hasattr(parent, "length"): 21 | return parent.length 22 | 23 | return parent.iterable.count() # pragma:nocover 24 | 25 | 26 | class OrderableConnectionField(DjangoFilterConnectionField): 27 | """Filter connection with ordering functionality.""" 28 | 29 | def __init__(self, *args, **kwargs): 30 | return super().__init__( 31 | *args, 32 | **kwargs, 33 | orderby=graphene.List( 34 | graphene.String, 35 | required=False, 36 | description="Sort results by field.", 37 | ), 38 | ) 39 | 40 | @classmethod 41 | def resolve_queryset(cls, connection, iterable, info, args, filtering_args, filterset_class): 42 | qs = super().resolve_queryset( 43 | connection, 44 | iterable, 45 | info, 46 | args, 47 | filtering_args, 48 | filterset_class, 49 | ) 50 | 51 | order = args.pop("orderby", None) or [] 52 | if order: 53 | qs = qs.order_by(*[to_snake_case(o) for o in order]) 54 | 55 | return qs 56 | -------------------------------------------------------------------------------- /graphene_django_plus/input_types.py: -------------------------------------------------------------------------------- 1 | import functools 2 | from typing import Union 3 | 4 | from django.db import models 5 | from django.db.models import Field 6 | from django.db.models.fields.reverse_related import ForeignObjectRel 7 | import graphene 8 | from graphene import Scalar 9 | from graphene.types.structures import Structure 10 | from graphene_django.converter import convert_django_field_with_choices 11 | from graphene_django.registry import Registry 12 | 13 | from .types import UploadType 14 | 15 | 16 | @functools.singledispatch 17 | def get_input_field( 18 | field: Union[Field, ForeignObjectRel], registry: Registry 19 | ) -> Union[Scalar, Structure]: 20 | """Convert a model field into a GraphQL input type used in mutations. 21 | 22 | :param field: A model field. 23 | :param registry: Registry which holds a mapping between django models/fields 24 | and Graphene types. 25 | :return: A scalar that can be used as an input field in mutations. 26 | 27 | """ 28 | return convert_django_field_with_choices(field, registry) 29 | 30 | 31 | @get_input_field.register(models.FileField) 32 | def get_file_field(field, registry): 33 | return UploadType( 34 | description=field.help_text, 35 | ) 36 | 37 | 38 | @get_input_field.register(models.BooleanField) 39 | def get_boolean_field(field, registry): 40 | return graphene.Boolean( 41 | description=field.help_text, 42 | ) 43 | 44 | 45 | @get_input_field.register(models.ForeignKey) 46 | @get_input_field.register(models.OneToOneField) 47 | def get_foreign_key_field(field, registry): 48 | return graphene.ID( 49 | description=field.help_text, 50 | ) 51 | 52 | 53 | @get_input_field.register(models.ManyToManyField) 54 | def get_many_to_many_field(field, registry): 55 | return graphene.List( 56 | graphene.ID, 57 | description=field.help_text, 58 | ) 59 | 60 | 61 | @get_input_field.register(models.ManyToOneRel) 62 | @get_input_field.register(models.ManyToManyRel) 63 | def get_relation_field(field, registry): 64 | return graphene.List( 65 | graphene.ID, 66 | description=f"Set list of {field.related_model._meta.verbose_name_plural}", 67 | ) 68 | -------------------------------------------------------------------------------- /graphene_django_plus/models.py: -------------------------------------------------------------------------------- 1 | try: 2 | from collections.abc import Iterable 3 | except ImportError: 4 | from collections import Iterable 5 | 6 | from typing import TYPE_CHECKING, List, Optional, Tuple, Type, TypeVar, Union, cast 7 | 8 | try: 9 | from guardian.conf import settings as guardian_settings 10 | from guardian.core import ObjectPermissionChecker 11 | from guardian.shortcuts import get_objects_for_user 12 | 13 | has_guardian = True 14 | except ImportError: # pragma: no cover 15 | has_guardian = False 16 | 17 | from django.apps import apps 18 | from django.contrib.auth.models import AbstractUser, AnonymousUser 19 | from django.db import models 20 | from django.db.models.query import QuerySet 21 | 22 | _T = TypeVar("_T", bound="GuardedModel") 23 | _TR = TypeVar("_TR", bound="GuardedRelatedModel") 24 | 25 | 26 | def _separate_perms( 27 | perms: Iterable, 28 | model: Type[models.Model], 29 | ) -> Tuple[List[str], List[str]]: 30 | perms = set(perms) 31 | own_perms = perms & ( 32 | {p[0] for p in model._meta.permissions} 33 | | {f"{model._meta.app_label}.{p[0]}" for p in model._meta.permissions} 34 | ) 35 | other_perms = perms - own_perms 36 | return list(other_perms), list(own_perms) 37 | 38 | 39 | def _has_anonymous_user(): 40 | if not has_guardian: 41 | return False 42 | 43 | return guardian_settings.ANONYMOUS_USER_NAME is not None 44 | 45 | 46 | class GuardedModelManager(models.Manager[_T]): 47 | """Model manager that integrates with guardian to check for permissions.""" 48 | 49 | model: Type[_T] 50 | 51 | def for_user( 52 | self, 53 | user: Union[AbstractUser, AnonymousUser], 54 | perms: Union[str, List[str]], 55 | any_perm: bool = True, 56 | with_superuser: bool = True, 57 | ) -> QuerySet[_T]: 58 | """Get a queryset filtered by perms for the user. 59 | 60 | :param user: the user itself 61 | :param perms: a string or list of perms to check for 62 | :param any_perm: if any perm or all perms should be considered 63 | :param with_superuser: if a superuser should skip the checks 64 | 65 | """ 66 | # No guardian means we are not checking perms 67 | if not has_guardian: 68 | return self.all() 69 | 70 | # In case the user is anonymous we don't have an anonymous user set, 71 | # then we can't return anything. 72 | if isinstance(user, AnonymousUser) and not _has_anonymous_user(): 73 | return self.none() 74 | 75 | perms = [perms] if isinstance(perms, str) else perms 76 | perms = [p.split(".", 1)[1] if "." in p else p for p in perms] 77 | 78 | return get_objects_for_user( 79 | user, 80 | perms, 81 | klass=self.model, 82 | any_perm=any_perm, 83 | with_superuser=with_superuser, 84 | ) 85 | 86 | 87 | class GuardedRelatedManager(GuardedModelManager[_TR]): 88 | """Manager for objects related to companies.""" 89 | 90 | model: Type[_TR] 91 | 92 | def for_user( 93 | self, 94 | user: Union[AbstractUser, AnonymousUser], 95 | perms: Union[str, List[str]], 96 | any_perm: bool = True, 97 | with_superuser: bool = True, 98 | ) -> models.QuerySet[_TR]: 99 | # No guardian means we are not checking perms 100 | if not has_guardian: 101 | return self.all() 102 | 103 | # In case the user is anonymous we don't have an anonymous user set, 104 | # then we can't return anything. 105 | if isinstance(user, AnonymousUser) and not _has_anonymous_user(): 106 | return self.none() 107 | 108 | perms = [perms] if isinstance(perms, str) else perms 109 | other_perms, own_perms = _separate_perms(perms, self.model) 110 | 111 | if own_perms: 112 | own_qs = super().for_user( 113 | user, 114 | own_perms, 115 | any_perm=any_perm, 116 | with_superuser=with_superuser, 117 | ) 118 | else: 119 | own_qs = None 120 | 121 | if other_perms: 122 | m = self.model.related_model 123 | if isinstance(m, str): 124 | app_label, model_name = m.split(".") 125 | m = apps.get_model(app_label=app_label, model_name=model_name) 126 | 127 | m = cast(Type[GuardedModel], m) 128 | other_qs = self.all().filter( 129 | **{ 130 | f"{self.model.related_attr}__in": m.objects.for_user( 131 | user, 132 | other_perms, 133 | any_perm=any_perm, 134 | with_superuser=with_superuser, 135 | ), 136 | } 137 | ) 138 | else: 139 | other_qs = None 140 | 141 | if own_qs is not None and other_qs is not None: 142 | if any_perm: 143 | return own_qs | other_qs 144 | else: 145 | return own_qs & other_qs 146 | elif own_qs is not None: 147 | return own_qs 148 | elif other_qs is not None: 149 | return other_qs 150 | else: # pragma: nocover 151 | raise AssertionError 152 | 153 | 154 | class GuardedModel(models.Model): 155 | """Model that integrates with guardian to check for permissions.""" 156 | 157 | class Meta: 158 | abstract = True 159 | 160 | # Make sure objects is properly typed for subclasses 161 | if TYPE_CHECKING: 162 | 163 | @classmethod 164 | @property 165 | def objects(cls: Type[_T]) -> GuardedModelManager[_T]: 166 | ... 167 | 168 | else: 169 | objects = GuardedModelManager["GuardedModel"]() 170 | 171 | def has_perm( 172 | self, 173 | user: Union[AbstractUser, AnonymousUser], 174 | perms: Union[str, List[str]], 175 | any_perm: bool = True, 176 | checker: Optional["ObjectPermissionChecker"] = None, 177 | ) -> bool: 178 | """Check if the user has the given permissions to this object. 179 | 180 | :param user: the user itself 181 | :param perms: a string or list of perms to check for 182 | :param any_perm: if any perm or all perms should be considered 183 | :param checker: a `guardian.core.ObjectPermissionChecker` that 184 | can be used to optimize performance in checking for permissions 185 | 186 | """ 187 | # No guardian means we are not checking perms 188 | if not has_guardian: 189 | return True 190 | 191 | # In case the user is anonymous we don't have an anonymous user set, 192 | # then we can't return anything. 193 | if isinstance(user, AnonymousUser) and not _has_anonymous_user(): 194 | return False 195 | 196 | perms = [perms] if isinstance(perms, str) else perms 197 | checker = checker or ObjectPermissionChecker(user) 198 | f = any if any_perm else all 199 | 200 | # First try to check if the user has global permissions for this 201 | # Otherwise we will check for the object itself below 202 | if f(user.has_perm(p) for p in perms): 203 | return True 204 | 205 | # Small performance improvement by mimicking guardian's api 206 | perms = [p.split(".", 1)[1] if "." in p else p for p in perms] 207 | c_perms = checker.get_perms(self) 208 | return f(p in c_perms for p in perms) 209 | 210 | 211 | class GuardedRelatedModel(GuardedModel): 212 | """Base model for objects that are related to other guarded model.""" 213 | 214 | class Meta: 215 | abstract = True 216 | 217 | # Make sure objects is properly typed for subclasses 218 | if TYPE_CHECKING: 219 | 220 | @classmethod 221 | @property 222 | def objects(cls: Type[_TR]) -> GuardedRelatedManager[_TR]: 223 | ... 224 | 225 | else: 226 | objects = GuardedRelatedManager["GuardedRelatedModel"]() 227 | 228 | related_model: Union[Type[GuardedModel], str] 229 | related_attr: str 230 | 231 | def has_perm( 232 | self, 233 | user: Union[AbstractUser, AnonymousUser], 234 | perms: Union[str, List[str]], 235 | any_perm: bool = True, 236 | checker: Optional["ObjectPermissionChecker"] = None, 237 | ) -> bool: 238 | # No guardian means we are not checking perms 239 | if not has_guardian: 240 | return True 241 | 242 | # In case the user is anonymous we don't have an anonymous user set, 243 | # then we can't return anything. 244 | if isinstance(user, AnonymousUser) and not _has_anonymous_user(): 245 | return False 246 | 247 | perms = [perms] if isinstance(perms, str) else perms 248 | checker = checker or ObjectPermissionChecker(user) 249 | other_perms, own_perms = _separate_perms(perms, self.__class__) 250 | 251 | if own_perms: 252 | super_has_perm = super().has_perm 253 | own_check = lambda: super_has_perm( 254 | user, 255 | list(own_perms), 256 | any_perm=any_perm, 257 | checker=checker, 258 | ) 259 | else: 260 | own_check = lambda: not any_perm 261 | 262 | if other_perms: 263 | assert self.related_attr is not None 264 | related = getattr(self, self.related_attr) 265 | other_check = lambda: related.has_perm( 266 | user, 267 | other_perms, 268 | any_perm=any_perm, 269 | checker=checker, 270 | ) 271 | else: 272 | other_check = lambda: not any_perm 273 | 274 | f = any if any_perm else all 275 | # Using lambdas will shortcut own_check when own_check is True 276 | # and we are checking for any_perm 277 | return f(check() for check in [other_check, own_check]) 278 | -------------------------------------------------------------------------------- /graphene_django_plus/mutations.py: -------------------------------------------------------------------------------- 1 | # This was based on some code from https://github.com/mirumee/saleor 2 | # but adapted to use relay, automatic field detection and some code adjustments 3 | 4 | import collections 5 | import collections.abc 6 | import itertools 7 | from typing import ( 8 | TYPE_CHECKING, 9 | Any, 10 | Dict, 11 | Generic, 12 | List, 13 | Optional, 14 | Type, 15 | TypeVar, 16 | cast, 17 | ) 18 | 19 | from django.core.exceptions import NON_FIELD_ERRORS, ImproperlyConfigured 20 | from django.core.exceptions import PermissionDenied as DJPermissionDenied 21 | from django.core.exceptions import ValidationError 22 | from django.db import models, transaction 23 | from django.db.models.fields import NOT_PROVIDED 24 | from django.db.models.fields.reverse_related import ManyToManyRel, ManyToOneRel 25 | import graphene 26 | from graphene.relay.mutation import ClientIDMutation 27 | from graphene.types.mutation import MutationOptions 28 | from graphene.types.objecttype import ObjectType 29 | from graphene.types.utils import yank_fields_from_attrs 30 | from graphene.utils.str_converters import to_camel_case, to_snake_case 31 | from graphene_django.converter import BlankValueField 32 | from graphene_django.registry import Registry, get_global_registry 33 | from graphql.error import GraphQLError 34 | 35 | from .exceptions import PermissionDenied 36 | from .input_types import get_input_field 37 | from .models import GuardedModel 38 | from .perms import check_authenticated, check_perms 39 | from .settings import graphene_django_plus_settings 40 | from .types import ( 41 | MutationErrorType, 42 | ResolverInfo, 43 | UploadType, 44 | schema_for_field, 45 | schema_registry, 46 | ) 47 | from .utils import get_model_fields, get_node, get_nodes, update_dict_nested 48 | 49 | _registry = get_global_registry() 50 | _T = TypeVar("_T", bound=models.Model) 51 | _M = TypeVar("_M", bound="BaseMutation") 52 | _MM = TypeVar("_MM", bound="ModelMutation") 53 | 54 | 55 | def _get_model_name(model): 56 | model_name = model.__name__ 57 | return to_snake_case(model_name[:1].lower() + model_name[1:]) 58 | 59 | 60 | def _get_output_fields(model, return_field_name, registry): 61 | model_type = registry.get_type_for_model(model) 62 | if not model_type: # pragma: no cover 63 | raise ImproperlyConfigured( 64 | "Unable to find type for model {} in graphene registry".format( 65 | model.__name__, 66 | ) 67 | ) 68 | f = graphene.Field( 69 | lambda: registry.get_type_for_model(model), 70 | description="The mutated object.", 71 | ) 72 | return {return_field_name: f} 73 | 74 | 75 | def _get_validation_errors(validation_error): 76 | e_list = [] 77 | 78 | if hasattr(validation_error, "error_dict"): 79 | # convert field errors 80 | for field, field_errors in validation_error.message_dict.items(): 81 | for e in field_errors: 82 | if field == NON_FIELD_ERRORS: 83 | field = None 84 | else: 85 | field = to_camel_case(field) 86 | e_list.append(MutationErrorType(field=field, message=e)) 87 | else: 88 | # convert non-field errors 89 | for e in validation_error.error_list: 90 | e_list.append(MutationErrorType(message=e.message)) 91 | 92 | return e_list 93 | 94 | 95 | def _get_fields(model, only_fields, exclude_fields, required_fields, registry): 96 | reverse_rel_include = graphene_django_plus_settings.MUTATIONS_INCLUDE_REVERSE_RELATIONS 97 | 98 | ret = collections.OrderedDict() 99 | for name, field in get_model_fields(model): 100 | if ( 101 | (only_fields and name not in only_fields) 102 | or name in exclude_fields 103 | or str(name).endswith("+") 104 | or name in ["created_at", "updated_at", "archived_at"] 105 | ): 106 | continue 107 | 108 | if name == "id": 109 | graphene_type = registry.get_type_for_model(model) 110 | description = ( 111 | f"ID of the " 112 | f'"{graphene_type._meta.name if graphene_type else model.__name__}" to mutate' 113 | ) 114 | f = graphene.ID( 115 | description=description, 116 | ) 117 | else: 118 | # Checking whether it was globally configured to not include reverse relations 119 | if isinstance(field, ManyToOneRel) and not reverse_rel_include and not only_fields: 120 | continue 121 | 122 | f = get_input_field(field, registry) 123 | 124 | if required_fields is not None: 125 | required = name in required_fields 126 | else: 127 | if isinstance(field, (ManyToOneRel, ManyToManyRel)): 128 | required = not field.null 129 | else: 130 | required = not field.blank and field.default is NOT_PROVIDED 131 | 132 | if not isinstance(f, BlankValueField): 133 | f.kwargs["required"] = required 134 | 135 | s = schema_for_field(field, name, registry) 136 | s["validation"]["required"] = required 137 | 138 | ret[name] = { 139 | "field": f, 140 | "schema": s, 141 | } 142 | 143 | return ret 144 | 145 | 146 | def _is_list_of_ids(field): 147 | return isinstance(field.type, graphene.List) and field.type.of_type == graphene.ID 148 | 149 | 150 | def _is_id_field(field): 151 | return ( 152 | field.type == graphene.ID 153 | or isinstance(field.type, graphene.NonNull) 154 | and field.type.of_type == graphene.ID 155 | ) 156 | 157 | 158 | def _is_upload_field(field): 159 | t = getattr(field.type, "of_type", field.type) 160 | return t == UploadType 161 | 162 | 163 | class BaseMutationOptions(MutationOptions): 164 | """Model type options for :class:`BaseMutation` and subclasses.""" 165 | 166 | #: A list of Django permissions to check against the user 167 | permissions: Optional[List[str]] = None 168 | 169 | #: If any permission should allow the user to execute this mutation 170 | permissions_any: bool = True 171 | 172 | #: If we should allow unauthenticated users to do this mutation 173 | public: bool = False 174 | 175 | #: The input schema for the schema query 176 | input_schema: Optional[dict] = None 177 | 178 | #: Optional registry to register/retrieve types and fields instead of the global one 179 | registry: Optional[Registry] = None 180 | 181 | 182 | class BaseMutation(ClientIDMutation): 183 | """Base mutation enhanced with permission checking and relay id handling.""" 184 | 185 | class Meta: 186 | abstract = True 187 | 188 | if TYPE_CHECKING: 189 | 190 | @classmethod 191 | @property 192 | def _meta(cls) -> BaseMutationOptions: 193 | ... 194 | 195 | #: A list of errors that happened during the mutation 196 | errors = graphene.List( 197 | graphene.NonNull(MutationErrorType), 198 | description="List of errors that occurred while executing the mutation.", 199 | ) 200 | 201 | @classmethod 202 | def __class_getitem__(cls, *args, **kwargs): 203 | return cls 204 | 205 | @classmethod 206 | def __init_subclass_with_meta__( 207 | cls, 208 | permissions=None, 209 | permissions_any=True, 210 | public=False, 211 | input_schema=None, 212 | registry=None, 213 | _meta=None, 214 | **kwargs, 215 | ): 216 | if not _meta: 217 | _meta = BaseMutationOptions(cls) 218 | if "allow_unauthenticated" in kwargs: 219 | raise ImproperlyConfigured("Use 'public' instead of 'allow_unauthenticated'") 220 | 221 | _meta.permissions = permissions or [] 222 | _meta.permissions_any = permissions_any 223 | _meta.public = public 224 | _meta.input_schema = input_schema or {} 225 | _meta.registry = registry or _registry 226 | 227 | super().__init_subclass_with_meta__(_meta=_meta, **kwargs) 228 | 229 | iname = cls.Input._meta.name 230 | schema_registry[iname] = { 231 | "object_type": iname, 232 | "fields": list(_meta.input_schema.values()), 233 | } 234 | 235 | @classmethod 236 | def get_node( 237 | cls, 238 | info: ResolverInfo, 239 | node_id: str, 240 | field: str = "id", 241 | only_type: Optional[ObjectType] = None, 242 | ) -> Any: 243 | """Get the node object given a relay global id.""" 244 | if not node_id: 245 | return None 246 | 247 | try: 248 | node = get_node(info, node_id, graphene_type=only_type, registry=cls._meta.registry) 249 | except (AssertionError, GraphQLError) as e: 250 | raise ValidationError({field: str(e)}) 251 | else: 252 | if node is None: # pragma: no cover 253 | raise ValidationError({field: f"Couldn't resolve to a node: {node_id}"}) 254 | 255 | return node 256 | 257 | @classmethod 258 | def get_nodes( 259 | cls, 260 | info: ResolverInfo, 261 | ids: List[str], 262 | field: str = "ids", 263 | only_type: Optional[ObjectType] = None, 264 | ) -> List[Any]: 265 | """Get a list of node objects given a list of relay global ids.""" 266 | try: 267 | instances = get_nodes(info, ids, graphene_type=only_type, registry=cls._meta.registry) 268 | except GraphQLError as e: 269 | raise ValidationError({field: str(e)}) 270 | 271 | return instances 272 | 273 | @classmethod 274 | def check_permissions(cls, info: ResolverInfo) -> bool: 275 | """Check permissions for the given user. 276 | 277 | Subclasses can override this to avoid the permission checking or 278 | extending it. Remember to call `super()` in the later case. 279 | 280 | """ 281 | user = info.context.user 282 | 283 | if not cls._meta.public and not check_authenticated(user): 284 | return False 285 | 286 | if not cls._meta.permissions: 287 | return True 288 | 289 | return check_perms(user, cls._meta.permissions, any_perm=cls._meta.permissions_any) 290 | 291 | @classmethod 292 | def mutate_and_get_payload(cls: Type[_M], root, info: ResolverInfo, **data) -> _M: 293 | """Mutate checking permissions. 294 | 295 | We override the default graphene's method to call 296 | :meth:`.check_permissions` and populate :attr:`.errors` in case 297 | of errors automatically. 298 | 299 | The mutation itself should be defined in :meth:`.perform_mutation`. 300 | 301 | """ 302 | try: 303 | if not cls.check_permissions(info): 304 | raise PermissionDenied() 305 | 306 | response = cls.perform_mutation(root, info, **data) 307 | if response.errors is None: 308 | response.errors = [] 309 | return response 310 | except ValidationError as e: 311 | errors = _get_validation_errors(e) 312 | return cls(errors=errors) 313 | except DJPermissionDenied as e: 314 | if not graphene_django_plus_settings.MUTATIONS_SWALLOW_PERMISSION_DENIED: 315 | raise 316 | msg = str(e) or "Permission denied..." 317 | return cls(errors=[MutationErrorType(message=msg)]) 318 | 319 | @classmethod 320 | def perform_mutation(cls: Type[_M], root, info: ResolverInfo, **data) -> _M: 321 | """Perform the mutation. 322 | 323 | This should be implemented in subclasses to perform the 324 | mutation. 325 | 326 | """ 327 | raise NotImplementedError 328 | 329 | 330 | class ModelMutationOptions(BaseMutationOptions, Generic[_T]): 331 | """Model type options for :class:`BaseModelMutation` and subclasses.""" 332 | 333 | #: The Django model. 334 | model: Type[_T] 335 | 336 | #: A list of guardian object permissions to check if the user has 337 | #: permission to perform a mutation to the model object. 338 | object_permissions: Optional[List[str]] = None 339 | 340 | #: If any object permission should allow the user to perform the mutation. 341 | object_permissions_any: bool = True 342 | 343 | #: Exclude the given fields from the mutation input. 344 | exclude_fields: Optional[List[str]] = None 345 | 346 | #: Include only those fields in the mutation input. 347 | only_fields: Optional[List[str]] = None 348 | 349 | #: Mark those fields as required (note that fields marked with `null=False` 350 | #: in Django will already be considered required). 351 | required_fields: Optional[List[str]] = None 352 | 353 | #: The name of the field that will contain the object type. If not 354 | #: provided, it will default to the model's name. 355 | return_field_name: Optional[str] = None 356 | 357 | 358 | class BaseModelMutation(BaseMutation, Generic[_T]): 359 | """Base mutation for models. 360 | 361 | This will allow mutations for both create and update operations, 362 | depending on if the object's id is present in the input or not. 363 | 364 | See :class:`ModelMutationOptions` for a list of meta configurations. 365 | 366 | """ 367 | 368 | class Meta: 369 | abstract = True 370 | 371 | if TYPE_CHECKING: 372 | 373 | @classmethod 374 | @property 375 | def _meta(cls) -> ModelMutationOptions[_T]: 376 | ... 377 | 378 | @classmethod 379 | def __init_subclass_with_meta__( 380 | cls, 381 | model=None, 382 | object_permissions=None, 383 | object_permissions_any=True, 384 | return_field_name=None, 385 | required_fields=None, 386 | exclude_fields=None, 387 | only_fields=None, 388 | input_schema=None, 389 | registry=None, 390 | _meta=None, 391 | **kwargs, 392 | ): 393 | if not model: # pragma: no cover 394 | raise ImproperlyConfigured("model is required for ModelMutation") 395 | if not _meta: 396 | _meta = ModelMutationOptions(cls) 397 | 398 | registry = registry or _registry 399 | exclude_fields = exclude_fields or [] 400 | only_fields = only_fields or [] 401 | if not return_field_name: 402 | return_field_name = _get_model_name(model) 403 | 404 | fdata = _get_fields(model, only_fields, exclude_fields, required_fields, registry) 405 | input_fields = yank_fields_from_attrs( 406 | {k: v["field"] for k, v in fdata.items()}, 407 | _as=graphene.InputField, 408 | ) 409 | 410 | input_schema = update_dict_nested( 411 | {k: v["schema"] for k, v in fdata.items()}, 412 | input_schema or {}, 413 | ) 414 | 415 | fields = _get_output_fields(model, return_field_name, registry) 416 | 417 | _meta.model = model 418 | _meta.object_permissions = object_permissions or [] 419 | _meta.object_permissions_any = object_permissions_any 420 | _meta.return_field_name = return_field_name 421 | _meta.exclude_fields = exclude_fields 422 | _meta.only_fields = only_fields 423 | _meta.required_fields = required_fields 424 | 425 | super().__init_subclass_with_meta__( 426 | _meta=_meta, 427 | input_fields=input_fields, 428 | input_schema=input_schema, 429 | registry=registry, 430 | **kwargs, 431 | ) 432 | 433 | cls._meta.fields.update(fields) 434 | 435 | @classmethod 436 | def check_object_permissions( 437 | cls, 438 | info: ResolverInfo, 439 | instance: _T, 440 | ) -> bool: 441 | """Check object permissions for the given user. 442 | 443 | Subclasses can override this to avoid the permission checking or 444 | extending it. Remember to call `super()` in the later case. 445 | 446 | For this to work, the model needs to implement a `has_perm` method. 447 | The easiest way when using `guardian` is to inherit it 448 | from :class:`graphene_django_plus.models.GuardedModel`. 449 | 450 | """ 451 | if not cls._meta.object_permissions: 452 | return True 453 | 454 | if not isinstance(instance, GuardedModel): 455 | return True 456 | 457 | return instance.has_perm( 458 | info.context.user, 459 | cls._meta.object_permissions, 460 | any_perm=cls._meta.object_permissions_any, 461 | ) 462 | 463 | @classmethod 464 | def get_instance(cls, info: ResolverInfo, obj_id: str) -> _T: 465 | """Get an object given a relay global id.""" 466 | instance = cls.get_node(info, obj_id) 467 | if not cls.check_object_permissions(info, instance): 468 | raise PermissionDenied() 469 | return cast(_T, instance) 470 | 471 | @classmethod 472 | def before_save(cls, info: ResolverInfo, instance: _T, cleaned_input: Dict[str, Any]): 473 | """Perform "before save" operations. 474 | 475 | Override this to perform any operation on the instance before 476 | its `.save()` method is called. 477 | 478 | """ 479 | 480 | @classmethod 481 | def after_save(cls, info: ResolverInfo, instance: _T, cleaned_input: Dict[str, Any]): 482 | """Perform "after save" operations. 483 | 484 | Override this to perform any operation on the instance after its 485 | `.save()` method is called. 486 | 487 | """ 488 | 489 | @classmethod 490 | def save(cls, info: ResolverInfo, instance: _T, cleaned_input: Dict[str, Any]): 491 | """Save the instance to the database. 492 | 493 | To do something with the instance "before" or "after" saving it, 494 | override either :meth:`.before_save` and/or :meth:`.after_save`. 495 | 496 | """ 497 | cls.before_save(info, instance, cleaned_input=cleaned_input) 498 | instance.save() 499 | 500 | # save m2m and related object's data 501 | model = type(instance) 502 | for f in itertools.chain( 503 | model._meta.many_to_many, 504 | model._meta.related_objects, 505 | model._meta.private_fields, 506 | ): 507 | if isinstance(f, (ManyToOneRel, ManyToManyRel)): 508 | # Handle reverse side relationships. 509 | d = cleaned_input.get(f.related_name or f.name + "_set", None) 510 | if d is not None: 511 | target_field = getattr(instance, f.related_name or f.name + "_set") 512 | target_field.set(d) 513 | elif hasattr(f, "save_form_data"): 514 | d = cleaned_input.get(f.name, None) 515 | if d is not None: 516 | f.save_form_data(instance, d) 517 | 518 | cls.after_save(info, instance, cleaned_input=cleaned_input) 519 | 520 | @classmethod 521 | def before_delete(cls, info: ResolverInfo, instance: _T): 522 | """Perform "before delete" operations. 523 | 524 | Override this to perform any operation on the instance before 525 | its `.delete()` method is called. 526 | 527 | """ 528 | 529 | @classmethod 530 | def after_delete(cls, info: ResolverInfo, instance: _T): 531 | """Perform "after delete" operations. 532 | 533 | Override this to perform any operation on the instance after its 534 | `.delete()` method is called. 535 | 536 | """ 537 | 538 | @classmethod 539 | def delete(cls, info: ResolverInfo, instance: _T): 540 | """Delete the instance from the database. 541 | 542 | To do something with the instance "before" or "after" deleting 543 | it, override either :meth:`.before_delete` and/or 544 | :meth:`.after_delete`. 545 | 546 | """ 547 | cls.before_delete(info, instance) 548 | instance.delete() 549 | cls.after_delete(info, instance) 550 | 551 | 552 | class ModelOperationMutation(BaseModelMutation[_T]): 553 | """Base mutation for operations on models. 554 | 555 | Just like a regular :class:`BaseModelMutation`, but this will 556 | receive only the object's id so an operation can happen to it. 557 | 558 | """ 559 | 560 | class Meta: 561 | abstract = True 562 | 563 | @classmethod 564 | def __init_subclass_with_meta__(cls, **kwargs): 565 | super().__init_subclass_with_meta__( 566 | only_fields=["id"], 567 | required_fields=["id"], 568 | **kwargs, 569 | ) 570 | 571 | 572 | class ModelMutation(BaseModelMutation[_T]): 573 | """Create and update mutation for models. 574 | 575 | This will allow mutations for both create and update operations, 576 | depending on if the object's id is present in the input or not. 577 | 578 | """ 579 | 580 | class Meta: 581 | abstract = True 582 | 583 | @classmethod 584 | def create_instance(cls, info: ResolverInfo, instance: _T, cleaned_data: Dict[str, Any]) -> _T: 585 | """Create a model instance given the already cleaned input data.""" 586 | for f in type(instance)._meta.fields: 587 | if not f.editable or isinstance(f, models.AutoField) or f.name not in cleaned_data: 588 | continue 589 | 590 | data = cleaned_data[f.name] 591 | if data is None: 592 | # We want to reset the file field value when None was passed 593 | # in the input, but `FileField.save_form_data` ignores None 594 | # values. In that case we manually pass False which clears 595 | # the file. 596 | if isinstance(f, models.FileField): 597 | data = False 598 | if not f.null: 599 | data = f._get_default() # type:ignore 600 | 601 | f.save_form_data(instance, data) 602 | 603 | return instance 604 | 605 | @classmethod 606 | def clean_instance(cls, info: ResolverInfo, instance: _T, clean_input: Dict[str, Any]) -> _T: 607 | """Validate the instance by calling its `.full_clean()` method.""" 608 | try: 609 | instance.full_clean(exclude=cls._meta.exclude_fields) 610 | except ValidationError as e: 611 | if e.error_dict: 612 | raise e 613 | 614 | return instance 615 | 616 | @classmethod 617 | def clean_input(cls, info: ResolverInfo, instance: _T, data: Dict[str, Any]): 618 | """Clear and normalize the input data.""" 619 | cleaned_input: Dict[str, Any] = {} 620 | 621 | for f_name, f_item in cls.Input._meta.fields.items(): 622 | if f_name not in data: 623 | continue 624 | value = data[f_name] 625 | 626 | if value is not None and _is_list_of_ids(f_item): 627 | # list of IDs field 628 | instances = cls.get_nodes(info, value, f_name) if value else [] 629 | cleaned_input[f_name] = instances 630 | elif value is not None and _is_id_field(f_item): 631 | # ID field 632 | instance = cls.get_node(info, value, f_name) 633 | cleaned_input[f_name] = instance 634 | elif value is not None and _is_upload_field(f_item): 635 | # uploaded files 636 | value = info.context.FILES.get(value) 637 | cleaned_input[f_name] = value 638 | else: 639 | # other fields 640 | cleaned_input[f_name] = value 641 | 642 | return cleaned_input 643 | 644 | @classmethod 645 | @transaction.atomic 646 | def perform_mutation(cls: Type[_MM], root, info: ResolverInfo, **data) -> _MM: 647 | """Perform the mutation. 648 | 649 | Create or update the instance, based on the existence of the 650 | `id` attribute in the input data and save it. 651 | 652 | """ 653 | obj_id = data.get("id") 654 | if obj_id: 655 | checked_permissions = True 656 | instance = cls.get_instance(info, obj_id) 657 | else: 658 | checked_permissions = False 659 | instance = cls._meta.model() 660 | 661 | cleaned_input = cls.clean_input(info, instance, data) 662 | instance = cls.create_instance(info, instance, cleaned_input) 663 | instance = cls.clean_instance(info, instance, cleaned_input) 664 | cls.save(info, instance, cleaned_input) 665 | 666 | if not checked_permissions and not cls.check_object_permissions(info, instance): 667 | # If we did not check permissions when getting the instance, 668 | # check if here. The model might check the permissions based on 669 | # some related objects 670 | raise PermissionDenied() 671 | 672 | assert cls._meta.return_field_name 673 | return cls(**{cls._meta.return_field_name: instance}) 674 | 675 | 676 | class ModelCreateMutation(ModelMutation[_T]): 677 | """Create mutation for models. 678 | 679 | A shortcut for defining a :class:`ModelMutation` that already 680 | excludes the `id` from being required. 681 | 682 | """ 683 | 684 | class Meta: 685 | abstract = True 686 | 687 | @classmethod 688 | def __init_subclass_with_meta__(cls, **kwargs): 689 | exclude_fields = kwargs.pop("exclude_fields", []) or [] 690 | if "id" not in exclude_fields: 691 | exclude_fields.append("id") 692 | super().__init_subclass_with_meta__( 693 | exclude_fields=exclude_fields, 694 | **kwargs, 695 | ) 696 | 697 | 698 | class ModelUpdateMutation(ModelMutation[_T]): 699 | """Update mutation for models. 700 | 701 | A shortcut for defining a :class:`ModelMutation` that already 702 | enforces the `id` to be required. 703 | 704 | """ 705 | 706 | class Meta: 707 | abstract = True 708 | 709 | @classmethod 710 | def __init_subclass_with_meta__(cls, **kwargs): 711 | if "only_fields" in kwargs and "id" not in kwargs["only_fields"]: 712 | kwargs["only_fields"].insert(0, "id") 713 | required_fields = kwargs.pop("required_fields", []) or [] 714 | if "id" not in required_fields: 715 | required_fields.insert(0, "id") 716 | super().__init_subclass_with_meta__( 717 | required_fields=required_fields, 718 | **kwargs, 719 | ) 720 | 721 | 722 | class ModelDeleteMutation(ModelOperationMutation[_T]): 723 | """Delete mutation for models.""" 724 | 725 | class Meta: 726 | abstract = True 727 | 728 | @classmethod 729 | @transaction.atomic 730 | def perform_mutation(cls: Type[_MM], root, info: ResolverInfo, **data) -> _MM: 731 | """Perform the mutation. 732 | 733 | Delete the instance from the database given its `id` attribute 734 | in the input data. 735 | 736 | """ 737 | instance = cls.get_instance(info, data["id"]) 738 | 739 | db_id = instance.id 740 | cls.delete(info, instance) 741 | 742 | # After the instance is deleted, set its ID to the original database's 743 | # ID so that the success response contains ID of the deleted object. 744 | instance.id = db_id 745 | 746 | assert cls._meta.return_field_name 747 | return cls(**{cls._meta.return_field_name: instance}) 748 | -------------------------------------------------------------------------------- /graphene_django_plus/perms.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional, Union 2 | 3 | from django.contrib.auth.models import AbstractUser, AnonymousUser 4 | 5 | from .exceptions import PermissionDenied 6 | 7 | 8 | def check_authenticated(user: Union[AbstractUser, AnonymousUser]): 9 | return user and user.is_authenticated 10 | 11 | 12 | def assert_authenticated(user: Union[AbstractUser, AnonymousUser], msg: Optional[str] = None): 13 | if not check_authenticated(user): 14 | raise PermissionDenied(msg or "You don't have permissions to do this...") 15 | 16 | 17 | def check_superuser(user: Union[AbstractUser, AnonymousUser]): 18 | return check_authenticated(user) and user.is_superuser 19 | 20 | 21 | def assert_superuser(user: Union[AbstractUser, AnonymousUser], msg: Optional[str] = None): 22 | if not check_superuser(user): 23 | raise PermissionDenied(msg or "You don't have permissions to do this...") 24 | 25 | 26 | def check_perms( 27 | user: Union[AbstractUser, AnonymousUser], 28 | perms: List[str], 29 | any_perm: bool = True, 30 | with_superuser: bool = True, 31 | ): 32 | if not check_authenticated(user): 33 | return False 34 | 35 | if with_superuser and check_superuser(user): 36 | return True 37 | 38 | u_perms = set(user.get_all_permissions()) 39 | f = any if any_perm else all 40 | return f(p in u_perms for p in perms) 41 | 42 | 43 | def assert_perms( 44 | user: Union[AbstractUser, AnonymousUser], 45 | perms: List[str], 46 | any_perm: bool = True, 47 | with_superuser: bool = True, 48 | msg: Optional[str] = None, 49 | ): 50 | if not check_perms(user, perms, any_perm=any_perm, with_superuser=with_superuser): 51 | raise PermissionDenied(msg or "You don't have permissions to do this...") 52 | -------------------------------------------------------------------------------- /graphene_django_plus/queries.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | 3 | from .types import ResolverInfo, SchemaType, schema_registry 4 | 5 | 6 | class Query: 7 | """Queries object.""" 8 | 9 | gql_object_schema = graphene.Field( 10 | SchemaType, 11 | description="GraphQL input schema for forms.", 12 | object_type=graphene.String( 13 | description="The input object to query for.", 14 | required=True, 15 | ), 16 | required=False, 17 | default_value=None, 18 | ) 19 | gql_object_schema_all = graphene.List( 20 | graphene.NonNull(SchemaType), 21 | description="GraphQL input schema for forms.", 22 | required=True, 23 | ) 24 | 25 | @staticmethod 26 | def resolve_gql_object_schema(root, info: ResolverInfo, object_type: str): 27 | return schema_registry.get(object_type, None) 28 | 29 | @staticmethod 30 | def resolve_gql_object_schema_all(root, info: ResolverInfo): 31 | return sorted(schema_registry.values(), key=lambda obj: obj["object_type"]) 32 | -------------------------------------------------------------------------------- /graphene_django_plus/schema.py: -------------------------------------------------------------------------------- 1 | import functools 2 | 3 | from django.db import models 4 | import graphene 5 | from graphene_django.compat import ArrayField, HStoreField, RangeField 6 | from graphene_django.registry import get_global_registry 7 | 8 | _registry = get_global_registry() 9 | 10 | 11 | class FieldKind(graphene.Enum): 12 | """Field kind.""" 13 | 14 | ID = "id" 15 | JSON = "json" 16 | STRING = "string" 17 | TEXT = "text" 18 | BOOLEAN = "boolean" 19 | INTEGER = "integer" 20 | DECIMAL = "decimal" 21 | FLOAT = "float" 22 | DATE = "date" 23 | DATETIME = "datetime" 24 | TIME = "time" 25 | PERCENT = "percent" 26 | EMAIL = "email" 27 | SLUG = "slug" 28 | PHONE = "phone" 29 | UUID = "uuid" 30 | IP = "ip" 31 | URL = "url" 32 | FILE = "file" 33 | PASSWORD = "password" 34 | CURRENCY = "currency" 35 | POSTAL_CODE = "postal-code" 36 | COMPANY_DOCUMENT = "company-document" 37 | INDIVIDUAL_DOCUMENT = "individual-document" 38 | 39 | 40 | @functools.singledispatch 41 | def get_field_schema(field, registry=None) -> dict: 42 | raise Exception(f"Don't know how to convert the Django field {field} ({field.__class__})") 43 | 44 | 45 | @get_field_schema.register(models.CharField) 46 | def get_field_schema_string(field, registry=None): 47 | return { 48 | "kind": FieldKind.STRING, 49 | } 50 | 51 | 52 | @get_field_schema.register(models.TextField) 53 | def get_field_schema_text(field, registry=None): 54 | return { 55 | "kind": FieldKind.TEXT, 56 | } 57 | 58 | 59 | @get_field_schema.register(models.EmailField) 60 | def get_field_schema_email(field, registry=None): 61 | return { 62 | "kind": FieldKind.EMAIL, 63 | } 64 | 65 | 66 | @get_field_schema.register(models.SlugField) 67 | def get_field_schema_slug(field, registry=None): 68 | return { 69 | "kind": FieldKind.SLUG, 70 | } 71 | 72 | 73 | @get_field_schema.register(models.UUIDField) 74 | def get_field_schema_uuid(field, registry=None): 75 | return { 76 | "kind": FieldKind.UUID, 77 | } 78 | 79 | 80 | @get_field_schema.register(models.URLField) 81 | def get_field_schema_url(field, registry=None): 82 | return { 83 | "kind": FieldKind.URL, 84 | } 85 | 86 | 87 | @get_field_schema.register(models.GenericIPAddressField) 88 | def get_field_schema_ip(field, registry=None): 89 | return { 90 | "kind": FieldKind.IP, 91 | } 92 | 93 | 94 | @get_field_schema.register(models.FileField) 95 | @get_field_schema.register(models.ImageField) 96 | @get_field_schema.register(models.FilePathField) 97 | def get_field_schema_file(field, registry=None): 98 | return { 99 | "kind": FieldKind.FILE, 100 | } 101 | 102 | 103 | @get_field_schema.register(models.AutoField) 104 | @get_field_schema.register(models.PositiveIntegerField) 105 | @get_field_schema.register(models.PositiveSmallIntegerField) 106 | @get_field_schema.register(models.SmallIntegerField) 107 | @get_field_schema.register(models.BigIntegerField) 108 | @get_field_schema.register(models.IntegerField) 109 | def get_field_schema_int(field, registry=None): 110 | return { 111 | "kind": FieldKind.INTEGER, 112 | } 113 | 114 | 115 | @get_field_schema.register(models.DecimalField) 116 | @get_field_schema.register(models.DurationField) 117 | def get_field_schema_decimal(field, registry=None): 118 | return { 119 | "kind": FieldKind.DECIMAL, 120 | "validation": { 121 | "max_digits": field.max_digits, 122 | "decimal_places": field.decimal_places, 123 | }, 124 | } 125 | 126 | 127 | @get_field_schema.register(models.FloatField) 128 | def get_field_schema_float(field, registry=None): 129 | return { 130 | "kind": FieldKind.FLOAT, 131 | } 132 | 133 | 134 | @get_field_schema.register(models.BooleanField) 135 | def get_field_schema_bool(field, registry=None): 136 | return { 137 | "kind": FieldKind.BOOLEAN, 138 | } 139 | 140 | 141 | if hasattr(models, "NullBooleanField"): 142 | 143 | @get_field_schema.register(models.NullBooleanField) # type:ignore 144 | def get_field_schema_nullbool(field, registry=None): 145 | return { 146 | "kind": FieldKind.BOOLEAN, 147 | } 148 | 149 | 150 | @get_field_schema.register(models.DateField) 151 | def get_field_schema_date(field, registry=None): 152 | return { 153 | "kind": FieldKind.DATE, 154 | } 155 | 156 | 157 | @get_field_schema.register(models.DateTimeField) 158 | def get_field_schema_datetime(field, registry=None): 159 | return { 160 | "kind": FieldKind.DATETIME, 161 | } 162 | 163 | 164 | @get_field_schema.register(models.TimeField) 165 | def get_field_schema_time(field, registry=None): 166 | return { 167 | "kind": FieldKind.TIME, 168 | } 169 | 170 | 171 | @get_field_schema.register(models.ForeignKey) 172 | @get_field_schema.register(models.OneToOneRel) 173 | @get_field_schema.register(models.OneToOneField) 174 | def get_field_schema_fk(field, registry=None): 175 | model = field.related_model 176 | registry = registry or _registry 177 | _type = registry.get_type_for_model(model) 178 | return { 179 | "kind": FieldKind.ID, 180 | "of_type": _type._meta.name if _type else None, 181 | } 182 | 183 | 184 | @get_field_schema.register(models.ManyToManyField) 185 | @get_field_schema.register(models.ManyToManyRel) 186 | @get_field_schema.register(models.ManyToOneRel) 187 | def get_field_schema_m2m(field, registry=None): 188 | model = field.related_model 189 | registry = registry or _registry 190 | _type = registry.get_type_for_model(model) 191 | return { 192 | "kind": FieldKind.ID, 193 | "of_type": _type._meta.name if _type else None, 194 | "multiple": True, 195 | } 196 | 197 | 198 | @get_field_schema.register(ArrayField) 199 | @get_field_schema.register(RangeField) 200 | def get_field_schema_array(field, registry=None): 201 | d = get_field_schema(field.base_field) 202 | d["multiple"] = True 203 | return d 204 | 205 | 206 | @get_field_schema.register(models.JSONField) 207 | @get_field_schema.register(HStoreField) 208 | def get_field_schema_pg(field, registry=None): 209 | return { 210 | "kind": FieldKind.JSON, 211 | } 212 | -------------------------------------------------------------------------------- /graphene_django_plus/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Settings for graphene-django-plus are all namespaced in the GRAPHENE_DJANGO_PLUS setting. 3 | For example your project's `settings.py` file might look like this: 4 | GRAPHENE_DJANGO_PLUS = { 5 | 'MUTATIONS_INCLUDE_REVERSE_RELATIONS': False 6 | } 7 | This module provides the `graphene_django_plus_settings` object, that is used to access 8 | graphene-django-plus settings, checking for user settings first, then falling 9 | back to the defaults. 10 | """ 11 | import importlib 12 | 13 | from django.conf import settings 14 | from django.test.signals import setting_changed 15 | 16 | # Copied shamelessly from Django REST Framework and graphene-django 17 | 18 | DEFAULTS = { 19 | "MUTATIONS_INCLUDE_REVERSE_RELATIONS": True, 20 | "MUTATIONS_SWALLOW_PERMISSION_DENIED": True, 21 | } 22 | 23 | # List of settings that may be in string import notation. 24 | IMPORT_STRINGS = [] 25 | 26 | 27 | def perform_import(val, setting_name): 28 | """Perform the necessary import in string import notation.""" 29 | if val is None: 30 | return None 31 | elif isinstance(val, str): 32 | return import_from_string(val, setting_name) 33 | elif isinstance(val, (list, tuple)): 34 | return [import_from_string(item, setting_name) for item in val] 35 | return val 36 | 37 | 38 | def import_from_string(val, setting_name): 39 | """Attempt to import a class from a string representation.""" 40 | try: 41 | # Nod to tastypie's use of importlib. 42 | parts = val.split(".") 43 | module_path, class_name = ".".join(parts[:-1]), parts[-1] 44 | module = importlib.import_module(module_path) 45 | return getattr(module, class_name) 46 | except (ImportError, AttributeError) as e: 47 | msg = "Could not import '{}' for graphene-django-plus setting '{}'. {}: {}.".format( 48 | val, 49 | setting_name, 50 | e.__class__.__name__, 51 | e, 52 | ) 53 | raise ImportError(msg) 54 | 55 | 56 | class GrapheneDjangoPlusSettings: 57 | """A settings object, that allows API settings to be accessed as properties. 58 | 59 | For example: 60 | from graphene_django_plus.settings import settings 61 | print(settings.MUTATIONS_INCLUDE_REVERSE_RELATIONS) 62 | Any setting with string import paths will be automatically resolved 63 | and return the class, rather than the string literal. 64 | 65 | """ 66 | 67 | def __init__(self, user_settings=None, defaults=None, import_strings=None): 68 | super().__init__() 69 | if user_settings: 70 | self._user_settings = user_settings 71 | self.defaults = defaults or DEFAULTS 72 | self.import_strings = import_strings or IMPORT_STRINGS 73 | self._cached_attrs = set() 74 | 75 | @property 76 | def user_settings(self): 77 | if not hasattr(self, "_user_settings"): 78 | self._user_settings = getattr(settings, "GRAPHENE_DJANGO_PLUS", {}) 79 | return self._user_settings 80 | 81 | def reload(self): 82 | for attr in self._cached_attrs: 83 | delattr(self, attr) 84 | self._cached_attrs.clear() 85 | if hasattr(self, "_user_settings"): 86 | delattr(self, "_user_settings") 87 | 88 | def __getattr__(self, attr): 89 | if attr not in self.defaults: 90 | raise AttributeError("Invalid graphene-django-plus setting: '%s'" % attr) 91 | 92 | try: 93 | # Check if present in user settings 94 | val = self.user_settings[attr] 95 | except KeyError: 96 | # Fall back to defaults 97 | val = self.defaults[attr] 98 | 99 | # Coerce import strings into classes 100 | if attr in self.import_strings: 101 | val = perform_import(val, attr) 102 | 103 | # Cache the result 104 | self._cached_attrs.add(attr) 105 | setattr(self, attr, val) 106 | return val 107 | 108 | 109 | graphene_django_plus_settings = GrapheneDjangoPlusSettings(None, DEFAULTS, IMPORT_STRINGS) 110 | 111 | 112 | def reload_graphene_django_plus_settings(*args, **kwargs): 113 | setting = kwargs["setting"] 114 | if setting == "GRAPHENE_DJANGO_PLUS": 115 | graphene_django_plus_settings.reload() 116 | 117 | 118 | setting_changed.connect(reload_graphene_django_plus_settings) 119 | -------------------------------------------------------------------------------- /graphene_django_plus/types.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import decimal 3 | from typing import TYPE_CHECKING, Any, Generic, List, Optional, Type, TypeVar, Union 4 | 5 | from django.contrib.auth.models import AbstractUser, AnonymousUser 6 | from django.core.exceptions import ImproperlyConfigured 7 | from django.db import models 8 | from django.db.models import Prefetch 9 | from django.db.models.fields import NOT_PROVIDED 10 | from django.db.models.fields.reverse_related import ManyToManyRel, ManyToOneRel 11 | from django.http import HttpRequest as DJHttpRequest 12 | import graphene 13 | from graphene.types import ResolveInfo 14 | from graphene.utils.str_converters import to_camel_case 15 | from graphene_django import DjangoObjectType 16 | from graphene_django.converter import get_choices 17 | from graphene_django.registry import get_global_registry 18 | from graphene_django.types import DjangoObjectTypeOptions 19 | 20 | try: 21 | import graphene_django_optimizer as gql_optimizer 22 | 23 | _BaseDjangoObjectType = gql_optimizer.OptimizedDjangoObjectType 24 | except ImportError: 25 | gql_optimizer = None 26 | _BaseDjangoObjectType = DjangoObjectType 27 | 28 | from .models import GuardedModel, GuardedModelManager 29 | from .perms import check_authenticated, check_perms 30 | from .schema import FieldKind, get_field_schema 31 | from .utils import get_model_fields, update_dict_nested 32 | 33 | if TYPE_CHECKING: # pragma: nocover 34 | _BaseDjangoObjectType = DjangoObjectType 35 | 36 | _T = TypeVar("_T", bound=models.Model) 37 | schema_registry = {} 38 | 39 | 40 | def schema_for_field(field, name, registry=None): 41 | registry = registry or get_global_registry() 42 | s = get_field_schema(field, registry) 43 | 44 | default_value = getattr(field, "default", None) 45 | if default_value is NOT_PROVIDED: 46 | default_value = None 47 | if default_value is not None and callable(default_value): 48 | default_value = default_value() 49 | if default_value is not None: 50 | if isinstance(default_value, decimal.Decimal): 51 | default_value = str(default_value) 52 | if isinstance(default_value, (datetime.datetime, datetime.date, datetime.time)): 53 | default_value = default_value.isoformat() 54 | 55 | if isinstance(field, (ManyToOneRel, ManyToManyRel)): 56 | required = not field.null 57 | else: 58 | required = not field.blank and field.default is NOT_PROVIDED 59 | 60 | items = getattr(field, "choices", None) 61 | if items: 62 | if isinstance(items, dict): # pragma:nocover 63 | items = items.items() 64 | 65 | choices = [] 66 | for (_original_v, label), (n, _value, _desc) in zip(items, get_choices(items)): 67 | choices.append( 68 | { 69 | "label": label, 70 | "value": n, 71 | } 72 | ) 73 | else: 74 | choices = None 75 | 76 | s = update_dict_nested( 77 | s, 78 | { 79 | "name": to_camel_case(name), 80 | # FIXME: Get verbose_name and help_text for m2m 81 | "label": getattr(field, "verbose_name", None), 82 | "help_text": getattr(field, "help_text", None), 83 | "hidden": name == "id", 84 | "choices": choices, 85 | "default_value": default_value, 86 | "validation": { 87 | "required": required, 88 | "min_length": getattr(field, "min_length", None), 89 | "max_length": getattr(field, "max_length", None), 90 | "min_value": None, 91 | "max_value": None, 92 | }, 93 | }, 94 | ) 95 | 96 | return s 97 | 98 | 99 | class HttpRequest(DJHttpRequest): 100 | user: Union[AbstractUser, AnonymousUser] 101 | 102 | 103 | class ResolverInfo(ResolveInfo): 104 | context: HttpRequest 105 | 106 | 107 | class MutationErrorType(graphene.ObjectType): 108 | """An error that happened in a mutation.""" 109 | 110 | field = graphene.String( 111 | description=( 112 | "The field that caused the error, or `null` if it " 113 | "isn't associated with any particular field." 114 | ), 115 | required=False, 116 | ) 117 | message = graphene.String( 118 | description="The error message.", 119 | ) 120 | 121 | 122 | class InputSchemaFieldChoiceType(graphene.ObjectType): 123 | """An input schema field choice.""" 124 | 125 | label = graphene.String( 126 | description="The choice's label.", 127 | required=True, 128 | ) 129 | value = graphene.String( 130 | description="The choice's value.", 131 | required=True, 132 | ) 133 | 134 | 135 | class SchemaFieldValidationType(graphene.ObjectType): 136 | """Validation data for the field.""" 137 | 138 | required = graphene.Boolean( 139 | description="If this field is required.", 140 | required=True, 141 | default_value=False, 142 | ) 143 | min_value = graphene.JSONString( 144 | description="Min value for the field. Parse the json to get its value.", 145 | required=False, 146 | default_value=None, 147 | ) 148 | max_value = graphene.JSONString( 149 | description="Max value for the field. Parse the json to get its value.", 150 | required=False, 151 | default_value=None, 152 | ) 153 | min_length = graphene.Int( 154 | description="Min length for string kinds.", 155 | required=False, 156 | default_value=None, 157 | ) 158 | max_length = graphene.Int( 159 | description="Max length for string kinds.", 160 | required=False, 161 | default_value=None, 162 | ) 163 | max_digits = graphene.Int( 164 | description="Max digits for decimal kinds (null otherwise).", 165 | required=False, 166 | default_value=None, 167 | ) 168 | decimal_places = graphene.Int( 169 | description="Max digits for decimal kinds (null otherwise).", 170 | required=False, 171 | default_value=None, 172 | ) 173 | 174 | 175 | class SchemaFieldType(graphene.ObjectType): 176 | """The input schema field.""" 177 | 178 | name = graphene.String( 179 | description="The name of the field", 180 | required=True, 181 | ) 182 | kind = FieldKind( 183 | description="The kind of this field.", 184 | required=True, 185 | ) 186 | of_type = graphene.String( 187 | description="The name of the related field for ID kinds.", 188 | required=False, 189 | default_value=None, 190 | ) 191 | multiple = graphene.Boolean( 192 | description="If this field expects an array of values.", 193 | required=True, 194 | default_value=False, 195 | ) 196 | choices = graphene.List( 197 | graphene.NonNull(InputSchemaFieldChoiceType), 198 | description="Choices for this field.", 199 | required=False, 200 | default_value=None, 201 | ) 202 | hidden = graphene.Boolean( 203 | description="If this field should be displayed in a hidden field.", 204 | required=True, 205 | default_value=False, 206 | ) 207 | label = graphene.String( 208 | description="The field's humanized name.", 209 | required=False, 210 | default_value=None, 211 | ) 212 | help_text = graphene.String( 213 | description="A help text for the field.", 214 | required=False, 215 | default_value=None, 216 | ) 217 | default_value = graphene.JSONString( 218 | description="Default value for the field. Parse the json to get its value.", 219 | required=False, 220 | default_value=None, 221 | ) 222 | validation = graphene.Field( 223 | SchemaFieldValidationType, 224 | description="Validation metadata for this field.", 225 | required=True, 226 | ) 227 | 228 | 229 | class SchemaType(graphene.ObjectType): 230 | """The input schema.""" 231 | 232 | object_type = graphene.String( 233 | description="The name of the input object.", 234 | required=True, 235 | ) 236 | fields = graphene.List( 237 | graphene.NonNull(SchemaFieldType), 238 | description="The fields in the input object.", 239 | required=True, 240 | ) 241 | 242 | 243 | class UploadType(graphene.types.Scalar): 244 | """The upload of a file. 245 | 246 | Variables of this type must be set to null in mutations. They will be 247 | replaced with a filename from a following multipart part containing a 248 | binary file. 249 | 250 | See: https://github.com/jaydenseric/graphql-multipart-request-spec 251 | 252 | """ 253 | 254 | @staticmethod 255 | def serialize(value): 256 | return value 257 | 258 | @staticmethod 259 | def parse_literal(node): 260 | return node 261 | 262 | @staticmethod 263 | def parse_value(value): 264 | return value 265 | 266 | 267 | class ModelTypeOptions(DjangoObjectTypeOptions, Generic[_T]): 268 | """Model type options for :class:`ModelType`.""" 269 | 270 | #: The Django model. 271 | model: Type[_T] 272 | 273 | #: If we should allow unauthenticated users to query for this model. 274 | public: bool = False 275 | 276 | #: A list of django permissions to check if the user has permission to 277 | #: query this model. 278 | permissions: Optional[List[str]] = None 279 | 280 | #: If any permission should allow the user to query this model. 281 | permissions_any: bool = True 282 | 283 | #: A list of guardian object permissions to check if the user has 284 | #: permission to query the model object. 285 | object_permissions: Optional[List[str]] = None 286 | 287 | #: If any object permission should allow the user to query this model. 288 | object_permissions_any: bool = True 289 | 290 | #: If superuser should be considered when getting `GuardedModelManager.for_user` 291 | object_permissions_with_superuser: bool = True 292 | 293 | #: The fields schema for the schema query 294 | fields_schema: Optional[dict] = None 295 | 296 | 297 | class ModelType(_BaseDjangoObjectType, Generic[_T]): 298 | """Base type with automatic optimizations and permissions checking.""" 299 | 300 | class Meta: 301 | abstract = True 302 | 303 | if TYPE_CHECKING: 304 | 305 | @classmethod 306 | @property 307 | def _meta(cls) -> ModelTypeOptions[_T]: 308 | ... 309 | 310 | @classmethod 311 | def __class_getitem__(cls, *args, **kwargs): 312 | return cls 313 | 314 | @classmethod 315 | def __init_subclass_with_meta__( 316 | cls, 317 | _meta=None, 318 | model=None, 319 | permissions=None, 320 | permissions_any=True, 321 | object_permissions=None, 322 | object_permissions_any=True, 323 | object_permissions_with_superuser=True, 324 | fields_schema=None, 325 | public=None, 326 | only_fields=None, 327 | fields=None, 328 | exclude_fields=None, 329 | exclude=None, 330 | **kwargs, 331 | ): 332 | if not _meta: 333 | _meta = ModelTypeOptions(cls) 334 | 335 | if "allow_unauthenticated" in kwargs: 336 | raise ImproperlyConfigured("Use 'public' instead of 'allow_unauthenticated'") 337 | 338 | _meta.permissions = permissions or [] 339 | _meta.permissions_any = permissions_any 340 | _meta.object_permissions = object_permissions or [] 341 | _meta.object_permissions_any = object_permissions_any 342 | _meta.object_permissions_with_superuser = object_permissions_with_superuser 343 | _meta.public = public 344 | 345 | _fields_schema = {} 346 | # graphene will handle the deprecated only_fields/exclude_fields for us 347 | # We just want to mimic the logic here 348 | _include = fields if fields is not None else only_fields 349 | if _include is not None: 350 | _include = set(_include) 351 | _exclude = set(exclude or []) or set(exclude_fields or []) 352 | for name, field in get_model_fields(model): 353 | if name in _exclude: 354 | continue 355 | if _include is not None and name not in _include: 356 | continue 357 | 358 | registry = kwargs.get("registry", get_global_registry()) 359 | _fields_schema[name] = schema_for_field(field, name, registry) 360 | 361 | fields_schema = update_dict_nested( 362 | _fields_schema, 363 | fields_schema or {}, 364 | ) 365 | _meta.fields_schema = fields_schema or {} 366 | 367 | super().__init_subclass_with_meta__( 368 | _meta=_meta, 369 | model=model, 370 | only_fields=only_fields, 371 | fields=fields, 372 | exclude_fields=exclude_fields, 373 | exclude=exclude, 374 | **kwargs, 375 | ) 376 | 377 | schema_registry[cls._meta.name] = { 378 | "object_type": cls._meta.name, 379 | "fields": list(_meta.fields_schema.values()), 380 | } 381 | 382 | @classmethod 383 | def get_queryset( 384 | cls, 385 | qs: Union[models.QuerySet[_T], models.Manager[_T]], 386 | info: ResolverInfo, 387 | ) -> models.QuerySet[_T]: 388 | """Get the queryset checking for permissions and optimizing the query. 389 | 390 | Override the default graphene's `get_queryset` to check for permissions 391 | and optimize the query performance. 392 | 393 | Note that the query will only be automaticallu optimized if, 394 | `graphene_django_optimizer` is installed. 395 | 396 | """ 397 | if isinstance(qs, models.Manager): 398 | qs = qs.get_queryset() 399 | 400 | if not cls.check_permissions(info.context.user): 401 | return qs.none() 402 | 403 | if cls._meta.object_permissions and isinstance( 404 | cls._meta.model.objects, GuardedModelManager 405 | ): 406 | qs &= cls._meta.model.objects.for_user( 407 | info.context.user, 408 | cls._meta.object_permissions, 409 | any_perm=cls._meta.object_permissions_any, 410 | with_superuser=cls._meta.object_permissions_with_superuser, 411 | ) 412 | 413 | ret = qs 414 | if gql_optimizer is not None: 415 | ret = gql_optimizer.query(ret, info) 416 | prl = { 417 | i.to_attr if isinstance(i, Prefetch) else i: i # type:ignore 418 | for i in ret._prefetch_related_lookups 419 | } 420 | ret._prefetch_related_lookups = tuple(prl.values()) 421 | 422 | return ret 423 | 424 | @classmethod 425 | def get_node(cls, info: ResolverInfo, id_: Any) -> Optional[_T]: 426 | """Get the node instance given the relay global id.""" 427 | # NOTE: get_queryset will filter allowed models for the user so 428 | # this will return None if he is not allowed to retrieve this 429 | 430 | if gql_optimizer is not None: 431 | # optimizer will ignore queryset so call the same as they call 432 | # but passing objects from get_queryset to keep our preferences 433 | try: 434 | instance = cls.get_queryset(cls._meta.model.objects, info).get( 435 | pk=id_, 436 | ) 437 | except cls._meta.model.DoesNotExist: 438 | instance = None 439 | else: 440 | instance = super().get_node(info, id_) 441 | 442 | if instance is not None and not cls.check_object_permissions(info.context.user, instance): 443 | return None 444 | 445 | return instance 446 | 447 | @classmethod 448 | def check_permissions(cls, user: Union[AbstractUser, AnonymousUser]) -> bool: 449 | """Check permissions for the given user. 450 | 451 | Subclasses can override this to avoid the permission checking or 452 | extending it. Remember to call `super()` in the later case. 453 | 454 | """ 455 | if not cls._meta.public and not check_authenticated(user): 456 | return False 457 | 458 | if not cls._meta.permissions: 459 | return True 460 | 461 | return check_perms(user, cls._meta.permissions, any_perm=cls._meta.permissions_any) 462 | 463 | @classmethod 464 | def check_object_permissions( 465 | cls, 466 | user: Union[AbstractUser, AnonymousUser], 467 | instance: _T, 468 | ) -> bool: 469 | """Check object permissions for the given user. 470 | 471 | Subclasses can override this to avoid the permission checking or 472 | extending it. Remember to call `super()` in the later case. 473 | 474 | For this to work, the model needs to implement a `has_perm` method. 475 | The easiest way when using `guardian` is to inherit it 476 | from :class:`graphene_django_plus.models.GuardedModel`. 477 | 478 | """ 479 | if not cls._meta.object_permissions: 480 | return True 481 | 482 | if not isinstance(instance, GuardedModel): 483 | return True 484 | 485 | return instance.has_perm( 486 | user, 487 | cls._meta.object_permissions, 488 | any_perm=cls._meta.object_permissions_any, 489 | ) 490 | -------------------------------------------------------------------------------- /graphene_django_plus/utils.py: -------------------------------------------------------------------------------- 1 | try: 2 | from collections.abc import Mapping 3 | except ImportError: 4 | from collections import Mapping 5 | 6 | import itertools 7 | from typing import List, Optional, Type 8 | 9 | from django.db import models 10 | from django.db.models.fields.reverse_related import ManyToOneRel 11 | import graphene 12 | from graphene.types.mountedtype import MountedType 13 | from graphene.types.objecttype import ObjectType 14 | from graphene.types.structures import Structure 15 | from graphene.types.unmountedtype import UnmountedType 16 | from graphene.types.utils import yank_fields_from_attrs 17 | from graphene_django.registry import get_global_registry 18 | from graphene_django.types import DjangoObjectType 19 | from graphql.error import GraphQLError 20 | from graphql_relay import from_global_id 21 | 22 | _registry = get_global_registry() 23 | _extra_register = {} 24 | _input_registry = {} 25 | 26 | 27 | def _resolve_nodes(ids, graphene_type=None): 28 | pks = [] 29 | invalid_ids = [] 30 | used_type = graphene_type 31 | 32 | for graphql_id in ids: 33 | if not graphql_id: 34 | continue 35 | 36 | try: 37 | node_type, _id = from_global_id(graphql_id) 38 | except Exception: 39 | invalid_ids.append(graphql_id) 40 | continue 41 | 42 | if used_type and str(used_type) != node_type: # pragma: nocover 43 | raise AssertionError(f"Must receive a {str(used_type)} id.") 44 | 45 | used_type = node_type 46 | pks.append(_id) 47 | 48 | if invalid_ids: # pragma: no cover 49 | raise GraphQLError( 50 | "Could not resolve to a node with the id list of '{}'.".format( 51 | invalid_ids, 52 | ), 53 | ) 54 | 55 | return used_type, pks 56 | 57 | 58 | def _resolve_graphene_type(type_name, registry=None): 59 | registry = registry or _registry 60 | for _, _type in itertools.chain(_extra_register.items(), registry._registry.items()): 61 | if _type._meta.name == type_name: 62 | return _type 63 | else: # pragma: no cover 64 | raise AssertionError(f"Could not resolve the type {type_name}") 65 | 66 | 67 | def _get_input_attrs(object_type): 68 | new = {} 69 | 70 | for attr, value in object_type.__dict__.items(): 71 | if not isinstance(value, (MountedType, UnmountedType)): 72 | continue 73 | 74 | if isinstance(value, Structure) and issubclass(value.of_type, ObjectType): 75 | value = type(value)(_input_registry[value.of_type]) 76 | elif isinstance(value, ObjectType): 77 | value = _input_registry[value.of_type] 78 | 79 | new[attr] = value 80 | 81 | return yank_fields_from_attrs(new, _as=graphene.InputField) 82 | 83 | 84 | def register_type(graphene_type, name: Optional[str] = None): 85 | """Register an extra type to be resolved in mutations.""" 86 | assert issubclass(graphene_type, graphene.ObjectType) 87 | if name is None: 88 | name = graphene_type._meta.name 89 | _extra_register[name] = graphene_type 90 | return graphene_type 91 | 92 | 93 | def get_node(info, id_: str, graphene_type: Optional[ObjectType] = None, registry=None): 94 | """Get a node given the relay id.""" 95 | node_type, _id = from_global_id(id_) 96 | if not graphene_type: 97 | graphene_type = _resolve_graphene_type(node_type, registry) 98 | assert graphene_type is not None 99 | 100 | if issubclass(graphene_type, DjangoObjectType): 101 | return graphene_type._meta.model.objects.get(pk=_id) 102 | else: 103 | return graphene_type.get_node(info, _id) 104 | 105 | 106 | def get_nodes(info, ids: List[str], graphene_type: Optional[ObjectType] = None, registry=None): 107 | """Get a list of nodes. 108 | 109 | If the `graphene_type` argument is provided, the IDs will be validated 110 | against this type. If the type was not provided, it will be looked up in 111 | the Graphene's registry. 112 | 113 | Raises an error if not all IDs are of the same type. 114 | 115 | """ 116 | if not ids: # pragma: nocover 117 | raise ValueError("ids list cannot be empty") 118 | 119 | nodes_type, pks = _resolve_nodes(ids, graphene_type) 120 | 121 | # If `graphene_type` was not provided, check if all resolved types are 122 | # the same. This prevents from accidentally mismatching IDs of different 123 | # types. 124 | if nodes_type and not graphene_type: 125 | graphene_type = _resolve_graphene_type(nodes_type, registry) 126 | assert graphene_type is not None 127 | 128 | if issubclass(graphene_type, DjangoObjectType): 129 | nodes = list(graphene_type._meta.model.objects.filter(pk__in=pks)) 130 | nodes.sort(key=lambda e: pks.index(str(e.pk))) # preserve order in pks 131 | 132 | diff = set(pks) - {str(n.pk) for n in nodes} 133 | if diff: 134 | raise GraphQLError( 135 | "There is no node of type {} with pk {}".format( 136 | graphene_type, 137 | ", ".join(diff), 138 | ) 139 | ) 140 | else: 141 | nodes = [graphene_type.get_node(info, id_) for id_ in pks] 142 | 143 | return nodes 144 | 145 | 146 | def get_inputtype(name, object_type): 147 | """Get an input type based on the object type.""" 148 | if object_type in _input_registry: 149 | return _input_registry[object_type] 150 | 151 | inputtype = type( 152 | name, 153 | (graphene.InputObjectType,), 154 | _get_input_attrs(object_type), 155 | ) 156 | 157 | _input_registry[object_type] = inputtype 158 | return inputtype 159 | 160 | 161 | def get_model_fields(model: Type[models.Model]): 162 | fields = [ 163 | (field.name, field) 164 | for field in sorted(model._meta.fields + model._meta.many_to_many) # type:ignore 165 | ] 166 | fields.extend( 167 | [ 168 | (field.related_name or field.name + "_set", field) 169 | for field in sorted( 170 | model._meta.related_objects, 171 | key=lambda field: field.name, 172 | ) 173 | if not isinstance(field, ManyToOneRel) or field.remote_field.null 174 | ], 175 | ) 176 | return fields 177 | 178 | 179 | def update_dict_nested(d: dict, u: dict) -> dict: 180 | for k, v in u.items(): 181 | if isinstance(v, Mapping): 182 | d[k] = update_dict_nested(d.get(k, {}), v) # type:ignore 183 | else: 184 | d[k] = v 185 | 186 | return d 187 | -------------------------------------------------------------------------------- /graphene_django_plus/views.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import json 3 | 4 | from graphene_django.views import GraphQLView as _GraphQLView 5 | 6 | 7 | def _get_key(key): 8 | try: 9 | int_key = int(key) 10 | except (TypeError, ValueError): 11 | return key 12 | else: 13 | return int_key 14 | 15 | 16 | def _get_shallow_property(obj, prop): 17 | if isinstance(prop, int): 18 | return obj[prop] 19 | 20 | try: 21 | return obj.get(prop) 22 | except AttributeError: 23 | return None 24 | 25 | 26 | def _obj_set(obj, path, value): 27 | if isinstance(path, int): 28 | path = [path] 29 | 30 | if not path: 31 | return obj 32 | 33 | if isinstance(path, str): 34 | new_path = [_get_key(part) for part in path.split(".")] 35 | return _obj_set(obj, new_path, value) 36 | 37 | current_path = path[0] 38 | current_value = _get_shallow_property(obj, current_path) 39 | 40 | if len(path) == 1: 41 | obj[current_path] = value 42 | 43 | if current_value is None: 44 | with contextlib.suppress(IndexError): 45 | if isinstance(path[1], int): 46 | obj[current_path] = [] 47 | else: 48 | obj[current_path] = {} 49 | 50 | return _obj_set(obj[current_path], path[1:], value) 51 | 52 | 53 | class GraphQLView(_GraphQLView): 54 | """GraphQLView with file upload support. 55 | 56 | Based on: 57 | https://github.com/mirumee/saleor/blob/master/saleor/graphql/views.py 58 | 59 | """ 60 | 61 | @staticmethod 62 | def get_graphql_params(request, data): 63 | query, variables, operation_name, id_ = _GraphQLView.get_graphql_params( 64 | request, 65 | data, 66 | ) 67 | 68 | content_type = _GraphQLView.get_content_type(request) 69 | if content_type == "multipart/form-data": 70 | operations = json.loads(data.get("operations", "{}")) 71 | files_map = json.loads(data.get("map", "{}")) 72 | for k, v in files_map.items(): 73 | for f in v: 74 | _obj_set(operations, f, k) 75 | query = operations.get("query") 76 | variables = operations.get("variables") 77 | 78 | return query, variables, operation_name, id_ 79 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Entrypoint for the demo app.""" 4 | 5 | import os 6 | import sys 7 | 8 | if __name__ == "__main__": 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 10 | from django.core.management import execute_from_command_line 11 | 12 | execute_from_command_line(sys.argv) 13 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "graphene-django-plus" 3 | version = "5.1" 4 | description = "Tools to easily create permissioned CRUD endpoints in graphene." 5 | authors = ["Thiago Bellini Ribeiro "] 6 | license = "MIT" 7 | readme = "README.md" 8 | homepage = "https://github.com/0soft/graphene-django-plus" 9 | repository = "https://github.com/0soft/graphene-django-plus" 10 | documentation = "https://graphene-django-plus.readthedocs.io" 11 | keywords = ["graphene", "django", "graphql", "crud", "permissions"] 12 | classifiers = [ 13 | "Development Status :: 5 - Production/Stable", 14 | "Environment :: Web Environment", 15 | "Intended Audience :: Developers", 16 | "License :: OSI Approved :: MIT License", 17 | "Operating System :: OS Independent", 18 | "Programming Language :: Python", 19 | "Programming Language :: Python :: 3", 20 | "Programming Language :: Python :: 3.8", 21 | "Programming Language :: Python :: 3.9", 22 | "Programming Language :: Python :: 3.10", 23 | "Programming Language :: Python :: 3.11", 24 | "Framework :: Django", 25 | "Framework :: Django :: 3.2", 26 | "Framework :: Django :: 4.0", 27 | "Framework :: Django :: 4.1", 28 | "Framework :: Django :: 4.2", 29 | ] 30 | packages = [{ include = "graphene_django_plus" }] 31 | 32 | [tool.poetry.dependencies] 33 | python = "^3.8" 34 | django = ">=3.2" 35 | graphene-django = ">=3.1.2" 36 | 37 | [tool.poetry.dev-dependencies] 38 | black = "^22.3.0" 39 | codecov = "^2.1.11" 40 | django = "^4.1.7" 41 | django-filter = "^22.1" 42 | django-guardian = "^2.3.0" 43 | django-types = "^0.17.0" 44 | flake8 = "^4.0.1" 45 | flake8-broken-line = "^0.4.0" 46 | flake8-bugbear = "^22.1.11" 47 | flake8-builtins = "^1.5.3" 48 | flake8-comprehensions = "^3.7.0" 49 | flake8-polyfill = "^1.0.2" 50 | flake8-return = "^1.1.3" 51 | flake8-simplify = "^0.19.2" 52 | graphene = "^3.2.1" 53 | graphene-django = "^3.1.2" 54 | graphene-django-optimizer = { git = "https://github.com/bellini666/graphene-django-optimizer.git" } 55 | mock = "^5.0.1" 56 | pytest = "^7.1.2" 57 | pytest-cov = "^4.0.0" 58 | pytest-env = "^0.8.1" 59 | pytest-django = "^4.2.0" 60 | pytest-mock = "^3.6.1" 61 | pytest-sugar = "^0.9.4" 62 | sphinx = "^4" 63 | sphinx-rtd-theme = "^1.0.0" 64 | 65 | [tool.black] 66 | line-length = 100 67 | target-version = ['py38', 'py39'] 68 | exclude = ''' 69 | /( 70 | \.eggs 71 | | \.git 72 | | \.hg 73 | | \.mypy_cache 74 | | \.tox 75 | | \.venv 76 | | __pycached__ 77 | | _build 78 | | buck-out 79 | | build 80 | | dist 81 | )/ 82 | ''' 83 | 84 | [tool.isort] 85 | profile = "black" 86 | py_version = 38 87 | multi_line_output = 3 88 | force_sort_within_sections = true 89 | 90 | [tool.pyright] 91 | pythonVersion = "3.8" 92 | pythonPlatform = "Linux" 93 | useLibraryCodeForTypes = true 94 | reportUnnecessaryTypeIgnoreComment = "warning" 95 | reportUnnecessaryCast = "warning" 96 | reportCallInDefaultInitializer = "warning" 97 | reportOverlappingOverload = "warning" 98 | reportUninitializedInstanceVariable = "warning" 99 | reportUntypedNamedTuple = "error" 100 | reportMissingSuperCall = "warning" 101 | strictListInference = "error" 102 | strictDictionaryInference = "error" 103 | strictSetInference = "error" 104 | 105 | [tool.pytest.ini_options] 106 | DJANGO_SETTINGS_MODULE = "tests.settings" 107 | python_files = "tests/test_*.py" 108 | addopts = "-p no:warnings --nomigrations --cov=./ --cov-report term-missing:skip-covered" 109 | 110 | [build-system] 111 | requires = ["poetry-core>=1.0.0"] 112 | build-backend = "poetry.core.masonry.api" 113 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | env = 3 | DJANGO_SETTINGS_MODULE=tests.settings 4 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0soft/graphene-django-plus/f39ae238b17df85e5caca747dc7f68bc2e3df79d/tests/__init__.py -------------------------------------------------------------------------------- /tests/base.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import List 3 | 4 | from django.contrib.auth.models import User 5 | from graphene_django.utils.testing import GraphQLTestCase 6 | from guardian.shortcuts import assign_perm 7 | 8 | from .models import Issue, IssueComment, Milestone, Project 9 | from .schema import schema 10 | 11 | 12 | class BaseTestCase(GraphQLTestCase): 13 | GRAPHQL_SCHEMA = schema 14 | 15 | user: User 16 | project: Project 17 | milestone_1: Milestone 18 | milestone_2: Milestone 19 | issues: List[Issue] 20 | allowed_issues: List[Issue] 21 | unallowed_issues: List[Issue] 22 | issues_comments: List[IssueComment] 23 | unallowed_issues_comments: List[IssueComment] 24 | allowed_issues_comments: List[IssueComment] 25 | 26 | def setUp(self): 27 | self.user = User(username="foobar") 28 | self.user.set_password("foobar") 29 | self.user.save() 30 | self.client.login(username="foobar", password="foobar") 31 | 32 | self.project = Project.objects.create( 33 | name="Test Project", 34 | due_date=datetime.date(2050, 1, 1), 35 | ) 36 | self.milestone_1 = Milestone.objects.create( 37 | name="Milestone 1", 38 | due_date=datetime.date(2050, 1, 1), 39 | project=self.project, 40 | ) 41 | self.milestone_2 = Milestone.objects.create( 42 | name="Milestone 2", 43 | project=self.project, 44 | ) 45 | self.allowed_issues = [] 46 | self.unallowed_issues = [] 47 | self.issues = [] 48 | for i, (priority, milestone) in enumerate( 49 | [ 50 | (1, self.milestone_1), 51 | (1, self.milestone_1), 52 | (0, self.milestone_2), 53 | (3, None), 54 | ] 55 | ): 56 | i = Issue.objects.create( 57 | name=f"Issue {i + 1}", 58 | priority=priority, 59 | milestone=milestone, 60 | ) 61 | if milestone == self.milestone_1: 62 | assign_perm("can_read", self.user, i) 63 | assign_perm("can_write", self.user, i) 64 | self.allowed_issues.append(i) 65 | else: 66 | self.unallowed_issues.append(i) 67 | 68 | self.issues.append(i) 69 | 70 | self.issues_comments = [] 71 | self.allowed_issues_comments = [] 72 | self.unallowed_issues_comments = [] 73 | for n, i in enumerate(self.issues): 74 | for j in range(3): 75 | c = IssueComment.objects.create(issue=i, comment=f"{i}: {n}-{j} comment") 76 | self.issues_comments.append(c) 77 | if i in self.allowed_issues: 78 | self.allowed_issues_comments.append(c) 79 | else: 80 | self.unallowed_issues_comments.append(c) 81 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.django_db(transaction=True) 5 | @pytest.fixture(autouse=True) 6 | def _enable_db(db): 7 | pass 8 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Optional 2 | 3 | from django.db import models 4 | 5 | from graphene_django_plus.models import GuardedModel, GuardedRelatedModel 6 | 7 | if TYPE_CHECKING: # pragma: nocover 8 | from django.db.models.manager import RelatedManager 9 | 10 | 11 | class Project(models.Model): 12 | 13 | milestones: "RelatedManager[Milestone]" 14 | 15 | id = models.BigAutoField( # noqa: A003 16 | verbose_name="ID", 17 | primary_key=True, 18 | ) 19 | name = models.CharField( 20 | max_length=255, 21 | ) 22 | due_date = models.DateField( 23 | null=True, 24 | blank=True, 25 | default=None, 26 | ) 27 | cost = models.DecimalField( 28 | max_digits=20, 29 | decimal_places=2, 30 | null=True, 31 | blank=True, 32 | default=None, 33 | ) 34 | 35 | 36 | class Milestone(models.Model): 37 | 38 | issues: "RelatedManager[Issue]" 39 | 40 | id = models.BigAutoField( # noqa: A003 41 | verbose_name="ID", 42 | primary_key=True, 43 | ) 44 | name = models.CharField( 45 | max_length=255, 46 | ) 47 | due_date = models.DateField( 48 | null=True, 49 | blank=True, 50 | default=None, 51 | ) 52 | project_id: int 53 | project = models.ForeignKey[Project]( 54 | Project, 55 | related_name="milestones", 56 | related_query_name="milestone", 57 | on_delete=models.CASCADE, 58 | ) 59 | 60 | 61 | class Issue(GuardedModel): 62 | class Meta: 63 | permissions = [ 64 | ("can_read", "Can read the issue's information."), 65 | ("can_write", "Can update the issue's information."), 66 | ] 67 | 68 | comments: "RelatedManager[Issue]" 69 | 70 | kinds = { 71 | "b": "Bug", 72 | "f": "Feature", 73 | } 74 | 75 | id = models.BigAutoField( # noqa: A003 76 | verbose_name="ID", 77 | primary_key=True, 78 | ) 79 | name = models.CharField( 80 | max_length=255, 81 | ) 82 | kind = models.CharField( 83 | verbose_name="kind", 84 | help_text="the kind of the issue", 85 | max_length=max(len(t) for t in kinds), 86 | choices=list(kinds.items()), 87 | default=None, 88 | blank=True, 89 | null=True, 90 | ) 91 | priority = models.IntegerField( 92 | default=0, 93 | ) 94 | milestone_id: int 95 | milestone = models.ForeignKey[Optional[Milestone]]( 96 | Milestone, 97 | related_name="issues", 98 | related_query_name="issue", 99 | null=True, 100 | blank=True, 101 | default=None, 102 | on_delete=models.SET_NULL, 103 | ) 104 | 105 | 106 | class IssueComment(GuardedRelatedModel): 107 | class Meta: 108 | permissions = [ 109 | ("can_moderate", "Can moderate this comment."), 110 | ] 111 | 112 | related_model = "tests.Issue" 113 | related_attr = "issue" 114 | 115 | id = models.BigAutoField( # noqa: A003 116 | verbose_name="ID", 117 | primary_key=True, 118 | ) 119 | issue_id: int 120 | issue = models.ForeignKey( 121 | Issue, 122 | null=True, 123 | blank=True, 124 | on_delete=models.SET_NULL, 125 | related_name="comments", 126 | related_query_name="comments", 127 | ) 128 | comment = models.CharField( 129 | max_length=255, 130 | ) 131 | 132 | 133 | class MilestoneComment(models.Model): 134 | 135 | id = models.BigAutoField( # noqa: A003 136 | verbose_name="ID", 137 | primary_key=True, 138 | ) 139 | text = models.CharField( 140 | max_length=255, 141 | ) 142 | milestone_id: int 143 | milestone = models.ForeignKey( 144 | Milestone, 145 | null=True, 146 | blank=True, 147 | on_delete=models.SET_NULL, 148 | ) 149 | -------------------------------------------------------------------------------- /tests/schema.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | from graphene import relay 3 | from graphene_django.registry import Registry 4 | 5 | from graphene_django_plus.fields import CountableConnection, OrderableConnectionField 6 | from graphene_django_plus.mutations import ( 7 | ModelCreateMutation, 8 | ModelDeleteMutation, 9 | ModelUpdateMutation, 10 | ) 11 | from graphene_django_plus.queries import Query as _Query 12 | from graphene_django_plus.types import ModelType 13 | 14 | from .models import Issue, Milestone, MilestoneComment, Project 15 | 16 | # Registry 17 | 18 | project_name_only_registry = Registry() 19 | 20 | # Types 21 | 22 | 23 | class IssueType(ModelType): 24 | class Meta: 25 | model = Issue 26 | connection_class = CountableConnection 27 | interfaces = [relay.Node] 28 | object_permissions = [ 29 | "can_read", 30 | ] 31 | filter_fields = {} 32 | 33 | 34 | class MilestoneType(ModelType): 35 | class Meta: 36 | model = Milestone 37 | connection_class = CountableConnection 38 | interfaces = [relay.Node] 39 | filter_fields = {} 40 | 41 | 42 | class ProjectType(ModelType): 43 | class Meta: 44 | model = Project 45 | connection_class = CountableConnection 46 | interfaces = [relay.Node] 47 | filter_fields = {} 48 | 49 | 50 | class MilestoneCommentType(ModelType): 51 | class Meta: 52 | model = MilestoneComment 53 | connection_class = CountableConnection 54 | interfaces = [relay.Node] 55 | filter_fields = {} 56 | 57 | 58 | class ProjectNameOnlyType(ModelType): 59 | class Meta: 60 | model = Project 61 | fields = ["id", "name"] 62 | connection_class = CountableConnection 63 | interfaces = [relay.Node] 64 | filter_fields = {} 65 | registry = project_name_only_registry 66 | 67 | 68 | # Queries 69 | 70 | 71 | class Query(graphene.ObjectType, _Query): 72 | projects = OrderableConnectionField(ProjectType) 73 | project = relay.Node.Field(ProjectType) 74 | project_name_only = relay.Node.Field(ProjectNameOnlyType) 75 | 76 | milestones = OrderableConnectionField(MilestoneType) 77 | milestone = relay.Node.Field(MilestoneType) 78 | 79 | issues = OrderableConnectionField(IssueType) 80 | issue = relay.Node.Field(IssueType) 81 | 82 | 83 | # Mutations 84 | 85 | 86 | class ProjectCreateMutation(ModelCreateMutation): 87 | class Meta: 88 | model = Project 89 | 90 | 91 | class ProjectUpdateMutation(ModelUpdateMutation): 92 | class Meta: 93 | model = Project 94 | 95 | 96 | class ProjectDeleteMutation(ModelDeleteMutation): 97 | class Meta: 98 | model = Project 99 | 100 | 101 | class MilestoneCreateMutation(ModelCreateMutation): 102 | class Meta: 103 | model = Milestone 104 | 105 | 106 | class MilestoneUpdateMutation(ModelUpdateMutation): 107 | class Meta: 108 | model = Milestone 109 | 110 | 111 | class MilestoneDeleteMutation(ModelDeleteMutation): 112 | class Meta: 113 | model = Milestone 114 | 115 | 116 | class IssueCreateMutation(ModelCreateMutation): 117 | class Meta: 118 | model = Issue 119 | 120 | 121 | class IssueUpdateMutation(ModelUpdateMutation): 122 | class Meta: 123 | model = Issue 124 | object_permissions = [ 125 | "can_write", 126 | ] 127 | 128 | 129 | class IssueDeleteMutation(ModelDeleteMutation): 130 | class Meta: 131 | model = Issue 132 | object_permissions = [ 133 | "can_write", 134 | ] 135 | 136 | 137 | class ProjectNameOnlyUpdateMutation(ModelUpdateMutation): 138 | class Meta: 139 | model = Project 140 | registry = project_name_only_registry 141 | 142 | 143 | class Mutation(graphene.ObjectType): 144 | """Milestones mutation.""" 145 | 146 | project_create = ProjectCreateMutation.Field() 147 | project_update = ProjectUpdateMutation.Field() 148 | project_delete = ProjectDeleteMutation.Field() 149 | project_update_name = ProjectNameOnlyUpdateMutation.Field() 150 | 151 | milestone_create = MilestoneCreateMutation.Field() 152 | milestone_update = MilestoneUpdateMutation.Field() 153 | milestone_delete = MilestoneDeleteMutation.Field() 154 | 155 | issue_create = IssueCreateMutation.Field() 156 | issue_update = IssueUpdateMutation.Field() 157 | issue_delete = IssueDeleteMutation.Field() 158 | 159 | 160 | # Schema 161 | 162 | 163 | schema = graphene.Schema( 164 | query=Query, 165 | mutation=Mutation, 166 | ) 167 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.db.models.manager import BaseManager 3 | from django.db.models.query import QuerySet 4 | 5 | for cls in [QuerySet, BaseManager, models.ForeignKey]: 6 | cls.__class_getitem__ = classmethod(lambda cls, *args, **kwargs: cls) # noqa 7 | 8 | 9 | DEBUG = True 10 | INSTALLED_APPS = [ 11 | "django.contrib.auth", 12 | "django.contrib.contenttypes", 13 | "django.contrib.sessions", 14 | "guardian", 15 | "graphene_django", 16 | "django_filters", 17 | "tests", 18 | ] 19 | 20 | DATABASES = { 21 | "default": { 22 | "ENGINE": "django.db.backends.sqlite3", 23 | }, 24 | } 25 | 26 | MIDDLEWARE = [ 27 | "django.contrib.sessions.middleware.SessionMiddleware", 28 | "django.contrib.auth.middleware.AuthenticationMiddleware", 29 | ] 30 | 31 | SECRET_KEY = "dummy" 32 | 33 | ROOT_URLCONF = "tests.urls" 34 | -------------------------------------------------------------------------------- /tests/test_fields.py: -------------------------------------------------------------------------------- 1 | import json 2 | from unittest import mock 3 | 4 | from .base import BaseTestCase 5 | 6 | 7 | class TestModels(BaseTestCase): 8 | def test_orderby(self): 9 | # milestones 10 | r = self.query( 11 | """ 12 | query milestones { 13 | milestones (orderby: ["name"]) { 14 | edges { 15 | node { 16 | name 17 | } 18 | } 19 | } 20 | } 21 | """, 22 | operation_name="milestones", 23 | ) 24 | d = (json.loads(r.content),) 25 | edges = d[0]["data"]["milestones"]["edges"] 26 | self.assertEqual( 27 | edges[0]["node"]["name"], 28 | "Milestone 1", 29 | ) 30 | self.assertEqual( 31 | edges[1]["node"]["name"], 32 | "Milestone 2", 33 | ) 34 | 35 | # milestones reversed 36 | r = self.query( 37 | """ 38 | query milestones { 39 | milestones (orderby: ["-name"]) { 40 | edges { 41 | node { 42 | name 43 | } 44 | } 45 | } 46 | } 47 | """, 48 | operation_name="milestones", 49 | ) 50 | d = (json.loads(r.content),) 51 | edges = d[0]["data"]["milestones"]["edges"] 52 | self.assertEqual( 53 | edges[0]["node"]["name"], 54 | "Milestone 2", 55 | ) 56 | self.assertEqual( 57 | edges[1]["node"]["name"], 58 | "Milestone 1", 59 | ) 60 | 61 | def test_total_count(self): 62 | # projects 63 | r = self.query( 64 | """ 65 | query projects { 66 | projects { 67 | totalCount 68 | } 69 | } 70 | """, 71 | operation_name="projects", 72 | ) 73 | self.assertEqual( 74 | json.loads(r.content), 75 | {"data": {"projects": {"totalCount": 1}}}, 76 | ) 77 | 78 | # milestones 79 | r = self.query( 80 | """ 81 | query milestones { 82 | milestones { 83 | totalCount 84 | } 85 | } 86 | """, 87 | operation_name="milestones", 88 | ) 89 | self.assertEqual( 90 | json.loads(r.content), 91 | {"data": {"milestones": {"totalCount": 2}}}, 92 | ) 93 | 94 | # issues 95 | r = self.query( 96 | """ 97 | query issues { 98 | issues { 99 | totalCount 100 | } 101 | } 102 | """, 103 | operation_name="issues", 104 | ) 105 | self.assertEqual( 106 | json.loads(r.content), 107 | {"data": {"issues": {"totalCount": 2}}}, 108 | ) 109 | 110 | @mock.patch("graphene_django_plus.types.gql_optimizer", None) 111 | def test_total_count_no_gql_optimizer(self): 112 | # projects 113 | r = self.query( 114 | """ 115 | query projects { 116 | projects { 117 | totalCount 118 | } 119 | } 120 | """, 121 | operation_name="projects", 122 | ) 123 | self.assertEqual( 124 | json.loads(r.content), 125 | {"data": {"projects": {"totalCount": 1}}}, 126 | ) 127 | 128 | # milestones 129 | r = self.query( 130 | """ 131 | query milestones { 132 | milestones { 133 | totalCount 134 | } 135 | } 136 | """, 137 | operation_name="milestones", 138 | ) 139 | self.assertEqual( 140 | json.loads(r.content), 141 | {"data": {"milestones": {"totalCount": 2}}}, 142 | ) 143 | 144 | # issues 145 | r = self.query( 146 | """ 147 | query issues { 148 | issues { 149 | totalCount 150 | } 151 | } 152 | """, 153 | operation_name="issues", 154 | ) 155 | self.assertEqual( 156 | json.loads(r.content), 157 | {"data": {"issues": {"totalCount": 2}}}, 158 | ) 159 | -------------------------------------------------------------------------------- /tests/test_input_types.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | import graphene 3 | from graphene_django.registry import get_global_registry 4 | 5 | from graphene_django_plus.input_types import get_input_field 6 | 7 | from .base import BaseTestCase 8 | from .models import Project 9 | 10 | _registry = get_global_registry() 11 | 12 | 13 | class TestInputTypes(BaseTestCase): 14 | def test_char(self): 15 | field = models.CharField() 16 | input_field = get_input_field(field, _registry) 17 | self.assertEqual(input_field._meta.name, "String") # type:ignore 18 | 19 | def test_boolean(self): 20 | field = models.BooleanField() 21 | input_field = get_input_field(field, _registry) 22 | self.assertEqual(input_field._meta.name, "Boolean") # type:ignore 23 | 24 | def test_file(self): 25 | field = models.FileField() 26 | input_field = get_input_field(field, _registry) 27 | self.assertEqual(input_field._meta.name, "UploadType") # type:ignore 28 | 29 | def test_foreign_key(self): 30 | field = models.ForeignKey(Project, on_delete=models.CASCADE) 31 | input_field = get_input_field(field, _registry) 32 | self.assertEqual(input_field._meta.name, "ID") # type:ignore 33 | 34 | def test_one_to_one(self): 35 | field = models.OneToOneField(Project, on_delete=models.CASCADE) 36 | input_field = get_input_field(field, _registry) 37 | self.assertEqual(input_field._meta.name, "ID") # type:ignore 38 | 39 | def test_many_to_many(self): 40 | field = models.ManyToManyField(Project) 41 | input_field = get_input_field(field, _registry) 42 | self.assertTrue(isinstance(input_field, graphene.List)) 43 | self.assertEqual(input_field.of_type._meta.name, "ID") # type:ignore 44 | 45 | def test_many_to_one_relation(self): 46 | field = models.ManyToOneRel( 47 | models.ForeignKey(Project, on_delete=models.CASCADE), Project, "projects" 48 | ) 49 | field.related_model = ( 50 | Project # set this manually because django does not initialize the model 51 | ) 52 | input_field = get_input_field(field, _registry) 53 | self.assertTrue(isinstance(input_field, graphene.List)) 54 | self.assertEqual(input_field.of_type._meta.name, "ID") # type:ignore 55 | 56 | def test_many_to_many_relation(self): 57 | field = models.ManyToManyRel( 58 | models.ForeignKey(Project, on_delete=models.CASCADE), Project, "projects" 59 | ) 60 | field.related_model = ( 61 | Project # set this manually because django does not initialize the model 62 | ) 63 | input_field = get_input_field(field, _registry) 64 | self.assertTrue(isinstance(input_field, graphene.List)) 65 | self.assertEqual(input_field.of_type._meta.name, "ID") # type:ignore 66 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from django.contrib.auth.models import Permission, User 4 | from django.contrib.contenttypes.models import ContentType 5 | 6 | from .base import BaseTestCase 7 | from .models import Issue, IssueComment 8 | 9 | 10 | class TestGuardedModel(BaseTestCase): 11 | def test_for_user(self): 12 | self.assertEqual( 13 | set(Issue.objects.for_user(self.user, ["can_read"])), 14 | set(self.allowed_issues), 15 | ) 16 | self.assertEqual( 17 | set(Issue.objects.for_user(self.user, "can_read")), 18 | set(self.allowed_issues), 19 | ) 20 | 21 | def test_for_user_no_perms(self): 22 | user = User.objects.create(username="no_perm") 23 | self.assertEqual( 24 | set(Issue.objects.for_user(user, ["tests.can_read"])), 25 | set(), 26 | ) 27 | self.assertEqual( 28 | set(Issue.objects.for_user(user, "tests.can_read")), 29 | set(), 30 | ) 31 | 32 | def test_for_user_global_perm(self): 33 | user = User.objects.create(username="global_perm") 34 | ct = ContentType.objects.get_for_model(Issue) 35 | permission = Permission.objects.get(content_type=ct, codename="can_read") 36 | user.user_permissions.add(permission) 37 | self.assertEqual( 38 | set(Issue.objects.for_user(user, ["tests.can_read"])), 39 | set(self.issues), 40 | ) 41 | self.assertEqual( 42 | set(Issue.objects.for_user(user, "tests.can_read")), 43 | set(self.issues), 44 | ) 45 | 46 | def test_for_user_superuser(self): 47 | user = User(username="superuser") 48 | user.is_superuser = True 49 | user.save() 50 | 51 | self.assertEqual( 52 | set(Issue.objects.for_user(user, ["can_read"], with_superuser=False)), 53 | set(), 54 | ) 55 | self.assertEqual( 56 | set(Issue.objects.for_user(user, "can_read", with_superuser=False)), 57 | set(), 58 | ) 59 | self.assertEqual( 60 | set(Issue.objects.for_user(user, ["can_read"])), 61 | set(self.issues), 62 | ) 63 | self.assertEqual( 64 | set(Issue.objects.for_user(user, "can_read")), 65 | set(self.issues), 66 | ) 67 | 68 | def test_has_perm_no_perm(self): 69 | user = User.objects.create(username="no_perm") 70 | 71 | for i in self.issues: 72 | self.assertFalse(i.has_perm(user, ["can_read"])) 73 | self.assertFalse(i.has_perm(user, "can_read")) 74 | 75 | def test_has_perm_global_perm(self): 76 | user = User.objects.create(username="global_perm") 77 | ct = ContentType.objects.get_for_model(Issue) 78 | permission = Permission.objects.get(content_type=ct, codename="can_read") 79 | user.user_permissions.add(permission) 80 | 81 | for i in self.issues: 82 | self.assertTrue(i.has_perm(user, ["tests.can_read"])) 83 | self.assertTrue(i.has_perm(user, "tests.can_read")) 84 | 85 | def test_has_perm(self): 86 | for i in self.issues: 87 | if i in self.allowed_issues: 88 | self.assertTrue(i.has_perm(self.user, ["can_read"])) 89 | self.assertTrue(i.has_perm(self.user, "can_read")) 90 | else: 91 | self.assertFalse(i.has_perm(self.user, ["can_read"])) 92 | self.assertFalse(i.has_perm(self.user, "can_read")) 93 | 94 | def test_has_perm_superuser(self): 95 | user = User(username="superuser") 96 | user.is_superuser = True 97 | user.save() 98 | 99 | for i in self.issues: 100 | self.assertTrue(i.has_perm(user, ["can_read"])) 101 | self.assertTrue(i.has_perm(user, "can_read")) 102 | 103 | @mock.patch("graphene_django_plus.models.has_guardian", False) 104 | def test_for_user_no_guardian(self): 105 | self.assertEqual( 106 | set(Issue.objects.for_user(self.user, ["can_read"])), 107 | set(self.issues), 108 | ) 109 | self.assertEqual( 110 | set(Issue.objects.for_user(self.user, "can_read")), 111 | set(self.issues), 112 | ) 113 | 114 | @mock.patch("graphene_django_plus.models.has_guardian", False) 115 | def test_has_perm_no_guardian(self): 116 | for i in self.issues: 117 | self.assertTrue(i.has_perm(self.user, ["can_read"])) 118 | self.assertTrue(i.has_perm(self.user, "can_read")) 119 | 120 | 121 | class TestGuardedRelatedModel(BaseTestCase): 122 | def test_for_user(self): 123 | self.assertEqual( 124 | set(IssueComment.objects.for_user(self.user, ["tests.can_read"])), 125 | set(self.allowed_issues_comments), 126 | ) 127 | self.assertEqual( 128 | set(IssueComment.objects.for_user(self.user, "tests.can_read")), 129 | set(self.allowed_issues_comments), 130 | ) 131 | 132 | def test_for_user_no_perms(self): 133 | user = User.objects.create(username="no_perm") 134 | self.assertEqual( 135 | set(IssueComment.objects.for_user(user, ["tests.can_read"])), 136 | set(), 137 | ) 138 | self.assertEqual( 139 | set(IssueComment.objects.for_user(user, "tests.can_read")), 140 | set(), 141 | ) 142 | 143 | def test_for_user_global_perm(self): 144 | user = User.objects.create(username="global_perm") 145 | ct = ContentType.objects.get_for_model(Issue) 146 | permission = Permission.objects.get(content_type=ct, codename="can_read") 147 | user.user_permissions.add(permission) 148 | self.assertEqual( 149 | set(IssueComment.objects.for_user(user, ["tests.can_read"])), 150 | set(self.issues_comments), 151 | ) 152 | self.assertEqual( 153 | set(IssueComment.objects.for_user(user, "tests.can_read")), 154 | set(self.issues_comments), 155 | ) 156 | 157 | def test_for_user_superuser(self): 158 | user = User(username="superuser") 159 | user.is_superuser = True 160 | user.save() 161 | 162 | self.assertEqual( 163 | set(IssueComment.objects.for_user(user, ["tests.can_read"], with_superuser=False)), 164 | set(), 165 | ) 166 | self.assertEqual( 167 | set(IssueComment.objects.for_user(user, "tests.can_read", with_superuser=False)), 168 | set(), 169 | ) 170 | self.assertEqual( 171 | set(IssueComment.objects.for_user(user, ["tests.can_read"])), 172 | set(self.issues_comments), 173 | ) 174 | self.assertEqual( 175 | set(IssueComment.objects.for_user(user, "tests.can_read")), 176 | set(self.issues_comments), 177 | ) 178 | 179 | def test_has_perm_no_perm(self): 180 | user = User.objects.create(username="no_perm") 181 | 182 | for i in self.issues_comments: 183 | self.assertFalse(i.has_perm(user, ["tests.can_read"])) 184 | self.assertFalse(i.has_perm(user, "tests.can_read")) 185 | 186 | def test_has_perm_global_perm(self): 187 | user = User.objects.create(username="global_perm") 188 | ct = ContentType.objects.get_for_model(Issue) 189 | permission = Permission.objects.get(content_type=ct, codename="can_read") 190 | user.user_permissions.add(permission) 191 | 192 | for i in self.issues_comments: 193 | self.assertTrue(i.has_perm(user, ["tests.can_read"])) 194 | self.assertTrue(i.has_perm(user, "tests.can_read")) 195 | 196 | def test_has_perm(self): 197 | for i in self.issues_comments: 198 | if i in self.allowed_issues_comments: 199 | self.assertTrue(i.has_perm(self.user, ["tests.can_read"])) 200 | self.assertTrue(i.has_perm(self.user, "tests.can_read")) 201 | else: 202 | self.assertFalse(i.has_perm(self.user, ["tests.can_read"])) 203 | self.assertFalse(i.has_perm(self.user, "tests.can_read")) 204 | 205 | def test_has_perm_superuser(self): 206 | user = User(username="superuser") 207 | user.is_superuser = True 208 | user.save() 209 | 210 | for i in self.issues_comments: 211 | self.assertTrue(i.has_perm(user, ["tests.can_read"])) 212 | self.assertTrue(i.has_perm(user, "tests.can_read")) 213 | 214 | @mock.patch("graphene_django_plus.models.has_guardian", False) 215 | def test_for_user_no_guardian(self): 216 | self.assertEqual( 217 | set(IssueComment.objects.for_user(self.user, ["tests.can_read"])), 218 | set(self.issues_comments), 219 | ) 220 | self.assertEqual( 221 | set(IssueComment.objects.for_user(self.user, "tests.can_read")), 222 | set(self.issues_comments), 223 | ) 224 | 225 | @mock.patch("graphene_django_plus.models.has_guardian", False) 226 | def test_has_perm_no_guardian(self): 227 | for i in self.issues_comments: 228 | self.assertTrue(i.has_perm(self.user, ["tests.can_read"])) 229 | self.assertTrue(i.has_perm(self.user, "tests.can_read")) 230 | -------------------------------------------------------------------------------- /tests/test_mutations.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | 4 | from django.test.utils import override_settings 5 | import graphene 6 | from graphene import relay 7 | from graphene_django.registry import get_global_registry 8 | from graphql_relay import to_global_id 9 | 10 | from graphene_django_plus.mutations import ModelCreateMutation 11 | 12 | from .base import BaseTestCase 13 | from .models import Issue, Milestone, MilestoneComment, Project 14 | from .schema import ( 15 | IssueType, 16 | MilestoneCommentType, 17 | MilestoneType, 18 | ProjectNameOnlyUpdateMutation, 19 | ProjectType, 20 | ProjectUpdateMutation, 21 | project_name_only_registry, 22 | ) 23 | 24 | 25 | class TestTypes(BaseTestCase): 26 | def test_mutation_create(self): 27 | # project 28 | self.assertIsNone(Project.objects.filter(name="FooBar").first()) 29 | r = self.query( 30 | """ 31 | mutation projectCreate { 32 | projectCreate (input: {name: "FooBar"}) { 33 | project { 34 | name 35 | } 36 | } 37 | } 38 | """, 39 | operation_name="projectCreate", 40 | ) 41 | self.assertEqual( 42 | json.loads(r.content), 43 | {"data": {"projectCreate": {"project": {"name": "FooBar"}}}}, 44 | ) 45 | self.assertIsNotNone(Project.objects.filter(name="FooBar").first()) 46 | 47 | # milestone 48 | p_id = base64.b64encode( 49 | "ProjectType:{}".format( 50 | self.project.id, 51 | ).encode() 52 | ).decode() 53 | self.assertIsNone(Milestone.objects.filter(name="BarBin").first()) 54 | r = self.query( 55 | """ 56 | mutation milestoneCreate { 57 | milestoneCreate (input: {name: "BarBin", project: "%s"}) { 58 | milestone { 59 | name 60 | project { 61 | name 62 | } 63 | } 64 | } 65 | } 66 | """ 67 | % (p_id,), 68 | operation_name="milestoneCreate", 69 | ) 70 | self.assertEqual( 71 | json.loads(r.content), 72 | { 73 | "data": { 74 | "milestoneCreate": { 75 | "milestone": { 76 | "name": "BarBin", 77 | "project": {"name": "Test Project"}, 78 | } 79 | } 80 | } 81 | }, 82 | ) 83 | self.assertIsNotNone(Milestone.objects.filter(name="BarBin").first()) 84 | 85 | def test_mutation_update(self): 86 | # project 87 | p_id = base64.b64encode( 88 | "ProjectType:{}".format( 89 | self.project.id, 90 | ).encode() 91 | ).decode() 92 | self.assertNotEqual(self.project.name, "XXX") 93 | r = self.query( 94 | """ 95 | mutation projectUpdate { 96 | projectUpdate (input: {id: "%s" name: "XXX"}) { 97 | project { 98 | name 99 | } 100 | } 101 | } 102 | """ 103 | % (p_id,), 104 | operation_name="projectUpdate", 105 | ) 106 | self.assertEqual( 107 | json.loads(r.content), 108 | {"data": {"projectUpdate": {"project": {"name": "XXX"}}}}, 109 | ) 110 | self.project.refresh_from_db() 111 | self.assertEqual(self.project.name, "XXX") 112 | 113 | # issue (allowed) 114 | issue = self.allowed_issues[0] 115 | i_id = base64.b64encode( 116 | "IssueType:{}".format( 117 | issue.id, 118 | ).encode() 119 | ).decode() 120 | self.assertNotEqual(issue.name, "YYY") 121 | r = self.query( 122 | """ 123 | mutation issueUpdate { 124 | issueUpdate (input: {id: "%s" name: "YYY"}) { 125 | issue { 126 | name 127 | } 128 | } 129 | } 130 | """ 131 | % (i_id,), 132 | operation_name="issueUpdate", 133 | ) 134 | self.assertEqual( 135 | json.loads(r.content), 136 | {"data": {"issueUpdate": {"issue": {"name": "YYY"}}}}, 137 | ) 138 | issue.refresh_from_db() 139 | self.assertEqual(issue.name, "YYY") 140 | 141 | # issue (not allowed) 142 | issue = self.unallowed_issues[0] 143 | i_id = base64.b64encode( 144 | "IssueType:{}".format( 145 | issue.id, 146 | ).encode() 147 | ).decode() 148 | r = self.query( 149 | """ 150 | mutation issueUpdate { 151 | issueUpdate (input: {id: "%s" name: "YYY"}) { 152 | issue { 153 | name 154 | } 155 | errors { 156 | field 157 | message 158 | } 159 | } 160 | } 161 | """ 162 | % (i_id,), 163 | operation_name="issueUpdate", 164 | ) 165 | self.assertEqual( 166 | json.loads(r.content), 167 | { 168 | "data": { 169 | "issueUpdate": { 170 | "errors": [ 171 | { 172 | "field": None, 173 | "message": "You do not have permission " "to perform this action", 174 | } 175 | ], 176 | "issue": None, 177 | } 178 | } 179 | }, 180 | ) 181 | 182 | def test_mutation_delete(self): 183 | # project 184 | p_id = base64.b64encode( 185 | "ProjectType:{}".format( 186 | self.project.id, 187 | ).encode() 188 | ).decode() 189 | r = self.query( 190 | """ 191 | mutation projectDelete { 192 | projectDelete (input: {id: "%s"}) { 193 | project { 194 | name 195 | } 196 | } 197 | } 198 | """ 199 | % (p_id,), 200 | operation_name="projectDelete", 201 | ) 202 | self.assertEqual( 203 | json.loads(r.content), 204 | {"data": {"projectDelete": {"project": {"name": "Test Project"}}}}, 205 | ) 206 | self.assertIsNone(Project.objects.filter(id=self.project.id).first()) 207 | 208 | # issue (allowed) 209 | issue = self.allowed_issues[0] 210 | i_id = base64.b64encode( 211 | "IssueType:{}".format( 212 | issue.id, 213 | ).encode() 214 | ).decode() 215 | r = self.query( 216 | """ 217 | mutation issueDelete { 218 | issueDelete (input: {id: "%s"}) { 219 | issue { 220 | name 221 | } 222 | } 223 | } 224 | """ 225 | % (i_id,), 226 | operation_name="issueDelete", 227 | ) 228 | self.assertEqual( 229 | json.loads(r.content), 230 | {"data": {"issueDelete": {"issue": {"name": issue.name}}}}, 231 | ) 232 | self.assertIsNone(Issue.objects.filter(id=issue.id).first()) 233 | 234 | # issue (not allowed) 235 | issue = self.unallowed_issues[0] 236 | i_id = base64.b64encode( 237 | "IssueType:{}".format( 238 | issue.id, 239 | ).encode() 240 | ).decode() 241 | r = self.query( 242 | """ 243 | mutation issueDelete { 244 | issueDelete (input: {id: "%s"}) { 245 | issue { 246 | name 247 | } 248 | errors { 249 | field 250 | message 251 | } 252 | } 253 | } 254 | """ 255 | % (i_id,), 256 | operation_name="issueDelete", 257 | ) 258 | self.assertEqual( 259 | json.loads(r.content), 260 | { 261 | "data": { 262 | "issueDelete": { 263 | "errors": [ 264 | { 265 | "field": None, 266 | "message": "You do not have permission " "to perform this action", 267 | } 268 | ], 269 | "issue": None, 270 | } 271 | } 272 | }, 273 | ) 274 | 275 | 276 | class TestMutationRegistry(BaseTestCase): 277 | """Tests with ObjectTypes and Mutations using a different registry than the global registry.""" 278 | 279 | def test_mutation_meta_registry(self): 280 | """Test having registry information stored in mutation meta.""" 281 | self.assertEqual(ProjectUpdateMutation._meta.registry, get_global_registry()) 282 | self.assertNotEqual(ProjectUpdateMutation._meta.registry, project_name_only_registry) 283 | 284 | self.assertNotEqual(ProjectNameOnlyUpdateMutation._meta.registry, get_global_registry()) 285 | self.assertEqual(ProjectNameOnlyUpdateMutation._meta.registry, project_name_only_registry) 286 | 287 | def test_mutation_with_non_global_registry(self): 288 | """Test that update mutation using non global registry is working.""" 289 | # project 290 | p_id = to_global_id("ProjectNameOnlyType", self.project.id) 291 | self.assertNotEqual(self.project.name, "XXX") 292 | r = self.query( 293 | """ 294 | mutation projectUpdateName { 295 | projectUpdateName (input: {id: "%s" name: "XXX"}) { 296 | project { 297 | name 298 | } 299 | } 300 | } 301 | """ 302 | % (p_id,) 303 | ) 304 | self.assertEqual( 305 | json.loads(r.content), 306 | {"data": {"projectUpdateName": {"project": {"name": "XXX"}}}}, 307 | ) 308 | self.project.refresh_from_db() 309 | self.assertEqual(self.project.name, "XXX") 310 | 311 | def test_mutation_name_only(self): 312 | """Test that update mutation using non global registry is using correct model type.""" 313 | 314 | # project 315 | p_id = to_global_id("ProjectNameOnlyType", self.project.id) 316 | self.assertNotEqual(self.project.name, "XXX") 317 | r = self.query( 318 | """ 319 | mutation projectUpdateName { 320 | projectUpdateName (input: {id: "%s" name: "XXX"}) { 321 | project { 322 | name 323 | dueDate 324 | } 325 | } 326 | } 327 | """ 328 | % (p_id,) 329 | ) 330 | self.assertEqual( 331 | json.loads(r.content)["errors"][0]["message"], 332 | "Cannot query field 'dueDate' on type 'ProjectNameOnlyType'.", 333 | ) 334 | self.project.refresh_from_db() 335 | self.assertNotEqual(self.project.name, "XXX") 336 | 337 | 338 | class TestMutationRelatedObjects(BaseTestCase): 339 | """Tests for creating and updating reverse side of FK and M2M relationships.""" 340 | 341 | def test_create_milestone_issues(self): 342 | """Test that a milestone can be created with a list of issues.""" 343 | milestone = "release_1A" 344 | self.assertIsNone(Milestone.objects.filter(name=milestone).first()) 345 | 346 | project_id = base64.b64encode( 347 | "ProjectType:{}".format( 348 | self.project.id, 349 | ).encode() 350 | ).decode() 351 | issue_id = base64.b64encode( 352 | "IssueType:{}".format( 353 | self.issues[0].id, 354 | ).encode() 355 | ).decode() 356 | 357 | r = self.query( 358 | """ 359 | mutation milestoneCreate { 360 | milestoneCreate (input: { 361 | name: "%s", 362 | project: "%s", 363 | issues: ["%s"] 364 | }) { 365 | milestone { 366 | name 367 | issues { 368 | edges { 369 | node { 370 | name 371 | } 372 | } 373 | } 374 | } 375 | } 376 | } 377 | """ 378 | % (milestone, project_id, issue_id), 379 | operation_name="milestoneCreate", 380 | ) 381 | self.assertEqual( 382 | json.loads(r.content), 383 | { 384 | "data": { 385 | "milestoneCreate": { 386 | "milestone": { 387 | "name": "release_1A", 388 | "issues": { 389 | "edges": [ 390 | { 391 | "node": {"name": "Issue 1"}, 392 | } 393 | ] 394 | }, 395 | } 396 | } 397 | } 398 | }, 399 | ) 400 | self.assertIsNotNone(Milestone.objects.filter(name=milestone).first()) 401 | 402 | def test_update_milestone_issues(self): 403 | """Test that issues can be updated as a part of milestone update.""" 404 | milestone = Milestone.objects.create(name="release-A", project=self.project) 405 | milestone.issues.set(self.issues) 406 | m_id = base64.b64encode( 407 | "MilestoneType:{}".format( 408 | milestone.id, 409 | ).encode() 410 | ).decode() 411 | 412 | # Now update it to just having a single issue. 413 | issue_id = base64.b64encode( 414 | "IssueType:{}".format( 415 | self.issues[0].id, 416 | ).encode() 417 | ).decode() 418 | r = self.query( 419 | """ 420 | mutation milestoneUpdate { 421 | milestoneUpdate (input: { 422 | id: "%s", 423 | issues: ["%s"] 424 | }) { 425 | milestone { 426 | name 427 | issues { 428 | edges { 429 | node { 430 | name 431 | } 432 | } 433 | } 434 | } 435 | } 436 | } 437 | """ 438 | % (m_id, issue_id), 439 | operation_name="milestoneUpdate", 440 | ) 441 | self.assertEqual( 442 | json.loads(r.content), 443 | { 444 | "data": { 445 | "milestoneUpdate": { 446 | "milestone": { 447 | "name": "release-A", 448 | "issues": { 449 | "edges": [ 450 | { 451 | "node": {"name": "Issue 1"}, 452 | } 453 | ] 454 | }, 455 | } 456 | } 457 | } 458 | }, 459 | ) 460 | 461 | # If we update this once again without "issues" them should not be touched 462 | r = self.query( 463 | """ 464 | mutation milestoneUpdate { 465 | milestoneUpdate (input: { 466 | id: "%s", 467 | }) { 468 | milestone { 469 | name 470 | issues { 471 | edges { 472 | node { 473 | name 474 | } 475 | } 476 | } 477 | } 478 | } 479 | } 480 | """ 481 | % (m_id,), 482 | operation_name="milestoneUpdate", 483 | ) 484 | self.assertEqual( 485 | json.loads(r.content), 486 | { 487 | "data": { 488 | "milestoneUpdate": { 489 | "milestone": { 490 | "name": "release-A", 491 | "issues": { 492 | "edges": [ 493 | { 494 | "node": {"name": "Issue 1"}, 495 | } 496 | ] 497 | }, 498 | } 499 | } 500 | } 501 | }, 502 | ) 503 | 504 | def test_remove_all_milestone_issues(self): 505 | """Test that all issues can be removed from a milestone.""" 506 | milestone = Milestone.objects.create(name="release-A", project=self.project) 507 | milestone.issues.set(self.issues) 508 | m_id = base64.b64encode( 509 | "MilestoneType:{}".format( 510 | milestone.id, 511 | ).encode() 512 | ).decode() 513 | 514 | r = self.query( 515 | """ 516 | mutation milestoneUpdate { 517 | milestoneUpdate (input: { 518 | id: "%s", 519 | issues: [], 520 | }) { 521 | milestone { 522 | name 523 | issues { 524 | edges { 525 | node { 526 | name 527 | } 528 | } 529 | } 530 | } 531 | } 532 | } 533 | """ 534 | % m_id, 535 | operation_name="milestoneUpdate", 536 | ) 537 | self.assertEqual( 538 | json.loads(r.content), 539 | { 540 | "data": { 541 | "milestoneUpdate": {"milestone": {"name": "release-A", "issues": {"edges": []}}} 542 | } 543 | }, 544 | ) 545 | 546 | 547 | class TestMutationRelatedObjectsWithOverrideSettings(BaseTestCase): 548 | """Tests for creating and updating reverse side of FK and M2M relationships.""" 549 | 550 | @override_settings(GRAPHENE_DJANGO_PLUS={"MUTATIONS_INCLUDE_REVERSE_RELATIONS": False}) 551 | def test_create_milestone_issues_turned_off_related_setting(self): 552 | """Test that a milestone can be created with a list of issues.""" 553 | 554 | milestone = "release_1A" 555 | self.assertFalse(Milestone.objects.filter(name=milestone).exists()) 556 | 557 | project_id = to_global_id(ProjectType.__name__, self.project.id) 558 | issue_id = to_global_id(IssueType.__name__, self.issues[0].id) 559 | 560 | # Creating schema inside test run so that settings would be already modified 561 | # since schema is generated on server start, not on execution 562 | class MilestoneCreateMutation(ModelCreateMutation): 563 | class Meta: 564 | model = Milestone 565 | 566 | class Query(graphene.ObjectType): 567 | milestone = relay.Node.Field(MilestoneType) 568 | 569 | class Mutation(graphene.ObjectType): 570 | milestone_create = MilestoneCreateMutation.Field() 571 | 572 | schema = graphene.Schema( 573 | query=Query, 574 | mutation=Mutation, 575 | ) 576 | query = """ 577 | mutation milestoneCreate {{ 578 | milestoneCreate (input: {{ 579 | name: "{}", 580 | project: "{}", 581 | issues: ["{}"] 582 | }}) {{ 583 | milestone {{ 584 | name 585 | issues {{ 586 | edges {{ 587 | node {{ 588 | name 589 | }} 590 | }} 591 | }} 592 | }} 593 | }} 594 | }} 595 | """.format( 596 | milestone, 597 | project_id, 598 | issue_id, 599 | ) 600 | result = schema.execute(query) 601 | print(result) 602 | self.assertFalse(Milestone.objects.filter(name=milestone).exists()) 603 | self.assertTrue("Field 'issues' is not defined" in result.errors[0].message) 604 | 605 | def test_create_milestone_issues_with_comments_without_related_name(self): 606 | comment = MilestoneComment.objects.create( 607 | text="Milestone Comment", 608 | milestone=self.milestone_1, 609 | ) 610 | 611 | milestone = "release_1A" 612 | self.assertFalse(Milestone.objects.filter(name=milestone).exists()) 613 | 614 | project_id = to_global_id(ProjectType.__name__, self.project.id) 615 | issue_id = to_global_id(IssueType.__name__, self.issues[0].id) 616 | comment_id = to_global_id(MilestoneCommentType.__name__, comment.id) 617 | 618 | r = self.query( 619 | """ 620 | mutation milestoneCreate { 621 | milestoneCreate (input: { 622 | name: "%s", 623 | project: "%s", 624 | issues: ["%s"], 625 | milestonecommentSet: ["%s"], 626 | }) { 627 | milestone { 628 | name 629 | issues { 630 | edges { 631 | node { 632 | name 633 | } 634 | } 635 | } 636 | milestonecommentSet { 637 | edges { 638 | node { 639 | text 640 | } 641 | } 642 | } 643 | } 644 | } 645 | } 646 | """ 647 | % (milestone, project_id, issue_id, comment_id), 648 | operation_name="milestoneCreate", 649 | ) 650 | self.assertTrue(Milestone.objects.filter(name=milestone).exists()) 651 | self.assertEqual( 652 | json.loads(r.content), 653 | { 654 | "data": { 655 | "milestoneCreate": { 656 | "milestone": { 657 | "name": "release_1A", 658 | "issues": { 659 | "edges": [ 660 | { 661 | "node": {"name": "Issue 1"}, 662 | } 663 | ] 664 | }, 665 | "milestonecommentSet": { 666 | "edges": [ 667 | { 668 | "node": {"text": "Milestone Comment"}, 669 | } 670 | ] 671 | }, 672 | } 673 | } 674 | } 675 | }, 676 | ) 677 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase, override_settings 2 | 3 | from graphene_django_plus.settings import graphene_django_plus_settings 4 | 5 | 6 | class TestSettings(TestCase): 7 | def test_compatibility_with_override_settings(self): 8 | self.assertTrue(graphene_django_plus_settings.MUTATIONS_INCLUDE_REVERSE_RELATIONS) 9 | 10 | with override_settings(GRAPHENE_DJANGO_PLUS={"MUTATIONS_INCLUDE_REVERSE_RELATIONS": False}): 11 | self.assertFalse( 12 | graphene_django_plus_settings.MUTATIONS_INCLUDE_REVERSE_RELATIONS 13 | ) # Setting should have been updated 14 | 15 | self.assertTrue( 16 | graphene_django_plus_settings.MUTATIONS_INCLUDE_REVERSE_RELATIONS 17 | ) # Setting should have been restored 18 | -------------------------------------------------------------------------------- /tests/test_types.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | from unittest import mock 4 | 5 | from graphene_django import DjangoObjectType 6 | from graphql_relay import to_global_id 7 | 8 | from .base import BaseTestCase 9 | from .schema import IssueType 10 | 11 | 12 | class TestTypes(BaseTestCase): 13 | def test_results(self): 14 | # projects 15 | r = self.query( 16 | """ 17 | query projects { 18 | projects { 19 | edges { 20 | node { 21 | name 22 | milestones { 23 | edges { 24 | node { 25 | name 26 | issues { 27 | edges { 28 | node { 29 | name 30 | } 31 | } 32 | } 33 | } 34 | } 35 | } 36 | } 37 | } 38 | } 39 | } 40 | """, 41 | operation_name="projects", 42 | ) 43 | self.assertEqual( 44 | json.loads(r.content), 45 | { 46 | "data": { 47 | "projects": { 48 | "edges": [ 49 | { 50 | "node": { 51 | "name": "Test Project", 52 | "milestones": { 53 | "edges": [ 54 | { 55 | "node": { 56 | "name": "Milestone 1", 57 | "issues": { 58 | "edges": [ 59 | {"node": {"name": "Issue 1"}}, 60 | {"node": {"name": "Issue 2"}}, 61 | ] 62 | }, 63 | } 64 | }, 65 | { 66 | "node": { 67 | "name": "Milestone 2", 68 | "issues": {"edges": []}, 69 | } 70 | }, 71 | ] 72 | }, 73 | } 74 | } 75 | ] 76 | } 77 | } 78 | }, 79 | ) 80 | 81 | # issues 82 | r = self.query( 83 | """ 84 | query issues { 85 | issues { 86 | edges { 87 | node { 88 | name 89 | milestone { 90 | name 91 | project { 92 | name 93 | } 94 | } 95 | } 96 | } 97 | } 98 | } 99 | """, 100 | operation_name="issues", 101 | ) 102 | self.assertEqual( 103 | json.loads(r.content), 104 | { 105 | "data": { 106 | "issues": { 107 | "edges": [ 108 | { 109 | "node": { 110 | "name": "Issue 1", 111 | "milestone": { 112 | "name": "Milestone 1", 113 | "project": {"name": "Test Project"}, 114 | }, 115 | } 116 | }, 117 | { 118 | "node": { 119 | "name": "Issue 2", 120 | "milestone": { 121 | "name": "Milestone 1", 122 | "project": {"name": "Test Project"}, 123 | }, 124 | } 125 | }, 126 | ] 127 | } 128 | } 129 | }, 130 | ) 131 | 132 | @mock.patch("graphene_django_plus.types.gql_optimizer", None) 133 | def test_results_no_gql_optimizer(self): 134 | # projects 135 | r = self.query( 136 | """ 137 | query projects { 138 | projects { 139 | edges { 140 | node { 141 | name 142 | milestones { 143 | edges { 144 | node { 145 | name 146 | issues { 147 | edges { 148 | node { 149 | name 150 | } 151 | } 152 | } 153 | } 154 | } 155 | } 156 | } 157 | } 158 | } 159 | } 160 | """, 161 | operation_name="projects", 162 | ) 163 | self.assertEqual( 164 | json.loads(r.content), 165 | { 166 | "data": { 167 | "projects": { 168 | "edges": [ 169 | { 170 | "node": { 171 | "name": "Test Project", 172 | "milestones": { 173 | "edges": [ 174 | { 175 | "node": { 176 | "name": "Milestone 1", 177 | "issues": { 178 | "edges": [ 179 | {"node": {"name": "Issue 1"}}, 180 | {"node": {"name": "Issue 2"}}, 181 | ] 182 | }, 183 | } 184 | }, 185 | { 186 | "node": { 187 | "name": "Milestone 2", 188 | "issues": {"edges": []}, 189 | } 190 | }, 191 | ] 192 | }, 193 | } 194 | } 195 | ] 196 | } 197 | } 198 | }, 199 | ) 200 | 201 | # issues 202 | r = self.query( 203 | """ 204 | query issues { 205 | issues { 206 | edges { 207 | node { 208 | name 209 | milestone { 210 | name 211 | project { 212 | name 213 | } 214 | } 215 | } 216 | } 217 | } 218 | } 219 | """, 220 | operation_name="issues", 221 | ) 222 | self.assertEqual( 223 | json.loads(r.content), 224 | { 225 | "data": { 226 | "issues": { 227 | "edges": [ 228 | { 229 | "node": { 230 | "name": "Issue 1", 231 | "milestone": { 232 | "name": "Milestone 1", 233 | "project": {"name": "Test Project"}, 234 | }, 235 | } 236 | }, 237 | { 238 | "node": { 239 | "name": "Issue 2", 240 | "milestone": { 241 | "name": "Milestone 1", 242 | "project": {"name": "Test Project"}, 243 | }, 244 | } 245 | }, 246 | ] 247 | } 248 | } 249 | }, 250 | ) 251 | 252 | def test_result(self): 253 | # project 254 | p_id = base64.b64encode( 255 | "ProjectType:{}".format( 256 | self.project.id, 257 | ).encode() 258 | ).decode() 259 | r = self.query( 260 | """ 261 | query project { 262 | project (id: "%s") { 263 | name 264 | } 265 | } 266 | """ 267 | % (p_id,), 268 | operation_name="project", 269 | ) 270 | self.assertEqual( 271 | json.loads(r.content), 272 | {"data": {"project": {"name": "Test Project"}}}, 273 | ) 274 | 275 | # issue (allowed) 276 | p_id = base64.b64encode( 277 | "IssueType:{}".format( 278 | self.allowed_issues[0].id, 279 | ).encode() 280 | ).decode() 281 | r = self.query( 282 | """ 283 | query issue { 284 | issue (id: "%s") { 285 | name 286 | } 287 | } 288 | """ 289 | % (p_id,), 290 | operation_name="issue", 291 | ) 292 | self.assertEqual( 293 | json.loads(r.content), 294 | {"data": {"issue": {"name": "Issue 1"}}}, 295 | ) 296 | 297 | # issue with more data (allowed) 298 | p_id = base64.b64encode( 299 | "IssueType:{}".format( 300 | self.allowed_issues[0].id, 301 | ).encode() 302 | ).decode() 303 | r = self.query( 304 | """ 305 | query issue { 306 | issue (id: "%s") { 307 | name 308 | milestone { 309 | name 310 | project { 311 | name 312 | } 313 | } 314 | } 315 | } 316 | """ 317 | % (p_id,), 318 | operation_name="issue", 319 | ) 320 | self.assertEqual( 321 | json.loads(r.content), 322 | { 323 | "data": { 324 | "issue": { 325 | "name": "Issue 1", 326 | "milestone": { 327 | "name": "Milestone 1", 328 | "project": { 329 | "name": "Test Project", 330 | }, 331 | }, 332 | } 333 | } 334 | }, 335 | ) 336 | 337 | # issue (not allowed) 338 | p_id = base64.b64encode( 339 | "IssueType:{}".format( 340 | self.unallowed_issues[0].id, 341 | ).encode() 342 | ).decode() 343 | r = self.query( 344 | """ 345 | query issue { 346 | issue (id: "%s") { 347 | name 348 | } 349 | } 350 | """ 351 | % (p_id,), 352 | operation_name="issue", 353 | ) 354 | self.assertEqual( 355 | json.loads(r.content), 356 | {"data": {"issue": None}}, 357 | ) 358 | 359 | @mock.patch("graphene_django_plus.types.gql_optimizer", None) 360 | def test_result_no_gql_optimizer(self): 361 | # project 362 | p_id = base64.b64encode( 363 | "ProjectType:{}".format( 364 | self.project.id, 365 | ).encode() 366 | ).decode() 367 | r = self.query( 368 | """ 369 | query project { 370 | project (id: "%s") { 371 | name 372 | } 373 | } 374 | """ 375 | % (p_id,), 376 | operation_name="project", 377 | ) 378 | self.assertEqual( 379 | json.loads(r.content), 380 | {"data": {"project": {"name": "Test Project"}}}, 381 | ) 382 | 383 | # issue (allowed) 384 | p_id = base64.b64encode( 385 | "IssueType:{}".format( 386 | self.allowed_issues[0].id, 387 | ).encode() 388 | ).decode() 389 | r = self.query( 390 | """ 391 | query issue { 392 | issue (id: "%s") { 393 | name 394 | } 395 | } 396 | """ 397 | % (p_id,), 398 | operation_name="issue", 399 | ) 400 | self.assertEqual( 401 | json.loads(r.content), 402 | {"data": {"issue": {"name": "Issue 1"}}}, 403 | ) 404 | 405 | # issue with more data (allowed) 406 | p_id = base64.b64encode( 407 | "IssueType:{}".format( 408 | self.allowed_issues[0].id, 409 | ).encode() 410 | ).decode() 411 | r = self.query( 412 | """ 413 | query issue { 414 | issue (id: "%s") { 415 | name 416 | milestone { 417 | name 418 | project { 419 | name 420 | } 421 | } 422 | } 423 | } 424 | """ 425 | % (p_id,), 426 | operation_name="issue", 427 | ) 428 | self.assertEqual( 429 | json.loads(r.content), 430 | { 431 | "data": { 432 | "issue": { 433 | "name": "Issue 1", 434 | "milestone": { 435 | "name": "Milestone 1", 436 | "project": { 437 | "name": "Test Project", 438 | }, 439 | }, 440 | } 441 | } 442 | }, 443 | ) 444 | 445 | with mock.patch.object( 446 | IssueType, 447 | "get_node", 448 | lambda info, id: DjangoObjectType.get_node.__func__(IssueType, info, id), 449 | ): 450 | # issue (not allowed) 451 | p_id = base64.b64encode( 452 | "IssueType:{}".format( 453 | self.unallowed_issues[0].id, 454 | ).encode() 455 | ).decode() 456 | r = self.query( 457 | """ 458 | query issue { 459 | issue (id: "%s") { 460 | name 461 | } 462 | } 463 | """ 464 | % (p_id,), 465 | operation_name="issue", 466 | ) 467 | self.assertEqual( 468 | json.loads(r.content), 469 | {"data": {"issue": None}}, 470 | ) 471 | 472 | def test_result_non_global_registry(self): 473 | """Test that query using non global registry is working and using correct model type.""" 474 | # project 475 | p_id = to_global_id("ProjectNameOnlyType", self.project.id) 476 | r = self.query( 477 | """ 478 | query project { 479 | projectNameOnly (id: "%s") { 480 | name 481 | } 482 | } 483 | """ 484 | % (p_id,), 485 | ) 486 | self.assertEqual( 487 | json.loads(r.content), 488 | {"data": {"projectNameOnly": {"name": "Test Project"}}}, 489 | ) 490 | 491 | r = self.query( 492 | """ 493 | query project { 494 | projectNameOnly (id: "%s") { 495 | cost 496 | } 497 | } 498 | """ 499 | % (p_id,), 500 | ) 501 | self.assertEqual( 502 | json.loads(r.content)["errors"][0]["message"], 503 | "Cannot query field 'cost' on type 'ProjectNameOnlyType'.", 504 | ) 505 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import base64 2 | 3 | from graphene_django.registry import Registry 4 | from graphql.error import GraphQLError 5 | 6 | from graphene_django_plus.utils import get_nodes 7 | 8 | from .base import BaseTestCase 9 | from .schema import IssueType, ProjectType 10 | 11 | 12 | class TestTypes(BaseTestCase): 13 | def test_get_nodes(self): 14 | info = object() 15 | 16 | issues = [ 17 | base64.b64encode( 18 | "IssueType:{}".format( 19 | issue.id, 20 | ).encode() 21 | ).decode() 22 | for issue in self.issues 23 | ] 24 | self.assertEqual( 25 | set(get_nodes(info, issues)), 26 | set(self.issues), 27 | ) 28 | self.assertEqual( 29 | set(get_nodes(info, issues, IssueType)), 30 | set(self.issues), 31 | ) 32 | with self.assertRaises(AssertionError): 33 | get_nodes(info, issues, ProjectType) 34 | 35 | with self.assertRaises(AssertionError): 36 | get_nodes(info, issues, registry=Registry()) 37 | 38 | issues_with_wrong_id = issues[:] 39 | issues_with_wrong_id.append(base64.b64encode(b"IssueType:9999").decode()) 40 | with self.assertRaises(GraphQLError): 41 | get_nodes(info, issues_with_wrong_id) 42 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from graphene_django_plus.views import GraphQLView 4 | 5 | from .schema import schema 6 | 7 | urlpatterns = [ 8 | path(r"graphql", GraphQLView.as_view(graphiql=True, schema=schema)), 9 | ] 10 | --------------------------------------------------------------------------------