diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 139c6f6..5d5ae27 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -20,7 +20,7 @@ jobs:
pip install wheel
python setup.py sdist bdist_wheel
- 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:
user: __token__
password: ${{ secrets.pypi_password }}
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 2c5b755..dfc5194 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -8,13 +8,15 @@ jobs:
strategy:
max-parallel: 4
matrix:
- django: ["3.2", "4.0", "4.1"]
+ django: ["3.2", "4.1", "4.2"]
python-version: ["3.8", "3.9", "3.10"]
include:
- django: "3.2"
python-version: "3.7"
- django: "4.1"
python-version: "3.11"
+ - django: "4.2"
+ python-version: "3.11"
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
diff --git a/.gitignore b/.gitignore
index 150025a..3cf0d9a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,9 @@ __pycache__/
# Distribution / packaging
.Python
env/
+.env/
+venv/
+.venv/
build/
develop-eggs/
dist/
@@ -80,3 +83,8 @@ Session.vim
tags
.tox/
.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
diff --git a/docs/authorization.rst b/docs/authorization.rst
index bc88cda..8595def 100644
--- a/docs/authorization.rst
+++ b/docs/authorization.rst
@@ -144,6 +144,21 @@ If you are using ``DjangoObjectType`` you can define a custom `get_queryset`.
return queryset.filter(published=True)
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
------------------------------
@@ -197,8 +212,8 @@ For Django 2.2 and above:
.. code:: python
urlpatterns = [
- # some other urls
- path('graphql/', PrivateGraphQLView.as_view(graphiql=True, schema=schema)),
+ # some other urls
+ path('graphql/', PrivateGraphQLView.as_view(graphiql=True, schema=schema)),
]
.. _LoginRequiredMixin: https://docs.djangoproject.com/en/dev/topics/auth/default/#the-loginrequired-mixin
diff --git a/docs/conf.py b/docs/conf.py
index b83e0f0..1be08b1 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -78,7 +78,7 @@ release = "1.0.dev"
#
# This is also used if you do content translation via gettext catalogs.
# 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
# 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.
-intersphinx_mapping = {"https://docs.python.org/": None}
+intersphinx_mapping = {
+ # "https://docs.python.org/": None,
+ "python": ("https://docs.python.org/", None),
+}
diff --git a/docs/introspection.rst b/docs/introspection.rst
index 2097c30..a4ecaae 100644
--- a/docs/introspection.rst
+++ b/docs/introspection.rst
@@ -57,9 +57,9 @@ specify the parameters in your settings.py:
.. code:: python
GRAPHENE = {
- 'SCHEMA': 'tutorial.quickstart.schema',
- 'SCHEMA_OUTPUT': 'data/schema.json', # defaults to schema.json,
- 'SCHEMA_INDENT': 2, # Defaults to None (displays all data on a single line)
+ 'SCHEMA': 'tutorial.quickstart.schema',
+ 'SCHEMA_OUTPUT': 'data/schema.json', # defaults to schema.json,
+ 'SCHEMA_INDENT': 2, # Defaults to None (displays all data on a single line)
}
diff --git a/docs/mutations.rst b/docs/mutations.rst
index 2ce8f16..f43063f 100644
--- a/docs/mutations.rst
+++ b/docs/mutations.rst
@@ -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
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
---------------------
diff --git a/docs/requirements.txt b/docs/requirements.txt
index 7c89926..276ae37 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -1,4 +1,5 @@
-Sphinx==1.5.3
-sphinx-autobuild==0.7.1
+Sphinx==7.0.0
+sphinx-autobuild==2021.3.14
+pygments-graphql-lexer==0.1.0
# Docs template
http://graphene-python.org/sphinx_graphene_theme.zip
diff --git a/docs/settings.rst b/docs/settings.rst
index 5bffd08..d38d0c9 100644
--- a/docs/settings.rst
+++ b/docs/settings.rst
@@ -224,7 +224,7 @@ Default: ``/graphql``
``GRAPHIQL_SHOULD_PERSIST_HEADERS``
----------------------
+-----------------------------------
Set to ``True`` if you want to persist GraphiQL headers after refreshing the page.
diff --git a/docs/tutorial-relay.rst b/docs/tutorial-relay.rst
index a27e255..bf93299 100644
--- a/docs/tutorial-relay.rst
+++ b/docs/tutorial-relay.rst
@@ -12,7 +12,7 @@ app `__
-* `GraphQL Relay Specification `__
+* `GraphQL Relay Specification `__
Setup the Django project
------------------------
diff --git a/examples/cookbook-plain/README.md b/examples/cookbook-plain/README.md
index dcd2420..b120b78 100644
--- a/examples/cookbook-plain/README.md
+++ b/examples/cookbook-plain/README.md
@@ -62,3 +62,12 @@ Now head on over to
and run some queries!
(See the [Graphene-Django Tutorial](http://docs.graphene-python.org/projects/django/en/latest/tutorial-plain/#testing-our-graphql-schema)
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
+```
diff --git a/examples/cookbook-plain/cookbook/settings.py b/examples/cookbook-plain/cookbook/settings.py
index 7eb9d56..f07f6f6 100644
--- a/examples/cookbook-plain/cookbook/settings.py
+++ b/examples/cookbook-plain/cookbook/settings.py
@@ -5,10 +5,10 @@ Django settings for cookbook project.
Generated by 'django-admin startproject' using Django 1.9.
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
-https://docs.djangoproject.com/en/1.9/ref/settings/
+https://docs.djangoproject.com/en/3.2/ref/settings/
"""
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
-# 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!
SECRET_KEY = "_$=$%eqxk$8ss4n7mtgarw^5$8^d5+c83!vwatr@i_81myb=e4"
@@ -81,7 +81,7 @@ WSGI_APPLICATION = "cookbook.wsgi.application"
# Database
-# https://docs.djangoproject.com/en/1.9/ref/settings/#databases
+# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
DATABASES = {
"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
-# 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 = [
{
@@ -105,7 +107,7 @@ AUTH_PASSWORD_VALIDATORS = [
# Internationalization
-# https://docs.djangoproject.com/en/1.9/topics/i18n/
+# https://docs.djangoproject.com/en/3.2/topics/i18n/
LANGUAGE_CODE = "en-us"
@@ -119,6 +121,6 @@ USE_TZ = True
# 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/"
diff --git a/examples/cookbook-plain/requirements.txt b/examples/cookbook-plain/requirements.txt
index 85a8963..6e02745 100644
--- a/examples/cookbook-plain/requirements.txt
+++ b/examples/cookbook-plain/requirements.txt
@@ -1,4 +1,3 @@
-graphene>=2.1,<3
-graphene-django>=2.1,<3
-graphql-core>=2.1,<3
-django==3.1.14
+django~=3.2
+graphene
+graphene-django>=3.1
diff --git a/graphene_django/__init__.py b/graphene_django/__init__.py
index 12408a4..676c674 100644
--- a/graphene_django/__init__.py
+++ b/graphene_django/__init__.py
@@ -1,11 +1,13 @@
from .fields import DjangoConnectionField, DjangoListField
from .types import DjangoObjectType
+from .utils import bypass_get_queryset
-__version__ = "3.0.2"
+__version__ = "3.1.3"
__all__ = [
"__version__",
"DjangoObjectType",
"DjangoListField",
"DjangoConnectionField",
+ "bypass_get_queryset",
]
diff --git a/graphene_django/compat.py b/graphene_django/compat.py
index b0e4753..4b48f03 100644
--- a/graphene_django/compat.py
+++ b/graphene_django/compat.py
@@ -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:
def __init__(self, *args, **kwargs):
pass
@@ -10,16 +16,7 @@ try:
IntegerRangeField,
ArrayField,
HStoreField,
- JSONField as PGJSONField,
RangeField,
)
except ImportError:
- IntegerRangeField, ArrayField, HStoreField, PGJSONField, RangeField = (
- MissingType,
- ) * 5
-
-try:
- # JSONField is only available from Django 3.1
- from django.db.models import JSONField
-except ImportError:
- JSONField = MissingType
+ IntegerRangeField, ArrayField, HStoreField, RangeField = (MissingType,) * 4
diff --git a/graphene_django/converter.py b/graphene_django/converter.py
index 375d683..f27119a 100644
--- a/graphene_django/converter.py
+++ b/graphene_django/converter.py
@@ -1,5 +1,6 @@
+import inspect
from collections import OrderedDict
-from functools import singledispatch, wraps
+from functools import partial, singledispatch, wraps
from django.db import models
from django.utils.encoding import force_str
@@ -25,6 +26,7 @@ from graphene import (
)
from graphene.types.json import JSONString
from graphene.types.scalars import BigInt
+from graphene.types.resolver import get_default_resolver
from graphene.utils.str_converters import to_camel_case
from graphql import GraphQLError
@@ -35,7 +37,7 @@ except ImportError:
from graphql import assert_valid_name as assert_name
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 .settings import graphene_settings
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)
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
def dynamic_type():
@@ -265,7 +270,52 @@ def convert_onetoone_field_to_djangomodel(field, registry=None):
if not _type:
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)
@@ -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.ForeignKey)
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
def dynamic_type():
@@ -320,7 +373,79 @@ def convert_field_to_djangomodel(field, registry=None):
if not _type:
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,
description=get_django_field_description(field),
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(PGJSONField)
-@convert_django_field.register(JSONField)
-def convert_pg_and_json_field_to_string(field, registry=None):
+@convert_django_field.register(models.JSONField)
+def convert_json_field_to_string(field, registry=None):
return JSONString(
description=get_django_field_description(field), required=not field.null
)
diff --git a/graphene_django/forms/tests/test_djangoinputobject.py b/graphene_django/forms/tests/test_djangoinputobject.py
new file mode 100644
index 0000000..2809d2f
--- /dev/null
+++ b/graphene_django/forms/tests/test_djangoinputobject.py
@@ -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
diff --git a/graphene_django/forms/types.py b/graphene_django/forms/types.py
index 74e275e..132095b 100644
--- a/graphene_django/forms/types.py
+++ b/graphene_django/forms/types.py
@@ -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 ..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()
diff --git a/graphene_django/rest_framework/mutation.py b/graphene_django/rest_framework/mutation.py
index 4062a44..b7393da 100644
--- a/graphene_django/rest_framework/mutation.py
+++ b/graphene_django/rest_framework/mutation.py
@@ -39,6 +39,9 @@ def fields_for_serializer(
field.read_only
and is_input
and lookup_field != name, # don't show read_only fields in Input
+ isinstance(
+ field, serializers.HiddenField
+ ), # don't show hidden fields in Input
]
)
diff --git a/graphene_django/rest_framework/tests/test_mutation.py b/graphene_django/rest_framework/tests/test_mutation.py
index 5de8237..91d99f0 100644
--- a/graphene_django/rest_framework/tests/test_mutation.py
+++ b/graphene_django/rest_framework/tests/test_mutation.py
@@ -164,6 +164,21 @@ def test_read_only_fields():
), "'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():
class MyFakeModelGrapheneType(DjangoObjectType):
class Meta:
diff --git a/graphene_django/templates/graphene/graphiql.html b/graphene_django/templates/graphene/graphiql.html
index ddff8fc..52421e8 100644
--- a/graphene_django/templates/graphene/graphiql.html
+++ b/graphene_django/templates/graphene/graphiql.html
@@ -21,6 +21,10 @@ add "&raw" to the end of the URL within a browser.
integrity="{{graphiql_css_sri}}"
rel="stylesheet"
crossorigin="anonymous" />
+
diff --git a/graphene_django/tests/models.py b/graphene_django/tests/models.py
index 085a508..e729838 100644
--- a/graphene_django/tests/models.py
+++ b/graphene_django/tests/models.py
@@ -43,7 +43,7 @@ class Reporter(models.Model):
last_name = models.CharField(max_length=30)
email = models.EmailField()
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()
doe_objects = DoeReporterManager()
fans = models.ManyToManyField(Person)
diff --git a/graphene_django/tests/test_command.py b/graphene_django/tests/test_command.py
index a281abb..f7325d5 100644
--- a/graphene_django/tests/test_command.py
+++ b/graphene_django/tests/test_command.py
@@ -46,7 +46,7 @@ def test_generate_graphql_file_on_call_graphql_schema():
open_mock.assert_called_once()
handle = open_mock()
- assert handle.write.called_once()
+ handle.write.assert_called_once()
schema_output = handle.write.call_args[0][0]
assert schema_output == dedent(
diff --git a/graphene_django/tests/test_converter.py b/graphene_django/tests/test_converter.py
index 4996505..7f4e350 100644
--- a/graphene_django/tests/test_converter.py
+++ b/graphene_django/tests/test_converter.py
@@ -15,8 +15,6 @@ from graphene.types.scalars import BigInt
from ..compat import (
ArrayField,
HStoreField,
- JSONField,
- PGJSONField,
MissingType,
RangeField,
)
@@ -372,16 +370,6 @@ def test_should_postgres_hstore_convert_string():
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")
def test_should_postgres_range_convert_list():
from django.contrib.postgres.fields import IntegerRangeField
diff --git a/graphene_django/tests/test_get_queryset.py b/graphene_django/tests/test_get_queryset.py
index 7cbaa54..99f50c7 100644
--- a/graphene_django/tests/test_get_queryset.py
+++ b/graphene_django/tests/test_get_queryset.py
@@ -8,7 +8,7 @@ from graphql_relay import to_global_id
from ..fields import DjangoConnectionField
from ..types import DjangoObjectType
-from .models import Article, Reporter
+from .models import Article, Reporter, FilmDetails, Film
class TestShouldCallGetQuerySetOnForeignKey:
@@ -127,6 +127,69 @@ class TestShouldCallGetQuerySetOnForeignKey:
assert not result.errors
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:
"""
@@ -233,3 +296,272 @@ class TestShouldCallGetQuerySetOnForeignKeyNode:
)
assert not result.errors
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."
diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py
index bbc2c90..91bacbd 100644
--- a/graphene_django/tests/test_query.py
+++ b/graphene_django/tests/test_query.py
@@ -128,13 +128,12 @@ def test_should_query_postgres_fields():
from django.contrib.postgres.fields import (
IntegerRangeField,
ArrayField,
- JSONField,
HStoreField,
)
class Event(models.Model):
ages = IntegerRangeField(help_text="The age ranges")
- data = JSONField(help_text="Data")
+ data = models.JSONField(help_text="Data")
store = HStoreField()
tags = ArrayField(models.CharField(max_length=50))
diff --git a/graphene_django/types.py b/graphene_django/types.py
index a6e54af..dec8723 100644
--- a/graphene_django/types.py
+++ b/graphene_django/types.py
@@ -252,6 +252,7 @@ class DjangoObjectType(ObjectType):
_meta.filterset_class = filterset_class
_meta.fields = django_fields
_meta.connection = connection
+ _meta.convert_choices_to_enum = convert_choices_to_enum
super().__init_subclass_with_meta__(
_meta=_meta, interfaces=interfaces, **options
diff --git a/graphene_django/utils/__init__.py b/graphene_django/utils/__init__.py
index 671b060..e4780e6 100644
--- a/graphene_django/utils/__init__.py
+++ b/graphene_django/utils/__init__.py
@@ -6,6 +6,7 @@ from .utils import (
get_reverse_fields,
is_valid_django_model,
maybe_queryset,
+ bypass_get_queryset,
)
__all__ = [
@@ -16,4 +17,5 @@ __all__ = [
"camelize",
"is_valid_django_model",
"GraphQLTestCase",
+ "bypass_get_queryset",
]
diff --git a/graphene_django/utils/utils.py b/graphene_django/utils/utils.py
index 187442c..d7993e7 100644
--- a/graphene_django/utils/utils.py
+++ b/graphene_django/utils/utils.py
@@ -136,3 +136,12 @@ def set_rollback():
atomic_requests = connection.settings_dict.get("ATOMIC_REQUESTS", False)
if atomic_requests and connection.in_atomic_block:
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
diff --git a/graphene_django/views.py b/graphene_django/views.py
index bdc0fdb..3fb87d4 100644
--- a/graphene_django/views.py
+++ b/graphene_django/views.py
@@ -66,18 +66,21 @@ class GraphQLView(View):
react_dom_sri = "sha256-nbMykgB6tsOFJ7OdVmPpdqMFVk4ZsqWocT6issAPUF0="
# The GraphiQL React app.
- graphiql_version = "2.4.1" # "1.0.3"
- graphiql_sri = "sha256-s+f7CFAPSUIygFnRC2nfoiEKd3liCUy+snSdYFAoLUc=" # "sha256-VR4buIDY9ZXSyCNFHFNik6uSe0MhigCzgN4u7moCOTk="
- graphiql_css_sri = "sha256-88yn8FJMyGboGs4Bj+Pbb3kWOWXo7jmb+XCRHE+282k=" # "sha256-LwqxjyZgqXDYbpxQJ5zLQeNcf7WVNSJ+r8yp2rnWE/E="
+ graphiql_version = "2.4.7"
+ graphiql_sri = "sha256-n/LKaELupC1H/PU6joz+ybeRJHT2xCdekEt6OYMOOZU="
+ graphiql_css_sri = "sha256-OsbM+LQHcnFHi0iH7AUKueZvDcEBoy/z4hJ7jx1cpsM="
# The websocket transport library for subscriptions.
- subscriptions_transport_ws_version = "5.12.1"
+ subscriptions_transport_ws_version = "5.13.1"
subscriptions_transport_ws_sri = (
"sha256-EZhvg6ANJrBsgLvLAa0uuHNLepLJVCFYS+xlb5U/bqw="
)
graphiql_plugin_explorer_version = "0.1.15"
graphiql_plugin_explorer_sri = "sha256-3hUuhBXdXlfCj6RTeEkJFtEh/kUG+TCDASFpFPLrzvE="
+ graphiql_plugin_explorer_css_sri = (
+ "sha256-fA0LPUlukMNR6L4SPSeFqDTYav8QdWjQ2nr559Zln1U="
+ )
schema = None
graphiql = False
@@ -105,17 +108,19 @@ class GraphQLView(View):
if middleware is None:
middleware = graphene_settings.MIDDLEWARE
- self.schema = self.schema or schema
+ self.schema = schema or self.schema
if middleware is not None:
if isinstance(middleware, MiddlewareManager):
self.middleware = middleware
else:
self.middleware = list(instantiate_middleware(middleware))
self.root_value = root_value
- self.pretty = self.pretty or pretty
- self.graphiql = self.graphiql or graphiql
- self.batch = self.batch or batch
- self.execution_context_class = execution_context_class
+ self.pretty = pretty or self.pretty
+ self.graphiql = graphiql or self.graphiql
+ self.batch = batch or self.batch
+ self.execution_context_class = (
+ execution_context_class or self.execution_context_class
+ )
if subscription_path is None:
self.subscription_path = graphene_settings.SUBSCRIPTION_PATH
diff --git a/setup.py b/setup.py
index 7407b62..87842bb 100644
--- a/setup.py
+++ b/setup.py
@@ -38,6 +38,7 @@ setup(
version=version,
description="Graphene Django integration",
long_description=open("README.md").read(),
+ long_description_content_type="text/markdown",
url="https://github.com/graphql-python/graphene-django",
author="Syrus Akbary",
author_email="me@syrusakbary.com",
@@ -55,8 +56,8 @@ setup(
"Programming Language :: Python :: Implementation :: PyPy",
"Framework :: Django",
"Framework :: Django :: 3.2",
- "Framework :: Django :: 4.0",
"Framework :: Django :: 4.1",
+ "Framework :: Django :: 4.2",
],
keywords="api graphql protocol rest relay graphene",
packages=find_packages(exclude=["tests", "examples", "examples.*"]),
diff --git a/tox.ini b/tox.ini
index e186f30..9739b1c 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,8 +1,8 @@
[tox]
envlist =
py{37,38,39,310}-django32,
- py{38,39,310}-django{40,41,main},
- py311-django{41,main}
+ py{38,39,310}-django{41,42,main},
+ py311-django{41,42,main}
pre-commit
[gh-actions]
@@ -16,8 +16,8 @@ python =
[gh-actions:env]
DJANGO =
3.2: django32
- 4.0: django40
4.1: django41
+ 4.2: django42
main: djangomain
[testenv]
@@ -30,8 +30,8 @@ deps =
-e.[test]
psycopg2-binary
django32: Django>=3.2,<4.0
- django40: Django>=4.0,<4.1
django41: Django>=4.1,<4.2
+ django42: Django>=4.2,<4.3
djangomain: https://github.com/django/django/archive/main.zip
commands = {posargs:py.test --cov=graphene_django graphene_django examples}