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}