diff --git a/.gitignore b/.gitignore index 150025ab2..ff6bd968c 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,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/mutations.rst b/docs/mutations.rst index 2ce8f16b2..f43063fe4 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/graphene_django/forms/tests/test_djangoinputobject.py b/graphene_django/forms/tests/test_djangoinputobject.py new file mode 100644 index 000000000..2809d2fc5 --- /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 74e275ec0..132095b22 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/tests/models.py b/graphene_django/tests/models.py index 636f74c6d..735f23648 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/types.py b/graphene_django/types.py index a6e54af41..dec872373 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -252,6 +252,7 @@ def __init_subclass_with_meta__( _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