Merge branch 'main' into proxy-model-supports-reverse-relationship

This commit is contained in:
Firas Kafri 2023-07-18 20:17:17 +03:00 committed by GitHub
commit 5b5fca47f6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 1093 additions and 73 deletions

View File

@ -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 }}

View File

@ -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
View File

@ -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

View File

@ -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

View File

@ -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),
}

View File

@ -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)
} }

View File

@ -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
--------------------- ---------------------

View File

@ -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

View File

@ -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.

View File

@ -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
------------------------ ------------------------

View File

@ -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
```

View File

@ -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/"

View File

@ -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

View File

@ -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",
] ]

View File

@ -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

View File

@ -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
) )

View 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

View File

@ -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()

View File

@ -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
] ]
) )

View File

@ -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:

View File

@ -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>

View File

@ -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)

View File

@ -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(

View File

@ -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

View File

@ -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."

View File

@ -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))

View File

@ -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

View File

@ -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",
] ]

View File

@ -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

View File

@ -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

View File

@ -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.*"]),

View File

@ -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}