mirror of
https://github.com/graphql-python/graphene-django.git
synced 2025-07-10 16:22:20 +03:00
Merge branch 'main' into proxy-model-supports-reverse-relationship
This commit is contained in:
commit
5b5fca47f6
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
|
@ -20,7 +20,7 @@ jobs:
|
||||||
pip install wheel
|
pip install wheel
|
||||||
python setup.py sdist bdist_wheel
|
python setup.py sdist bdist_wheel
|
||||||
- name: Publish a Python distribution to PyPI
|
- name: Publish a Python distribution to PyPI
|
||||||
uses: pypa/gh-action-pypi-publish@v1.8.6
|
uses: pypa/gh-action-pypi-publish@release/v1
|
||||||
with:
|
with:
|
||||||
user: __token__
|
user: __token__
|
||||||
password: ${{ secrets.pypi_password }}
|
password: ${{ secrets.pypi_password }}
|
||||||
|
|
4
.github/workflows/tests.yml
vendored
4
.github/workflows/tests.yml
vendored
|
@ -8,13 +8,15 @@ jobs:
|
||||||
strategy:
|
strategy:
|
||||||
max-parallel: 4
|
max-parallel: 4
|
||||||
matrix:
|
matrix:
|
||||||
django: ["3.2", "4.0", "4.1"]
|
django: ["3.2", "4.1", "4.2"]
|
||||||
python-version: ["3.8", "3.9", "3.10"]
|
python-version: ["3.8", "3.9", "3.10"]
|
||||||
include:
|
include:
|
||||||
- django: "3.2"
|
- django: "3.2"
|
||||||
python-version: "3.7"
|
python-version: "3.7"
|
||||||
- django: "4.1"
|
- django: "4.1"
|
||||||
python-version: "3.11"
|
python-version: "3.11"
|
||||||
|
- django: "4.2"
|
||||||
|
python-version: "3.11"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
|
|
8
.gitignore
vendored
8
.gitignore
vendored
|
@ -11,6 +11,9 @@ __pycache__/
|
||||||
# Distribution / packaging
|
# Distribution / packaging
|
||||||
.Python
|
.Python
|
||||||
env/
|
env/
|
||||||
|
.env/
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
build/
|
build/
|
||||||
develop-eggs/
|
develop-eggs/
|
||||||
dist/
|
dist/
|
||||||
|
@ -80,3 +83,8 @@ Session.vim
|
||||||
tags
|
tags
|
||||||
.tox/
|
.tox/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
|
.python-version
|
||||||
|
|
|
@ -144,6 +144,21 @@ If you are using ``DjangoObjectType`` you can define a custom `get_queryset`.
|
||||||
return queryset.filter(published=True)
|
return queryset.filter(published=True)
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
.. warning::
|
||||||
|
|
||||||
|
Defining a custom ``get_queryset`` gives the guaranteed it will be called
|
||||||
|
when resolving the ``DjangoObjectType``, even through related objects.
|
||||||
|
Note that because of this, benefits from using ``select_related``
|
||||||
|
in objects that define a relation to this ``DjangoObjectType`` will be canceled out.
|
||||||
|
In the case of ``prefetch_related``, the benefits of the optimization will be lost only
|
||||||
|
if the custom ``get_queryset`` modifies the queryset. For more information about this, refers
|
||||||
|
to Django documentation about ``prefetch_related``: https://docs.djangoproject.com/en/4.2/ref/models/querysets/#prefetch-related.
|
||||||
|
|
||||||
|
|
||||||
|
If you want to explicitly disable the execution of the custom ``get_queryset`` when resolving,
|
||||||
|
you can decorate the resolver with `@graphene_django.bypass_get_queryset`. Note that this
|
||||||
|
can lead to authorization leaks if you are performing authorization checks in the custom
|
||||||
|
``get_queryset``.
|
||||||
|
|
||||||
Filtering ID-based Node Access
|
Filtering ID-based Node Access
|
||||||
------------------------------
|
------------------------------
|
||||||
|
@ -197,8 +212,8 @@ For Django 2.2 and above:
|
||||||
.. code:: python
|
.. code:: python
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# some other urls
|
# some other urls
|
||||||
path('graphql/', PrivateGraphQLView.as_view(graphiql=True, schema=schema)),
|
path('graphql/', PrivateGraphQLView.as_view(graphiql=True, schema=schema)),
|
||||||
]
|
]
|
||||||
|
|
||||||
.. _LoginRequiredMixin: https://docs.djangoproject.com/en/dev/topics/auth/default/#the-loginrequired-mixin
|
.. _LoginRequiredMixin: https://docs.djangoproject.com/en/dev/topics/auth/default/#the-loginrequired-mixin
|
||||||
|
|
|
@ -78,7 +78,7 @@ release = "1.0.dev"
|
||||||
#
|
#
|
||||||
# This is also used if you do content translation via gettext catalogs.
|
# This is also used if you do content translation via gettext catalogs.
|
||||||
# Usually you set "language" from the command line for these cases.
|
# Usually you set "language" from the command line for these cases.
|
||||||
language = None
|
# language = None
|
||||||
|
|
||||||
# There are two options for replacing |today|: either, you set today to some
|
# There are two options for replacing |today|: either, you set today to some
|
||||||
# non-false value, then it is used:
|
# non-false value, then it is used:
|
||||||
|
@ -445,4 +445,7 @@ epub_exclude_files = ["search.html"]
|
||||||
|
|
||||||
|
|
||||||
# Example configuration for intersphinx: refer to the Python standard library.
|
# Example configuration for intersphinx: refer to the Python standard library.
|
||||||
intersphinx_mapping = {"https://docs.python.org/": None}
|
intersphinx_mapping = {
|
||||||
|
# "https://docs.python.org/": None,
|
||||||
|
"python": ("https://docs.python.org/", None),
|
||||||
|
}
|
||||||
|
|
|
@ -57,9 +57,9 @@ specify the parameters in your settings.py:
|
||||||
.. code:: python
|
.. code:: python
|
||||||
|
|
||||||
GRAPHENE = {
|
GRAPHENE = {
|
||||||
'SCHEMA': 'tutorial.quickstart.schema',
|
'SCHEMA': 'tutorial.quickstart.schema',
|
||||||
'SCHEMA_OUTPUT': 'data/schema.json', # defaults to schema.json,
|
'SCHEMA_OUTPUT': 'data/schema.json', # defaults to schema.json,
|
||||||
'SCHEMA_INDENT': 2, # Defaults to None (displays all data on a single line)
|
'SCHEMA_INDENT': 2, # Defaults to None (displays all data on a single line)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -125,6 +125,55 @@ to change how the form is saved or to return a different Graphene object type.
|
||||||
If the form is *not* valid then a list of errors will be returned. These errors have two fields: ``field``, a string
|
If the form is *not* valid then a list of errors will be returned. These errors have two fields: ``field``, a string
|
||||||
containing the name of the invalid form field, and ``messages``, a list of strings with the validation messages.
|
containing the name of the invalid form field, and ``messages``, a list of strings with the validation messages.
|
||||||
|
|
||||||
|
DjangoFormInputObjectType
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
``DjangoFormInputObjectType`` is used in mutations to create input fields by **using django form** to retrieve input data structure from it. This can be helpful in situations where you need to pass data to several django forms in one mutation.
|
||||||
|
|
||||||
|
.. code:: python
|
||||||
|
|
||||||
|
from graphene_django.forms.types import DjangoFormInputObjectType
|
||||||
|
|
||||||
|
|
||||||
|
class PetFormInput(DjangoFormInputObjectType):
|
||||||
|
# any other fields can be placed here as well as
|
||||||
|
# other djangoforminputobjects and intputobjects
|
||||||
|
class Meta:
|
||||||
|
form_class = PetForm
|
||||||
|
object_type = PetType
|
||||||
|
|
||||||
|
class QuestionFormInput(DjangoFormInputObjectType)
|
||||||
|
class Meta:
|
||||||
|
form_class = QuestionForm
|
||||||
|
object_type = QuestionType
|
||||||
|
|
||||||
|
class SeveralFormsInputData(graphene.InputObjectType):
|
||||||
|
pet = PetFormInput(required=True)
|
||||||
|
question = QuestionFormInput(required=True)
|
||||||
|
|
||||||
|
class SomeSophisticatedMutation(graphene.Mutation):
|
||||||
|
class Arguments:
|
||||||
|
data = SeveralFormsInputData(required=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def mutate(_root, _info, data):
|
||||||
|
pet_form_inst = PetForm(data=data.pet)
|
||||||
|
question_form_inst = QuestionForm(data=data.question)
|
||||||
|
|
||||||
|
if pet_form_inst.is_valid():
|
||||||
|
pet_model_instance = pet_form_inst.save(commit=False)
|
||||||
|
|
||||||
|
if question_form_inst.is_valid():
|
||||||
|
question_model_instance = question_form_inst.save(commit=False)
|
||||||
|
|
||||||
|
# ...
|
||||||
|
|
||||||
|
Additional to **InputObjectType** ``Meta`` class attributes:
|
||||||
|
|
||||||
|
* ``form_class`` is required and should be equal to django form class.
|
||||||
|
* ``object_type`` is not required and used to enable convertion of enum values back to original if model object type ``convert_choices_to_enum`` ``Meta`` class attribute is not set to ``False``. Any data field, which have choices in django, with value ``A_1`` (for example) from client will be automatically converted to ``1`` in mutation data.
|
||||||
|
* ``add_id_field_name`` is used to specify `id` field name (not required, by default equal to ``id``)
|
||||||
|
* ``add_id_field_type`` is used to specify `id` field type (not required, default is ``graphene.ID``)
|
||||||
|
|
||||||
Django REST Framework
|
Django REST Framework
|
||||||
---------------------
|
---------------------
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
Sphinx==1.5.3
|
Sphinx==7.0.0
|
||||||
sphinx-autobuild==0.7.1
|
sphinx-autobuild==2021.3.14
|
||||||
|
pygments-graphql-lexer==0.1.0
|
||||||
# Docs template
|
# Docs template
|
||||||
http://graphene-python.org/sphinx_graphene_theme.zip
|
http://graphene-python.org/sphinx_graphene_theme.zip
|
||||||
|
|
|
@ -224,7 +224,7 @@ Default: ``/graphql``
|
||||||
|
|
||||||
|
|
||||||
``GRAPHIQL_SHOULD_PERSIST_HEADERS``
|
``GRAPHIQL_SHOULD_PERSIST_HEADERS``
|
||||||
---------------------
|
-----------------------------------
|
||||||
|
|
||||||
Set to ``True`` if you want to persist GraphiQL headers after refreshing the page.
|
Set to ``True`` if you want to persist GraphiQL headers after refreshing the page.
|
||||||
|
|
||||||
|
|
|
@ -12,7 +12,7 @@ app <https://github.com/graphql-python/graphene-django/tree/master/examples/cook
|
||||||
A good idea is to check the following things first:
|
A good idea is to check the following things first:
|
||||||
|
|
||||||
* `Graphene Relay documentation <http://docs.graphene-python.org/en/latest/relay/>`__
|
* `Graphene Relay documentation <http://docs.graphene-python.org/en/latest/relay/>`__
|
||||||
* `GraphQL Relay Specification <https://facebook.github.io/relay/docs/en/graphql-server-specification.html>`__
|
* `GraphQL Relay Specification <https://relay.dev/docs/guides/graphql-server-specification/>`__
|
||||||
|
|
||||||
Setup the Django project
|
Setup the Django project
|
||||||
------------------------
|
------------------------
|
||||||
|
|
|
@ -62,3 +62,12 @@ Now head on over to
|
||||||
and run some queries!
|
and run some queries!
|
||||||
(See the [Graphene-Django Tutorial](http://docs.graphene-python.org/projects/django/en/latest/tutorial-plain/#testing-our-graphql-schema)
|
(See the [Graphene-Django Tutorial](http://docs.graphene-python.org/projects/django/en/latest/tutorial-plain/#testing-our-graphql-schema)
|
||||||
for some example queries)
|
for some example queries)
|
||||||
|
|
||||||
|
Testing local graphene-django changes
|
||||||
|
-------------------------------------
|
||||||
|
|
||||||
|
In `requirements.txt`, replace the entire `graphene-django=...` line with the following (so that we install the local version instead of the one from PyPI):
|
||||||
|
|
||||||
|
```
|
||||||
|
../../ # graphene-django
|
||||||
|
```
|
||||||
|
|
|
@ -5,10 +5,10 @@ Django settings for cookbook project.
|
||||||
Generated by 'django-admin startproject' using Django 1.9.
|
Generated by 'django-admin startproject' using Django 1.9.
|
||||||
|
|
||||||
For more information on this file, see
|
For more information on this file, see
|
||||||
https://docs.djangoproject.com/en/1.9/topics/settings/
|
https://docs.djangoproject.com/en/3.2/topics/settings/
|
||||||
|
|
||||||
For the full list of settings and their values, see
|
For the full list of settings and their values, see
|
||||||
https://docs.djangoproject.com/en/1.9/ref/settings/
|
https://docs.djangoproject.com/en/3.2/ref/settings/
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
@ -18,7 +18,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
|
||||||
# Quick-start development settings - unsuitable for production
|
# Quick-start development settings - unsuitable for production
|
||||||
# See https://docs.djangoproject.com/en/1.9/howto/deployment/checklist/
|
# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
|
||||||
|
|
||||||
# SECURITY WARNING: keep the secret key used in production secret!
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
SECRET_KEY = "_$=$%eqxk$8ss4n7mtgarw^5$8^d5+c83!vwatr@i_81myb=e4"
|
SECRET_KEY = "_$=$%eqxk$8ss4n7mtgarw^5$8^d5+c83!vwatr@i_81myb=e4"
|
||||||
|
@ -81,7 +81,7 @@ WSGI_APPLICATION = "cookbook.wsgi.application"
|
||||||
|
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
# https://docs.djangoproject.com/en/1.9/ref/settings/#databases
|
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
|
||||||
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
"default": {
|
"default": {
|
||||||
|
@ -90,9 +90,11 @@ DATABASES = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field
|
||||||
|
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||||
|
|
||||||
# Password validation
|
# Password validation
|
||||||
# https://docs.djangoproject.com/en/1.9/ref/settings/#auth-password-validators
|
# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators
|
||||||
|
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
{
|
{
|
||||||
|
@ -105,7 +107,7 @@ AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
|
||||||
|
|
||||||
# Internationalization
|
# Internationalization
|
||||||
# https://docs.djangoproject.com/en/1.9/topics/i18n/
|
# https://docs.djangoproject.com/en/3.2/topics/i18n/
|
||||||
|
|
||||||
LANGUAGE_CODE = "en-us"
|
LANGUAGE_CODE = "en-us"
|
||||||
|
|
||||||
|
@ -119,6 +121,6 @@ USE_TZ = True
|
||||||
|
|
||||||
|
|
||||||
# Static files (CSS, JavaScript, Images)
|
# Static files (CSS, JavaScript, Images)
|
||||||
# https://docs.djangoproject.com/en/1.9/howto/static-files/
|
# https://docs.djangoproject.com/en/3.2/howto/static-files/
|
||||||
|
|
||||||
STATIC_URL = "/static/"
|
STATIC_URL = "/static/"
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
graphene>=2.1,<3
|
django~=3.2
|
||||||
graphene-django>=2.1,<3
|
graphene
|
||||||
graphql-core>=2.1,<3
|
graphene-django>=3.1
|
||||||
django==3.1.14
|
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
from .fields import DjangoConnectionField, DjangoListField
|
from .fields import DjangoConnectionField, DjangoListField
|
||||||
from .types import DjangoObjectType
|
from .types import DjangoObjectType
|
||||||
|
from .utils import bypass_get_queryset
|
||||||
|
|
||||||
__version__ = "3.0.2"
|
__version__ = "3.1.3"
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"__version__",
|
"__version__",
|
||||||
"DjangoObjectType",
|
"DjangoObjectType",
|
||||||
"DjangoListField",
|
"DjangoListField",
|
||||||
"DjangoConnectionField",
|
"DjangoConnectionField",
|
||||||
|
"bypass_get_queryset",
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,3 +1,9 @@
|
||||||
|
# For backwards compatibility, we import JSONField to have it available for import via
|
||||||
|
# this compat module (https://github.com/graphql-python/graphene-django/issues/1428).
|
||||||
|
# Django's JSONField is available in Django 3.2+ (the minimum version we support)
|
||||||
|
from django.db.models import JSONField
|
||||||
|
|
||||||
|
|
||||||
class MissingType:
|
class MissingType:
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
pass
|
pass
|
||||||
|
@ -10,16 +16,7 @@ try:
|
||||||
IntegerRangeField,
|
IntegerRangeField,
|
||||||
ArrayField,
|
ArrayField,
|
||||||
HStoreField,
|
HStoreField,
|
||||||
JSONField as PGJSONField,
|
|
||||||
RangeField,
|
RangeField,
|
||||||
)
|
)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
IntegerRangeField, ArrayField, HStoreField, PGJSONField, RangeField = (
|
IntegerRangeField, ArrayField, HStoreField, RangeField = (MissingType,) * 4
|
||||||
MissingType,
|
|
||||||
) * 5
|
|
||||||
|
|
||||||
try:
|
|
||||||
# JSONField is only available from Django 3.1
|
|
||||||
from django.db.models import JSONField
|
|
||||||
except ImportError:
|
|
||||||
JSONField = MissingType
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
|
import inspect
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from functools import singledispatch, wraps
|
from functools import partial, singledispatch, wraps
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.encoding import force_str
|
from django.utils.encoding import force_str
|
||||||
|
@ -25,6 +26,7 @@ from graphene import (
|
||||||
)
|
)
|
||||||
from graphene.types.json import JSONString
|
from graphene.types.json import JSONString
|
||||||
from graphene.types.scalars import BigInt
|
from graphene.types.scalars import BigInt
|
||||||
|
from graphene.types.resolver import get_default_resolver
|
||||||
from graphene.utils.str_converters import to_camel_case
|
from graphene.utils.str_converters import to_camel_case
|
||||||
from graphql import GraphQLError
|
from graphql import GraphQLError
|
||||||
|
|
||||||
|
@ -35,7 +37,7 @@ except ImportError:
|
||||||
from graphql import assert_valid_name as assert_name
|
from graphql import assert_valid_name as assert_name
|
||||||
from graphql.pyutils import register_description
|
from graphql.pyutils import register_description
|
||||||
|
|
||||||
from .compat import ArrayField, HStoreField, JSONField, PGJSONField, RangeField
|
from .compat import ArrayField, HStoreField, RangeField
|
||||||
from .fields import DjangoListField, DjangoConnectionField
|
from .fields import DjangoListField, DjangoConnectionField
|
||||||
from .settings import graphene_settings
|
from .settings import graphene_settings
|
||||||
from .utils.str_converters import to_const
|
from .utils.str_converters import to_const
|
||||||
|
@ -258,6 +260,9 @@ def convert_time_to_string(field, registry=None):
|
||||||
|
|
||||||
@convert_django_field.register(models.OneToOneRel)
|
@convert_django_field.register(models.OneToOneRel)
|
||||||
def convert_onetoone_field_to_djangomodel(field, registry=None):
|
def convert_onetoone_field_to_djangomodel(field, registry=None):
|
||||||
|
from graphene.utils.str_converters import to_snake_case
|
||||||
|
from .types import DjangoObjectType
|
||||||
|
|
||||||
model = field.related_model
|
model = field.related_model
|
||||||
|
|
||||||
def dynamic_type():
|
def dynamic_type():
|
||||||
|
@ -265,7 +270,52 @@ def convert_onetoone_field_to_djangomodel(field, registry=None):
|
||||||
if not _type:
|
if not _type:
|
||||||
return
|
return
|
||||||
|
|
||||||
return Field(_type, required=not field.null)
|
class CustomField(Field):
|
||||||
|
def wrap_resolve(self, parent_resolver):
|
||||||
|
"""
|
||||||
|
Implements a custom resolver which goes through the `get_node` method to ensure that
|
||||||
|
it goes through the `get_queryset` method of the DjangoObjectType.
|
||||||
|
"""
|
||||||
|
resolver = super().wrap_resolve(parent_resolver)
|
||||||
|
|
||||||
|
# If `get_queryset` was not overridden in the DjangoObjectType
|
||||||
|
# or if we explicitly bypass the `get_queryset` method,
|
||||||
|
# we can just return the default resolver.
|
||||||
|
if (
|
||||||
|
_type.get_queryset.__func__
|
||||||
|
is DjangoObjectType.get_queryset.__func__
|
||||||
|
or getattr(resolver, "_bypass_get_queryset", False)
|
||||||
|
):
|
||||||
|
return resolver
|
||||||
|
|
||||||
|
def custom_resolver(root, info, **args):
|
||||||
|
# Note: this function is used to resolve 1:1 relation fields
|
||||||
|
|
||||||
|
is_resolver_awaitable = inspect.iscoroutinefunction(resolver)
|
||||||
|
|
||||||
|
if is_resolver_awaitable:
|
||||||
|
fk_obj = resolver(root, info, **args)
|
||||||
|
# In case the resolver is a custom awaitable resolver that overwrites
|
||||||
|
# the default Django resolver
|
||||||
|
return fk_obj
|
||||||
|
|
||||||
|
field_name = to_snake_case(info.field_name)
|
||||||
|
reversed_field_name = root.__class__._meta.get_field(
|
||||||
|
field_name
|
||||||
|
).remote_field.name
|
||||||
|
return _type.get_queryset(
|
||||||
|
_type._meta.model.objects.filter(
|
||||||
|
**{reversed_field_name: root.pk}
|
||||||
|
),
|
||||||
|
info,
|
||||||
|
).get()
|
||||||
|
|
||||||
|
return custom_resolver
|
||||||
|
|
||||||
|
return CustomField(
|
||||||
|
_type,
|
||||||
|
required=not field.null,
|
||||||
|
)
|
||||||
|
|
||||||
return Dynamic(dynamic_type)
|
return Dynamic(dynamic_type)
|
||||||
|
|
||||||
|
@ -313,6 +363,9 @@ def convert_field_to_list_or_connection(field, registry=None):
|
||||||
@convert_django_field.register(models.OneToOneField)
|
@convert_django_field.register(models.OneToOneField)
|
||||||
@convert_django_field.register(models.ForeignKey)
|
@convert_django_field.register(models.ForeignKey)
|
||||||
def convert_field_to_djangomodel(field, registry=None):
|
def convert_field_to_djangomodel(field, registry=None):
|
||||||
|
from graphene.utils.str_converters import to_snake_case
|
||||||
|
from .types import DjangoObjectType
|
||||||
|
|
||||||
model = field.related_model
|
model = field.related_model
|
||||||
|
|
||||||
def dynamic_type():
|
def dynamic_type():
|
||||||
|
@ -320,7 +373,79 @@ def convert_field_to_djangomodel(field, registry=None):
|
||||||
if not _type:
|
if not _type:
|
||||||
return
|
return
|
||||||
|
|
||||||
return Field(
|
class CustomField(Field):
|
||||||
|
def wrap_resolve(self, parent_resolver):
|
||||||
|
"""
|
||||||
|
Implements a custom resolver which goes through the `get_node` method to ensure that
|
||||||
|
it goes through the `get_queryset` method of the DjangoObjectType.
|
||||||
|
"""
|
||||||
|
resolver = super().wrap_resolve(parent_resolver)
|
||||||
|
|
||||||
|
# If `get_queryset` was not overridden in the DjangoObjectType
|
||||||
|
# or if we explicitly bypass the `get_queryset` method,
|
||||||
|
# we can just return the default resolver.
|
||||||
|
if (
|
||||||
|
_type.get_queryset.__func__
|
||||||
|
is DjangoObjectType.get_queryset.__func__
|
||||||
|
or getattr(resolver, "_bypass_get_queryset", False)
|
||||||
|
):
|
||||||
|
return resolver
|
||||||
|
|
||||||
|
def custom_resolver(root, info, **args):
|
||||||
|
# Note: this function is used to resolve FK or 1:1 fields
|
||||||
|
# it does not differentiate between custom-resolved fields
|
||||||
|
# and default resolved fields.
|
||||||
|
|
||||||
|
# because this is a django foreign key or one-to-one field, the primary-key for
|
||||||
|
# this node can be accessed from the root node.
|
||||||
|
# ex: article.reporter_id
|
||||||
|
|
||||||
|
# get the name of the id field from the root's model
|
||||||
|
field_name = to_snake_case(info.field_name)
|
||||||
|
db_field_key = root.__class__._meta.get_field(field_name).attname
|
||||||
|
if hasattr(root, db_field_key):
|
||||||
|
# get the object's primary-key from root
|
||||||
|
object_pk = getattr(root, db_field_key)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
is_resolver_awaitable = inspect.iscoroutinefunction(resolver)
|
||||||
|
|
||||||
|
if is_resolver_awaitable:
|
||||||
|
fk_obj = resolver(root, info, **args)
|
||||||
|
# In case the resolver is a custom awaitable resolver that overwrites
|
||||||
|
# the default Django resolver
|
||||||
|
return fk_obj
|
||||||
|
|
||||||
|
instance_from_get_node = _type.get_node(info, object_pk)
|
||||||
|
|
||||||
|
if instance_from_get_node is None:
|
||||||
|
# no instance to return
|
||||||
|
return
|
||||||
|
elif (
|
||||||
|
isinstance(resolver, partial)
|
||||||
|
and resolver.func is get_default_resolver()
|
||||||
|
):
|
||||||
|
return instance_from_get_node
|
||||||
|
elif resolver is not get_default_resolver():
|
||||||
|
# Default resolver is overridden
|
||||||
|
# For optimization, add the instance to the resolver
|
||||||
|
setattr(root, field_name, instance_from_get_node)
|
||||||
|
# Explanation:
|
||||||
|
# previously, _type.get_node` is called which results in at least one hit to the database.
|
||||||
|
# But, if we did not pass the instance to the root, calling the resolver will result in
|
||||||
|
# another call to get the instance which results in at least two database queries in total
|
||||||
|
# to resolve this node only.
|
||||||
|
# That's why the value of the object is set in the root so when the object is accessed
|
||||||
|
# in the resolver (root.field_name) it does not access the database unless queried explicitly.
|
||||||
|
fk_obj = resolver(root, info, **args)
|
||||||
|
return fk_obj
|
||||||
|
else:
|
||||||
|
return instance_from_get_node
|
||||||
|
|
||||||
|
return custom_resolver
|
||||||
|
|
||||||
|
return CustomField(
|
||||||
_type,
|
_type,
|
||||||
description=get_django_field_description(field),
|
description=get_django_field_description(field),
|
||||||
required=not field.null,
|
required=not field.null,
|
||||||
|
@ -346,9 +471,8 @@ def convert_postgres_array_to_list(field, registry=None):
|
||||||
|
|
||||||
|
|
||||||
@convert_django_field.register(HStoreField)
|
@convert_django_field.register(HStoreField)
|
||||||
@convert_django_field.register(PGJSONField)
|
@convert_django_field.register(models.JSONField)
|
||||||
@convert_django_field.register(JSONField)
|
def convert_json_field_to_string(field, registry=None):
|
||||||
def convert_pg_and_json_field_to_string(field, registry=None):
|
|
||||||
return JSONString(
|
return JSONString(
|
||||||
description=get_django_field_description(field), required=not field.null
|
description=get_django_field_description(field), required=not field.null
|
||||||
)
|
)
|
||||||
|
|
333
graphene_django/forms/tests/test_djangoinputobject.py
Normal file
333
graphene_django/forms/tests/test_djangoinputobject.py
Normal file
|
@ -0,0 +1,333 @@
|
||||||
|
import graphene
|
||||||
|
|
||||||
|
from django import forms
|
||||||
|
from pytest import raises
|
||||||
|
|
||||||
|
from graphene_django import DjangoObjectType
|
||||||
|
from ..types import DjangoFormInputObjectType
|
||||||
|
from ...tests.models import Reporter, Film, CHOICES
|
||||||
|
|
||||||
|
# Reporter a_choice CHOICES = ((1, "this"), (2, _("that")))
|
||||||
|
THIS = CHOICES[0][0]
|
||||||
|
THIS_ON_CLIENT_CONVERTED = "A_1"
|
||||||
|
|
||||||
|
# Film genre choices=[("do", "Documentary"), ("ac", "Action"), ("ot", "Other")],
|
||||||
|
DOCUMENTARY = "do"
|
||||||
|
DOCUMENTARY_ON_CLIENT_CONVERTED = "DO"
|
||||||
|
|
||||||
|
|
||||||
|
class FilmForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Film
|
||||||
|
exclude = ()
|
||||||
|
|
||||||
|
|
||||||
|
class ReporterType(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = Reporter
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
|
class ReporterForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Reporter
|
||||||
|
exclude = ("pets", "email")
|
||||||
|
|
||||||
|
|
||||||
|
class MyForm(forms.Form):
|
||||||
|
text_field = forms.CharField()
|
||||||
|
int_field = forms.IntegerField()
|
||||||
|
|
||||||
|
|
||||||
|
def test_needs_form_class():
|
||||||
|
with raises(Exception) as exc:
|
||||||
|
|
||||||
|
class MyInputType(DjangoFormInputObjectType):
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert exc.value.args[0] == "form_class is required for DjangoFormInputObjectType"
|
||||||
|
|
||||||
|
|
||||||
|
def test_type_from_modelform_has_input_fields():
|
||||||
|
class ReporterInputType(DjangoFormInputObjectType):
|
||||||
|
class Meta:
|
||||||
|
form_class = ReporterForm
|
||||||
|
only_fields = ("first_name", "last_name", "a_choice")
|
||||||
|
|
||||||
|
fields = ["first_name", "last_name", "a_choice", "id"]
|
||||||
|
assert all(f in ReporterInputType._meta.fields for f in fields)
|
||||||
|
|
||||||
|
|
||||||
|
def test_type_from_form_has_input_fields():
|
||||||
|
class MyFormInputType(DjangoFormInputObjectType):
|
||||||
|
class Meta:
|
||||||
|
form_class = MyForm
|
||||||
|
|
||||||
|
fields = ["text_field", "int_field", "id"]
|
||||||
|
assert all(f in MyFormInputType._meta.fields for f in fields)
|
||||||
|
|
||||||
|
|
||||||
|
def test_type_custom_id_field():
|
||||||
|
class MyFormInputType(DjangoFormInputObjectType):
|
||||||
|
class Meta:
|
||||||
|
form_class = MyForm
|
||||||
|
add_id_field_name = "pk"
|
||||||
|
|
||||||
|
fields = ["text_field", "int_field", "pk"]
|
||||||
|
assert all(f in MyFormInputType._meta.fields for f in fields)
|
||||||
|
assert MyFormInputType._meta.fields["pk"].type is graphene.ID
|
||||||
|
|
||||||
|
|
||||||
|
def test_type_custom_id_field_type():
|
||||||
|
class MyFormInputType(DjangoFormInputObjectType):
|
||||||
|
class Meta:
|
||||||
|
form_class = MyForm
|
||||||
|
add_id_field_name = "pk"
|
||||||
|
add_id_field_type = graphene.String(required=False)
|
||||||
|
|
||||||
|
fields = ["text_field", "int_field", "pk"]
|
||||||
|
assert all(f in MyFormInputType._meta.fields for f in fields)
|
||||||
|
assert MyFormInputType._meta.fields["pk"].type is graphene.String
|
||||||
|
|
||||||
|
|
||||||
|
class MockQuery(graphene.ObjectType):
|
||||||
|
a = graphene.String()
|
||||||
|
|
||||||
|
|
||||||
|
def test_mutation_with_form_djangoforminputtype():
|
||||||
|
class MyFormInputType(DjangoFormInputObjectType):
|
||||||
|
class Meta:
|
||||||
|
form_class = MyForm
|
||||||
|
|
||||||
|
class MyFormMutation(graphene.Mutation):
|
||||||
|
class Arguments:
|
||||||
|
form_data = MyFormInputType(required=True)
|
||||||
|
|
||||||
|
result = graphene.Boolean()
|
||||||
|
|
||||||
|
def mutate(_root, _info, form_data):
|
||||||
|
form = MyForm(data=form_data)
|
||||||
|
if form.is_valid():
|
||||||
|
result = form.cleaned_data == {
|
||||||
|
"text_field": "text",
|
||||||
|
"int_field": 777,
|
||||||
|
}
|
||||||
|
return MyFormMutation(result=result)
|
||||||
|
return MyFormMutation(result=False)
|
||||||
|
|
||||||
|
class Mutation(graphene.ObjectType):
|
||||||
|
myForm_mutation = MyFormMutation.Field()
|
||||||
|
|
||||||
|
schema = graphene.Schema(query=MockQuery, mutation=Mutation)
|
||||||
|
|
||||||
|
result = schema.execute(
|
||||||
|
""" mutation MyFormMutation($formData: MyFormInputType!) {
|
||||||
|
myFormMutation(formData: $formData) {
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
variable_values={"formData": {"textField": "text", "intField": 777}},
|
||||||
|
)
|
||||||
|
assert result.errors is None
|
||||||
|
assert result.data == {"myFormMutation": {"result": True}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mutation_with_modelform_djangoforminputtype():
|
||||||
|
class ReporterInputType(DjangoFormInputObjectType):
|
||||||
|
class Meta:
|
||||||
|
form_class = ReporterForm
|
||||||
|
object_type = ReporterType
|
||||||
|
only_fields = ("first_name", "last_name", "a_choice")
|
||||||
|
|
||||||
|
class ReporterMutation(graphene.Mutation):
|
||||||
|
class Arguments:
|
||||||
|
reporter_data = ReporterInputType(required=True)
|
||||||
|
|
||||||
|
result = graphene.Field(ReporterType)
|
||||||
|
|
||||||
|
def mutate(_root, _info, reporter_data):
|
||||||
|
reporter = Reporter.objects.get(pk=reporter_data.id)
|
||||||
|
form = ReporterForm(data=reporter_data, instance=reporter)
|
||||||
|
if form.is_valid():
|
||||||
|
reporter = form.save()
|
||||||
|
return ReporterMutation(result=reporter)
|
||||||
|
|
||||||
|
return ReporterMutation(result=None)
|
||||||
|
|
||||||
|
class Mutation(graphene.ObjectType):
|
||||||
|
report_mutation = ReporterMutation.Field()
|
||||||
|
|
||||||
|
schema = graphene.Schema(query=MockQuery, mutation=Mutation)
|
||||||
|
|
||||||
|
reporter = Reporter.objects.create(
|
||||||
|
first_name="Bob", last_name="Roberts", a_choice=THIS
|
||||||
|
)
|
||||||
|
|
||||||
|
result = schema.execute(
|
||||||
|
""" mutation ReportMutation($reporterData: ReporterInputType!) {
|
||||||
|
reportMutation(reporterData: $reporterData) {
|
||||||
|
result {
|
||||||
|
id,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
aChoice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
variable_values={
|
||||||
|
"reporterData": {
|
||||||
|
"id": reporter.pk,
|
||||||
|
"firstName": "Dave",
|
||||||
|
"lastName": "Smith",
|
||||||
|
"aChoice": THIS_ON_CLIENT_CONVERTED,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert result.errors is None
|
||||||
|
assert result.data["reportMutation"]["result"] == {
|
||||||
|
"id": "1",
|
||||||
|
"firstName": "Dave",
|
||||||
|
"lastName": "Smith",
|
||||||
|
"aChoice": THIS_ON_CLIENT_CONVERTED,
|
||||||
|
}
|
||||||
|
assert Reporter.objects.count() == 1
|
||||||
|
reporter.refresh_from_db()
|
||||||
|
assert reporter.first_name == "Dave"
|
||||||
|
|
||||||
|
|
||||||
|
def reporter_enum_convert_mutation_result(
|
||||||
|
ReporterInputType, choice_val_on_client=THIS_ON_CLIENT_CONVERTED
|
||||||
|
):
|
||||||
|
class ReporterMutation(graphene.Mutation):
|
||||||
|
class Arguments:
|
||||||
|
reporter = ReporterInputType(required=True)
|
||||||
|
|
||||||
|
result_str = graphene.String()
|
||||||
|
result_int = graphene.Int()
|
||||||
|
|
||||||
|
def mutate(_root, _info, reporter):
|
||||||
|
if isinstance(reporter.a_choice, int) or reporter.a_choice.isdigit():
|
||||||
|
return ReporterMutation(result_int=reporter.a_choice, result_str=None)
|
||||||
|
return ReporterMutation(result_int=None, result_str=reporter.a_choice)
|
||||||
|
|
||||||
|
class Mutation(graphene.ObjectType):
|
||||||
|
report_mutation = ReporterMutation.Field()
|
||||||
|
|
||||||
|
schema = graphene.Schema(query=MockQuery, mutation=Mutation)
|
||||||
|
|
||||||
|
return schema.execute(
|
||||||
|
""" mutation ReportMutation($reporter: ReporterInputType!) {
|
||||||
|
reportMutation(reporter: $reporter) {
|
||||||
|
resultStr,
|
||||||
|
resultInt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
variable_values={"reporter": {"aChoice": choice_val_on_client}},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_enum_not_converted():
|
||||||
|
class ReporterInputType(DjangoFormInputObjectType):
|
||||||
|
class Meta:
|
||||||
|
form_class = ReporterForm
|
||||||
|
only_fields = ("a_choice",)
|
||||||
|
|
||||||
|
result = reporter_enum_convert_mutation_result(ReporterInputType)
|
||||||
|
assert result.errors is None
|
||||||
|
assert result.data["reportMutation"]["resultStr"] == THIS_ON_CLIENT_CONVERTED
|
||||||
|
assert result.data["reportMutation"]["resultInt"] is None
|
||||||
|
assert ReporterInputType._meta.fields["a_choice"].type is graphene.String
|
||||||
|
|
||||||
|
|
||||||
|
def test_enum_is_converted_to_original():
|
||||||
|
class ReporterInputType(DjangoFormInputObjectType):
|
||||||
|
class Meta:
|
||||||
|
form_class = ReporterForm
|
||||||
|
object_type = ReporterType
|
||||||
|
only_fields = ("a_choice",)
|
||||||
|
|
||||||
|
result = reporter_enum_convert_mutation_result(ReporterInputType)
|
||||||
|
assert result.errors is None
|
||||||
|
assert result.data["reportMutation"]["resultInt"] == THIS
|
||||||
|
assert result.data["reportMutation"]["resultStr"] is None
|
||||||
|
assert (
|
||||||
|
ReporterInputType._meta.fields["a_choice"].type.__name__
|
||||||
|
== "AChoiceEnumBackConvString"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_choices_to_enum_is_false_and_field_type_as_in_model():
|
||||||
|
class ReporterTypeNotConvertChoices(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = Reporter
|
||||||
|
convert_choices_to_enum = False
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
class ReporterInputType(DjangoFormInputObjectType):
|
||||||
|
class Meta:
|
||||||
|
form_class = ReporterForm
|
||||||
|
object_type = ReporterTypeNotConvertChoices
|
||||||
|
only_fields = ("a_choice",)
|
||||||
|
|
||||||
|
result = reporter_enum_convert_mutation_result(ReporterInputType, THIS)
|
||||||
|
assert result.errors is None
|
||||||
|
assert result.data["reportMutation"]["resultInt"] == THIS
|
||||||
|
assert result.data["reportMutation"]["resultStr"] is None
|
||||||
|
assert ReporterInputType._meta.fields["a_choice"].type is graphene.Int
|
||||||
|
|
||||||
|
|
||||||
|
def enum_convert_mutation_result_film(FilmInputType):
|
||||||
|
class FilmMutation(graphene.Mutation):
|
||||||
|
class Arguments:
|
||||||
|
film = FilmInputType(required=True)
|
||||||
|
|
||||||
|
result = graphene.String()
|
||||||
|
|
||||||
|
def mutate(_root, _info, film):
|
||||||
|
return FilmMutation(result=film.genre)
|
||||||
|
|
||||||
|
class Mutation(graphene.ObjectType):
|
||||||
|
film_mutation = FilmMutation.Field()
|
||||||
|
|
||||||
|
schema = graphene.Schema(query=MockQuery, mutation=Mutation)
|
||||||
|
|
||||||
|
return schema.execute(
|
||||||
|
""" mutation FilmMutation($film: FilmInputType!) {
|
||||||
|
filmMutation(film: $film) {
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
variable_values={"film": {"genre": DOCUMENTARY_ON_CLIENT_CONVERTED}},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_enum_not_converted_required_non_number():
|
||||||
|
class FilmInputType(DjangoFormInputObjectType):
|
||||||
|
class Meta:
|
||||||
|
form_class = FilmForm
|
||||||
|
only_fields = ("genre",)
|
||||||
|
|
||||||
|
result = enum_convert_mutation_result_film(FilmInputType)
|
||||||
|
assert result.errors is None
|
||||||
|
assert result.data["filmMutation"]["result"] == DOCUMENTARY_ON_CLIENT_CONVERTED
|
||||||
|
|
||||||
|
|
||||||
|
def test_enum_is_converted_to_original_required_non_number():
|
||||||
|
class FilmType(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = Film
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
class FilmInputType(DjangoFormInputObjectType):
|
||||||
|
class Meta:
|
||||||
|
form_class = FilmForm
|
||||||
|
object_type = FilmType
|
||||||
|
only_fields = ("genre",)
|
||||||
|
|
||||||
|
result = enum_convert_mutation_result_film(FilmInputType)
|
||||||
|
assert result.errors is None
|
||||||
|
assert result.data["filmMutation"]["result"] == DOCUMENTARY
|
|
@ -1 +1,118 @@
|
||||||
|
import graphene
|
||||||
|
|
||||||
|
from graphene import ID
|
||||||
|
from graphene.types.inputobjecttype import InputObjectType
|
||||||
|
from graphene.utils.str_converters import to_camel_case
|
||||||
|
|
||||||
|
from .mutation import fields_for_form
|
||||||
from ..types import ErrorType # noqa Import ErrorType for backwards compatability
|
from ..types import ErrorType # noqa Import ErrorType for backwards compatability
|
||||||
|
from ..converter import BlankValueField
|
||||||
|
|
||||||
|
|
||||||
|
class DjangoFormInputObjectType(InputObjectType):
|
||||||
|
@classmethod
|
||||||
|
def __init_subclass_with_meta__(
|
||||||
|
cls,
|
||||||
|
container=None,
|
||||||
|
_meta=None,
|
||||||
|
only_fields=(),
|
||||||
|
exclude_fields=(),
|
||||||
|
form_class=None,
|
||||||
|
object_type=None,
|
||||||
|
add_id_field_name=None,
|
||||||
|
add_id_field_type=None,
|
||||||
|
**options,
|
||||||
|
):
|
||||||
|
"""Retrieve fields from django form (Meta.form_class). Received
|
||||||
|
fields are set to cls (they will be converted to input fields
|
||||||
|
by InputObjectType). Type of fields with choices (converted
|
||||||
|
to enum) is set to custom scalar type (using Meta.object_type)
|
||||||
|
to dynamically convert enum values back.
|
||||||
|
|
||||||
|
class MyDjangoFormInput(DjangoFormInputObjectType):
|
||||||
|
# any other fields can be placed here and other inputobjectforms as well
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
form_class = MyDjangoModelForm
|
||||||
|
object_type = MyModelType
|
||||||
|
|
||||||
|
class SomeMutation(graphene.Mutation):
|
||||||
|
class Arguments:
|
||||||
|
data = MyDjangoFormInput(required=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def mutate(_root, _info, data):
|
||||||
|
form_inst = MyDjangoModelForm(data=data)
|
||||||
|
if form_inst.is_valid():
|
||||||
|
django_model_instance = form_inst.save(commit=False)
|
||||||
|
# ... etc ...
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not form_class:
|
||||||
|
raise Exception("form_class is required for DjangoFormInputObjectType")
|
||||||
|
|
||||||
|
form = form_class()
|
||||||
|
form_fields = fields_for_form(form, only_fields, exclude_fields)
|
||||||
|
|
||||||
|
for name, field in form_fields.items():
|
||||||
|
if (
|
||||||
|
object_type
|
||||||
|
and name in object_type._meta.fields
|
||||||
|
and isinstance(object_type._meta.fields[name], BlankValueField)
|
||||||
|
):
|
||||||
|
# Field type BlankValueField here means that field
|
||||||
|
# with choises have been converted to enum
|
||||||
|
# (BlankValueField is using only for that task ?)
|
||||||
|
setattr(cls, name, cls.get_enum_cnv_cls_instance(name, object_type))
|
||||||
|
elif (
|
||||||
|
object_type
|
||||||
|
and name in object_type._meta.fields
|
||||||
|
and object_type._meta.convert_choices_to_enum is False
|
||||||
|
and form.fields[name].__class__.__name__ == "TypedChoiceField"
|
||||||
|
):
|
||||||
|
# FIXME
|
||||||
|
# in case if convert_choices_to_enum is False
|
||||||
|
# form field class is converted to String but original
|
||||||
|
# model field type is needed here... (.converter.py bug?)
|
||||||
|
# This is temp workaround to get field type from ObjectType field
|
||||||
|
# TEST: test_enum_not_converted_and_field_type_as_in_model
|
||||||
|
setattr(cls, name, object_type._meta.fields[name].type())
|
||||||
|
else:
|
||||||
|
# set input field according to django form field
|
||||||
|
setattr(cls, name, field)
|
||||||
|
|
||||||
|
# explicitly adding id field (absent in django form fields)
|
||||||
|
# with name and type from Meta or 'id' with graphene.ID by default
|
||||||
|
if add_id_field_name:
|
||||||
|
setattr(cls, add_id_field_name, add_id_field_type or ID(required=False))
|
||||||
|
elif "id" not in exclude_fields:
|
||||||
|
cls.id = ID(required=False)
|
||||||
|
|
||||||
|
super().__init_subclass_with_meta__(container=container, _meta=_meta, **options)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_enum_cnv_cls_instance(field_name, object_type):
|
||||||
|
"""Saves args in context to convert enum values in
|
||||||
|
Dynamically created Scalar derived class
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_value(value):
|
||||||
|
# field_name & object_type have been saved in context (closure)
|
||||||
|
field = object_type._meta.fields[field_name]
|
||||||
|
if isinstance(field.type, graphene.NonNull):
|
||||||
|
val_before_convert = field.type._of_type[value].value
|
||||||
|
else:
|
||||||
|
val_before_convert = field.type[value].value
|
||||||
|
return graphene.String.parse_value(val_before_convert)
|
||||||
|
|
||||||
|
cls_doc = "String scalar to convert choice value back from enum to original"
|
||||||
|
scalar_type = type(
|
||||||
|
(
|
||||||
|
f"{field_name[0].upper()}{to_camel_case(field_name[1:])}"
|
||||||
|
"EnumBackConvString"
|
||||||
|
),
|
||||||
|
(graphene.String,),
|
||||||
|
{"parse_value": parse_value, "__doc__": cls_doc},
|
||||||
|
)
|
||||||
|
return scalar_type()
|
||||||
|
|
|
@ -39,6 +39,9 @@ def fields_for_serializer(
|
||||||
field.read_only
|
field.read_only
|
||||||
and is_input
|
and is_input
|
||||||
and lookup_field != name, # don't show read_only fields in Input
|
and lookup_field != name, # don't show read_only fields in Input
|
||||||
|
isinstance(
|
||||||
|
field, serializers.HiddenField
|
||||||
|
), # don't show hidden fields in Input
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -164,6 +164,21 @@ def test_read_only_fields():
|
||||||
), "'cool_name' is read_only field and shouldn't be on arguments"
|
), "'cool_name' is read_only field and shouldn't be on arguments"
|
||||||
|
|
||||||
|
|
||||||
|
def test_hidden_fields():
|
||||||
|
class SerializerWithHiddenField(serializers.Serializer):
|
||||||
|
cool_name = serializers.CharField()
|
||||||
|
user = serializers.HiddenField(default=serializers.CurrentUserDefault())
|
||||||
|
|
||||||
|
class MyMutation(SerializerMutation):
|
||||||
|
class Meta:
|
||||||
|
serializer_class = SerializerWithHiddenField
|
||||||
|
|
||||||
|
assert "cool_name" in MyMutation.Input._meta.fields
|
||||||
|
assert (
|
||||||
|
"user" not in MyMutation.Input._meta.fields
|
||||||
|
), "'user' is hidden field and shouldn't be on arguments"
|
||||||
|
|
||||||
|
|
||||||
def test_nested_model():
|
def test_nested_model():
|
||||||
class MyFakeModelGrapheneType(DjangoObjectType):
|
class MyFakeModelGrapheneType(DjangoObjectType):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
|
@ -21,6 +21,10 @@ add "&raw" to the end of the URL within a browser.
|
||||||
integrity="{{graphiql_css_sri}}"
|
integrity="{{graphiql_css_sri}}"
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
crossorigin="anonymous" />
|
crossorigin="anonymous" />
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/@graphiql/plugin-explorer@{{graphiql_plugin_explorer_version}}/dist/style.css"
|
||||||
|
integrity="{{graphiql_plugin_explorer_css_sri}}"
|
||||||
|
rel="stylesheet"
|
||||||
|
crossorigin="anonymous" />
|
||||||
<script src="https://cdn.jsdelivr.net/npm/whatwg-fetch@{{whatwg_fetch_version}}/dist/fetch.umd.js"
|
<script src="https://cdn.jsdelivr.net/npm/whatwg-fetch@{{whatwg_fetch_version}}/dist/fetch.umd.js"
|
||||||
integrity="{{whatwg_fetch_sri}}"
|
integrity="{{whatwg_fetch_sri}}"
|
||||||
crossorigin="anonymous"></script>
|
crossorigin="anonymous"></script>
|
||||||
|
|
|
@ -43,7 +43,7 @@ class Reporter(models.Model):
|
||||||
last_name = models.CharField(max_length=30)
|
last_name = models.CharField(max_length=30)
|
||||||
email = models.EmailField()
|
email = models.EmailField()
|
||||||
pets = models.ManyToManyField("self")
|
pets = models.ManyToManyField("self")
|
||||||
a_choice = models.CharField(max_length=30, choices=CHOICES, blank=True)
|
a_choice = models.IntegerField(choices=CHOICES, null=True, blank=True)
|
||||||
objects = models.Manager()
|
objects = models.Manager()
|
||||||
doe_objects = DoeReporterManager()
|
doe_objects = DoeReporterManager()
|
||||||
fans = models.ManyToManyField(Person)
|
fans = models.ManyToManyField(Person)
|
||||||
|
|
|
@ -46,7 +46,7 @@ def test_generate_graphql_file_on_call_graphql_schema():
|
||||||
open_mock.assert_called_once()
|
open_mock.assert_called_once()
|
||||||
|
|
||||||
handle = open_mock()
|
handle = open_mock()
|
||||||
assert handle.write.called_once()
|
handle.write.assert_called_once()
|
||||||
|
|
||||||
schema_output = handle.write.call_args[0][0]
|
schema_output = handle.write.call_args[0][0]
|
||||||
assert schema_output == dedent(
|
assert schema_output == dedent(
|
||||||
|
|
|
@ -15,8 +15,6 @@ from graphene.types.scalars import BigInt
|
||||||
from ..compat import (
|
from ..compat import (
|
||||||
ArrayField,
|
ArrayField,
|
||||||
HStoreField,
|
HStoreField,
|
||||||
JSONField,
|
|
||||||
PGJSONField,
|
|
||||||
MissingType,
|
MissingType,
|
||||||
RangeField,
|
RangeField,
|
||||||
)
|
)
|
||||||
|
@ -372,16 +370,6 @@ def test_should_postgres_hstore_convert_string():
|
||||||
assert_conversion(HStoreField, JSONString)
|
assert_conversion(HStoreField, JSONString)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(PGJSONField is MissingType, reason="PGJSONField should exist")
|
|
||||||
def test_should_postgres_json_convert_string():
|
|
||||||
assert_conversion(PGJSONField, JSONString)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(JSONField is MissingType, reason="JSONField should exist")
|
|
||||||
def test_should_json_convert_string():
|
|
||||||
assert_conversion(JSONField, JSONString)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(RangeField is MissingType, reason="RangeField should exist")
|
@pytest.mark.skipif(RangeField is MissingType, reason="RangeField should exist")
|
||||||
def test_should_postgres_range_convert_list():
|
def test_should_postgres_range_convert_list():
|
||||||
from django.contrib.postgres.fields import IntegerRangeField
|
from django.contrib.postgres.fields import IntegerRangeField
|
||||||
|
|
|
@ -8,7 +8,7 @@ from graphql_relay import to_global_id
|
||||||
from ..fields import DjangoConnectionField
|
from ..fields import DjangoConnectionField
|
||||||
from ..types import DjangoObjectType
|
from ..types import DjangoObjectType
|
||||||
|
|
||||||
from .models import Article, Reporter
|
from .models import Article, Reporter, FilmDetails, Film
|
||||||
|
|
||||||
|
|
||||||
class TestShouldCallGetQuerySetOnForeignKey:
|
class TestShouldCallGetQuerySetOnForeignKey:
|
||||||
|
@ -127,6 +127,69 @@ class TestShouldCallGetQuerySetOnForeignKey:
|
||||||
assert not result.errors
|
assert not result.errors
|
||||||
assert result.data == {"reporter": {"firstName": "Jane"}}
|
assert result.data == {"reporter": {"firstName": "Jane"}}
|
||||||
|
|
||||||
|
def test_get_queryset_called_on_foreignkey(self):
|
||||||
|
# If a user tries to access a reporter through an article they should get our authorization error
|
||||||
|
query = """
|
||||||
|
query getArticle($id: ID!) {
|
||||||
|
article(id: $id) {
|
||||||
|
headline
|
||||||
|
reporter {
|
||||||
|
firstName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = self.schema.execute(query, variables={"id": self.articles[0].id})
|
||||||
|
assert len(result.errors) == 1
|
||||||
|
assert result.errors[0].message == "Not authorized to access reporters."
|
||||||
|
|
||||||
|
# An admin user should be able to get reporters through an article
|
||||||
|
query = """
|
||||||
|
query getArticle($id: ID!) {
|
||||||
|
article(id: $id) {
|
||||||
|
headline
|
||||||
|
reporter {
|
||||||
|
firstName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = self.schema.execute(
|
||||||
|
query,
|
||||||
|
variables={"id": self.articles[0].id},
|
||||||
|
context_value={"admin": True},
|
||||||
|
)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["article"] == {
|
||||||
|
"headline": "A fantastic article",
|
||||||
|
"reporter": {"firstName": "Jane"},
|
||||||
|
}
|
||||||
|
|
||||||
|
# An admin user should not be able to access draft article through a reporter
|
||||||
|
query = """
|
||||||
|
query getReporter($id: ID!) {
|
||||||
|
reporter(id: $id) {
|
||||||
|
firstName
|
||||||
|
articles {
|
||||||
|
headline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = self.schema.execute(
|
||||||
|
query,
|
||||||
|
variables={"id": self.reporter.id},
|
||||||
|
context_value={"admin": True},
|
||||||
|
)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["reporter"] == {
|
||||||
|
"firstName": "Jane",
|
||||||
|
"articles": [{"headline": "A fantastic article"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class TestShouldCallGetQuerySetOnForeignKeyNode:
|
class TestShouldCallGetQuerySetOnForeignKeyNode:
|
||||||
"""
|
"""
|
||||||
|
@ -233,3 +296,272 @@ class TestShouldCallGetQuerySetOnForeignKeyNode:
|
||||||
)
|
)
|
||||||
assert not result.errors
|
assert not result.errors
|
||||||
assert result.data == {"reporter": {"firstName": "Jane"}}
|
assert result.data == {"reporter": {"firstName": "Jane"}}
|
||||||
|
|
||||||
|
def test_get_queryset_called_on_foreignkey(self):
|
||||||
|
# If a user tries to access a reporter through an article they should get our authorization error
|
||||||
|
query = """
|
||||||
|
query getArticle($id: ID!) {
|
||||||
|
article(id: $id) {
|
||||||
|
headline
|
||||||
|
reporter {
|
||||||
|
firstName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = self.schema.execute(
|
||||||
|
query, variables={"id": to_global_id("ArticleType", self.articles[0].id)}
|
||||||
|
)
|
||||||
|
assert len(result.errors) == 1
|
||||||
|
assert result.errors[0].message == "Not authorized to access reporters."
|
||||||
|
|
||||||
|
# An admin user should be able to get reporters through an article
|
||||||
|
query = """
|
||||||
|
query getArticle($id: ID!) {
|
||||||
|
article(id: $id) {
|
||||||
|
headline
|
||||||
|
reporter {
|
||||||
|
firstName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = self.schema.execute(
|
||||||
|
query,
|
||||||
|
variables={"id": to_global_id("ArticleType", self.articles[0].id)},
|
||||||
|
context_value={"admin": True},
|
||||||
|
)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["article"] == {
|
||||||
|
"headline": "A fantastic article",
|
||||||
|
"reporter": {"firstName": "Jane"},
|
||||||
|
}
|
||||||
|
|
||||||
|
# An admin user should not be able to access draft article through a reporter
|
||||||
|
query = """
|
||||||
|
query getReporter($id: ID!) {
|
||||||
|
reporter(id: $id) {
|
||||||
|
firstName
|
||||||
|
articles {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
headline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = self.schema.execute(
|
||||||
|
query,
|
||||||
|
variables={"id": to_global_id("ReporterType", self.reporter.id)},
|
||||||
|
context_value={"admin": True},
|
||||||
|
)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["reporter"] == {
|
||||||
|
"firstName": "Jane",
|
||||||
|
"articles": {"edges": [{"node": {"headline": "A fantastic article"}}]},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestShouldCallGetQuerySetOnOneToOne:
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def setup_schema(self):
|
||||||
|
class FilmDetailsType(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = FilmDetails
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_queryset(cls, queryset, info):
|
||||||
|
if info.context and info.context.get("permission_get_film_details"):
|
||||||
|
return queryset
|
||||||
|
raise Exception("Not authorized to access film details.")
|
||||||
|
|
||||||
|
class FilmType(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = Film
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_queryset(cls, queryset, info):
|
||||||
|
if info.context and info.context.get("permission_get_film"):
|
||||||
|
return queryset
|
||||||
|
raise Exception("Not authorized to access film.")
|
||||||
|
|
||||||
|
class Query(graphene.ObjectType):
|
||||||
|
film_details = graphene.Field(
|
||||||
|
FilmDetailsType, id=graphene.ID(required=True)
|
||||||
|
)
|
||||||
|
film = graphene.Field(FilmType, id=graphene.ID(required=True))
|
||||||
|
|
||||||
|
def resolve_film_details(self, info, id):
|
||||||
|
return (
|
||||||
|
FilmDetailsType.get_queryset(FilmDetails.objects, info)
|
||||||
|
.filter(id=id)
|
||||||
|
.last()
|
||||||
|
)
|
||||||
|
|
||||||
|
def resolve_film(self, info, id):
|
||||||
|
return FilmType.get_queryset(Film.objects, info).filter(id=id).last()
|
||||||
|
|
||||||
|
self.schema = graphene.Schema(query=Query)
|
||||||
|
|
||||||
|
self.films = [
|
||||||
|
Film.objects.create(
|
||||||
|
genre="do",
|
||||||
|
),
|
||||||
|
Film.objects.create(
|
||||||
|
genre="ac",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
self.film_details = [
|
||||||
|
FilmDetails.objects.create(
|
||||||
|
film=self.films[0],
|
||||||
|
),
|
||||||
|
FilmDetails.objects.create(
|
||||||
|
film=self.films[1],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_get_queryset_called_on_field(self):
|
||||||
|
# A user tries to access a film
|
||||||
|
query = """
|
||||||
|
query getFilm($id: ID!) {
|
||||||
|
film(id: $id) {
|
||||||
|
genre
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
# With `permission_get_film`
|
||||||
|
result = self.schema.execute(
|
||||||
|
query,
|
||||||
|
variables={"id": self.films[0].id},
|
||||||
|
context_value={"permission_get_film": True},
|
||||||
|
)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["film"] == {
|
||||||
|
"genre": "DO",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Without `permission_get_film`
|
||||||
|
result = self.schema.execute(
|
||||||
|
query,
|
||||||
|
variables={"id": self.films[1].id},
|
||||||
|
context_value={"permission_get_film": False},
|
||||||
|
)
|
||||||
|
assert len(result.errors) == 1
|
||||||
|
assert result.errors[0].message == "Not authorized to access film."
|
||||||
|
|
||||||
|
# A user tries to access a film details
|
||||||
|
query = """
|
||||||
|
query getFilmDetails($id: ID!) {
|
||||||
|
filmDetails(id: $id) {
|
||||||
|
location
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
# With `permission_get_film`
|
||||||
|
result = self.schema.execute(
|
||||||
|
query,
|
||||||
|
variables={"id": self.film_details[0].id},
|
||||||
|
context_value={"permission_get_film_details": True},
|
||||||
|
)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data == {"filmDetails": {"location": ""}}
|
||||||
|
|
||||||
|
# Without `permission_get_film`
|
||||||
|
result = self.schema.execute(
|
||||||
|
query,
|
||||||
|
variables={"id": self.film_details[0].id},
|
||||||
|
context_value={"permission_get_film_details": False},
|
||||||
|
)
|
||||||
|
assert len(result.errors) == 1
|
||||||
|
assert result.errors[0].message == "Not authorized to access film details."
|
||||||
|
|
||||||
|
def test_get_queryset_called_on_foreignkey(self, django_assert_num_queries):
|
||||||
|
# A user tries to access a film details through a film
|
||||||
|
query = """
|
||||||
|
query getFilm($id: ID!) {
|
||||||
|
film(id: $id) {
|
||||||
|
genre
|
||||||
|
details {
|
||||||
|
location
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
# With `permission_get_film_details`
|
||||||
|
with django_assert_num_queries(2):
|
||||||
|
result = self.schema.execute(
|
||||||
|
query,
|
||||||
|
variables={"id": self.films[0].id},
|
||||||
|
context_value={
|
||||||
|
"permission_get_film": True,
|
||||||
|
"permission_get_film_details": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["film"] == {
|
||||||
|
"genre": "DO",
|
||||||
|
"details": {"location": ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Without `permission_get_film_details`
|
||||||
|
with django_assert_num_queries(1):
|
||||||
|
result = self.schema.execute(
|
||||||
|
query,
|
||||||
|
variables={"id": self.films[0].id},
|
||||||
|
context_value={
|
||||||
|
"permission_get_film": True,
|
||||||
|
"permission_get_film_details": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert len(result.errors) == 1
|
||||||
|
assert result.errors[0].message == "Not authorized to access film details."
|
||||||
|
|
||||||
|
# A user tries to access a film through a film details
|
||||||
|
query = """
|
||||||
|
query getFilmDetails($id: ID!) {
|
||||||
|
filmDetails(id: $id) {
|
||||||
|
location
|
||||||
|
film {
|
||||||
|
genre
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
# With `permission_get_film`
|
||||||
|
with django_assert_num_queries(2):
|
||||||
|
result = self.schema.execute(
|
||||||
|
query,
|
||||||
|
variables={"id": self.film_details[0].id},
|
||||||
|
context_value={
|
||||||
|
"permission_get_film": True,
|
||||||
|
"permission_get_film_details": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["filmDetails"] == {
|
||||||
|
"location": "",
|
||||||
|
"film": {"genre": "DO"},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Without `permission_get_film`
|
||||||
|
with django_assert_num_queries(1):
|
||||||
|
result = self.schema.execute(
|
||||||
|
query,
|
||||||
|
variables={"id": self.film_details[1].id},
|
||||||
|
context_value={
|
||||||
|
"permission_get_film": False,
|
||||||
|
"permission_get_film_details": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert len(result.errors) == 1
|
||||||
|
assert result.errors[0].message == "Not authorized to access film."
|
||||||
|
|
|
@ -128,13 +128,12 @@ def test_should_query_postgres_fields():
|
||||||
from django.contrib.postgres.fields import (
|
from django.contrib.postgres.fields import (
|
||||||
IntegerRangeField,
|
IntegerRangeField,
|
||||||
ArrayField,
|
ArrayField,
|
||||||
JSONField,
|
|
||||||
HStoreField,
|
HStoreField,
|
||||||
)
|
)
|
||||||
|
|
||||||
class Event(models.Model):
|
class Event(models.Model):
|
||||||
ages = IntegerRangeField(help_text="The age ranges")
|
ages = IntegerRangeField(help_text="The age ranges")
|
||||||
data = JSONField(help_text="Data")
|
data = models.JSONField(help_text="Data")
|
||||||
store = HStoreField()
|
store = HStoreField()
|
||||||
tags = ArrayField(models.CharField(max_length=50))
|
tags = ArrayField(models.CharField(max_length=50))
|
||||||
|
|
||||||
|
|
|
@ -252,6 +252,7 @@ class DjangoObjectType(ObjectType):
|
||||||
_meta.filterset_class = filterset_class
|
_meta.filterset_class = filterset_class
|
||||||
_meta.fields = django_fields
|
_meta.fields = django_fields
|
||||||
_meta.connection = connection
|
_meta.connection = connection
|
||||||
|
_meta.convert_choices_to_enum = convert_choices_to_enum
|
||||||
|
|
||||||
super().__init_subclass_with_meta__(
|
super().__init_subclass_with_meta__(
|
||||||
_meta=_meta, interfaces=interfaces, **options
|
_meta=_meta, interfaces=interfaces, **options
|
||||||
|
|
|
@ -6,6 +6,7 @@ from .utils import (
|
||||||
get_reverse_fields,
|
get_reverse_fields,
|
||||||
is_valid_django_model,
|
is_valid_django_model,
|
||||||
maybe_queryset,
|
maybe_queryset,
|
||||||
|
bypass_get_queryset,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
@ -16,4 +17,5 @@ __all__ = [
|
||||||
"camelize",
|
"camelize",
|
||||||
"is_valid_django_model",
|
"is_valid_django_model",
|
||||||
"GraphQLTestCase",
|
"GraphQLTestCase",
|
||||||
|
"bypass_get_queryset",
|
||||||
]
|
]
|
||||||
|
|
|
@ -136,3 +136,12 @@ def set_rollback():
|
||||||
atomic_requests = connection.settings_dict.get("ATOMIC_REQUESTS", False)
|
atomic_requests = connection.settings_dict.get("ATOMIC_REQUESTS", False)
|
||||||
if atomic_requests and connection.in_atomic_block:
|
if atomic_requests and connection.in_atomic_block:
|
||||||
transaction.set_rollback(True)
|
transaction.set_rollback(True)
|
||||||
|
|
||||||
|
|
||||||
|
def bypass_get_queryset(resolver):
|
||||||
|
"""
|
||||||
|
Adds a bypass_get_queryset attribute to the resolver, which is used to
|
||||||
|
bypass any custom get_queryset method of the DjangoObjectType.
|
||||||
|
"""
|
||||||
|
resolver._bypass_get_queryset = True
|
||||||
|
return resolver
|
||||||
|
|
|
@ -66,18 +66,21 @@ class GraphQLView(View):
|
||||||
react_dom_sri = "sha256-nbMykgB6tsOFJ7OdVmPpdqMFVk4ZsqWocT6issAPUF0="
|
react_dom_sri = "sha256-nbMykgB6tsOFJ7OdVmPpdqMFVk4ZsqWocT6issAPUF0="
|
||||||
|
|
||||||
# The GraphiQL React app.
|
# The GraphiQL React app.
|
||||||
graphiql_version = "2.4.1" # "1.0.3"
|
graphiql_version = "2.4.7"
|
||||||
graphiql_sri = "sha256-s+f7CFAPSUIygFnRC2nfoiEKd3liCUy+snSdYFAoLUc=" # "sha256-VR4buIDY9ZXSyCNFHFNik6uSe0MhigCzgN4u7moCOTk="
|
graphiql_sri = "sha256-n/LKaELupC1H/PU6joz+ybeRJHT2xCdekEt6OYMOOZU="
|
||||||
graphiql_css_sri = "sha256-88yn8FJMyGboGs4Bj+Pbb3kWOWXo7jmb+XCRHE+282k=" # "sha256-LwqxjyZgqXDYbpxQJ5zLQeNcf7WVNSJ+r8yp2rnWE/E="
|
graphiql_css_sri = "sha256-OsbM+LQHcnFHi0iH7AUKueZvDcEBoy/z4hJ7jx1cpsM="
|
||||||
|
|
||||||
# The websocket transport library for subscriptions.
|
# The websocket transport library for subscriptions.
|
||||||
subscriptions_transport_ws_version = "5.12.1"
|
subscriptions_transport_ws_version = "5.13.1"
|
||||||
subscriptions_transport_ws_sri = (
|
subscriptions_transport_ws_sri = (
|
||||||
"sha256-EZhvg6ANJrBsgLvLAa0uuHNLepLJVCFYS+xlb5U/bqw="
|
"sha256-EZhvg6ANJrBsgLvLAa0uuHNLepLJVCFYS+xlb5U/bqw="
|
||||||
)
|
)
|
||||||
|
|
||||||
graphiql_plugin_explorer_version = "0.1.15"
|
graphiql_plugin_explorer_version = "0.1.15"
|
||||||
graphiql_plugin_explorer_sri = "sha256-3hUuhBXdXlfCj6RTeEkJFtEh/kUG+TCDASFpFPLrzvE="
|
graphiql_plugin_explorer_sri = "sha256-3hUuhBXdXlfCj6RTeEkJFtEh/kUG+TCDASFpFPLrzvE="
|
||||||
|
graphiql_plugin_explorer_css_sri = (
|
||||||
|
"sha256-fA0LPUlukMNR6L4SPSeFqDTYav8QdWjQ2nr559Zln1U="
|
||||||
|
)
|
||||||
|
|
||||||
schema = None
|
schema = None
|
||||||
graphiql = False
|
graphiql = False
|
||||||
|
@ -105,17 +108,19 @@ class GraphQLView(View):
|
||||||
if middleware is None:
|
if middleware is None:
|
||||||
middleware = graphene_settings.MIDDLEWARE
|
middleware = graphene_settings.MIDDLEWARE
|
||||||
|
|
||||||
self.schema = self.schema or schema
|
self.schema = schema or self.schema
|
||||||
if middleware is not None:
|
if middleware is not None:
|
||||||
if isinstance(middleware, MiddlewareManager):
|
if isinstance(middleware, MiddlewareManager):
|
||||||
self.middleware = middleware
|
self.middleware = middleware
|
||||||
else:
|
else:
|
||||||
self.middleware = list(instantiate_middleware(middleware))
|
self.middleware = list(instantiate_middleware(middleware))
|
||||||
self.root_value = root_value
|
self.root_value = root_value
|
||||||
self.pretty = self.pretty or pretty
|
self.pretty = pretty or self.pretty
|
||||||
self.graphiql = self.graphiql or graphiql
|
self.graphiql = graphiql or self.graphiql
|
||||||
self.batch = self.batch or batch
|
self.batch = batch or self.batch
|
||||||
self.execution_context_class = execution_context_class
|
self.execution_context_class = (
|
||||||
|
execution_context_class or self.execution_context_class
|
||||||
|
)
|
||||||
if subscription_path is None:
|
if subscription_path is None:
|
||||||
self.subscription_path = graphene_settings.SUBSCRIPTION_PATH
|
self.subscription_path = graphene_settings.SUBSCRIPTION_PATH
|
||||||
|
|
||||||
|
|
3
setup.py
3
setup.py
|
@ -38,6 +38,7 @@ setup(
|
||||||
version=version,
|
version=version,
|
||||||
description="Graphene Django integration",
|
description="Graphene Django integration",
|
||||||
long_description=open("README.md").read(),
|
long_description=open("README.md").read(),
|
||||||
|
long_description_content_type="text/markdown",
|
||||||
url="https://github.com/graphql-python/graphene-django",
|
url="https://github.com/graphql-python/graphene-django",
|
||||||
author="Syrus Akbary",
|
author="Syrus Akbary",
|
||||||
author_email="me@syrusakbary.com",
|
author_email="me@syrusakbary.com",
|
||||||
|
@ -55,8 +56,8 @@ setup(
|
||||||
"Programming Language :: Python :: Implementation :: PyPy",
|
"Programming Language :: Python :: Implementation :: PyPy",
|
||||||
"Framework :: Django",
|
"Framework :: Django",
|
||||||
"Framework :: Django :: 3.2",
|
"Framework :: Django :: 3.2",
|
||||||
"Framework :: Django :: 4.0",
|
|
||||||
"Framework :: Django :: 4.1",
|
"Framework :: Django :: 4.1",
|
||||||
|
"Framework :: Django :: 4.2",
|
||||||
],
|
],
|
||||||
keywords="api graphql protocol rest relay graphene",
|
keywords="api graphql protocol rest relay graphene",
|
||||||
packages=find_packages(exclude=["tests", "examples", "examples.*"]),
|
packages=find_packages(exclude=["tests", "examples", "examples.*"]),
|
||||||
|
|
8
tox.ini
8
tox.ini
|
@ -1,8 +1,8 @@
|
||||||
[tox]
|
[tox]
|
||||||
envlist =
|
envlist =
|
||||||
py{37,38,39,310}-django32,
|
py{37,38,39,310}-django32,
|
||||||
py{38,39,310}-django{40,41,main},
|
py{38,39,310}-django{41,42,main},
|
||||||
py311-django{41,main}
|
py311-django{41,42,main}
|
||||||
pre-commit
|
pre-commit
|
||||||
|
|
||||||
[gh-actions]
|
[gh-actions]
|
||||||
|
@ -16,8 +16,8 @@ python =
|
||||||
[gh-actions:env]
|
[gh-actions:env]
|
||||||
DJANGO =
|
DJANGO =
|
||||||
3.2: django32
|
3.2: django32
|
||||||
4.0: django40
|
|
||||||
4.1: django41
|
4.1: django41
|
||||||
|
4.2: django42
|
||||||
main: djangomain
|
main: djangomain
|
||||||
|
|
||||||
[testenv]
|
[testenv]
|
||||||
|
@ -30,8 +30,8 @@ deps =
|
||||||
-e.[test]
|
-e.[test]
|
||||||
psycopg2-binary
|
psycopg2-binary
|
||||||
django32: Django>=3.2,<4.0
|
django32: Django>=3.2,<4.0
|
||||||
django40: Django>=4.0,<4.1
|
|
||||||
django41: Django>=4.1,<4.2
|
django41: Django>=4.1,<4.2
|
||||||
|
django42: Django>=4.2,<4.3
|
||||||
djangomain: https://github.com/django/django/archive/main.zip
|
djangomain: https://github.com/django/django/archive/main.zip
|
||||||
commands = {posargs:py.test --cov=graphene_django graphene_django examples}
|
commands = {posargs:py.test --cov=graphene_django graphene_django examples}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user