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 2d9d43c..3cf0d9a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,8 @@ __pycache__/ .Python env/ .env/ +venv/ +.venv/ build/ develop-eggs/ dist/ @@ -81,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/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/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/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..d4ef76d 100644 --- a/graphene_django/__init__.py +++ b/graphene_django/__init__.py @@ -1,7 +1,7 @@ from .fields import DjangoConnectionField, DjangoListField from .types import DjangoObjectType -__version__ = "3.0.2" +__version__ = "3.1.1" __all__ = [ "__version__", diff --git a/graphene_django/compat.py b/graphene_django/compat.py index b0e4753..2fcecf6 100644 --- a/graphene_django/compat.py +++ b/graphene_django/compat.py @@ -10,16 +10,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 3a07372..1fcc03f 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -37,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 @@ -467,9 +467,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 636f74c..735f236 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() 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_query.py b/graphene_django/tests/test_query.py index 383ff2e..68bdc7d 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -119,13 +119,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/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}