From ec45b89e47303801e98656c06e21dd1ec0ee0201 Mon Sep 17 00:00:00 2001 From: Grant McConnaughey Date: Tue, 18 Jul 2017 10:15:25 -0500 Subject: [PATCH 1/6] Add Django form-based mutations --- graphene_django/forms/__init__.py | 1 + graphene_django/forms/converter.py | 90 +++++++++++ graphene_django/{ => forms}/forms.py | 0 graphene_django/forms/mutation.py | 157 +++++++++++++++++++ graphene_django/forms/tests/__init__.py | 0 graphene_django/forms/tests/test_coverter.py | 98 ++++++++++++ graphene_django/forms/tests/test_mutation.py | 49 ++++++ graphene_django/forms/types.py | 6 + 8 files changed, 401 insertions(+) create mode 100644 graphene_django/forms/__init__.py create mode 100644 graphene_django/forms/converter.py rename graphene_django/{ => forms}/forms.py (100%) create mode 100644 graphene_django/forms/mutation.py create mode 100644 graphene_django/forms/tests/__init__.py create mode 100644 graphene_django/forms/tests/test_coverter.py create mode 100644 graphene_django/forms/tests/test_mutation.py create mode 100644 graphene_django/forms/types.py diff --git a/graphene_django/forms/__init__.py b/graphene_django/forms/__init__.py new file mode 100644 index 000000000..9559be4a4 --- /dev/null +++ b/graphene_django/forms/__init__.py @@ -0,0 +1 @@ +from .forms import GlobalIDFormField, GlobalIDMultipleChoiceField diff --git a/graphene_django/forms/converter.py b/graphene_django/forms/converter.py new file mode 100644 index 000000000..9d7b2428a --- /dev/null +++ b/graphene_django/forms/converter.py @@ -0,0 +1,90 @@ +from django import forms +from django.core.exceptions import ImproperlyConfigured +from graphene_django.utils import import_single_dispatch +import graphene + + +singledispatch = import_single_dispatch() + + +def convert_form_to_input_type(form_class): + form = form_class() + + items = { + name: convert_form_field(field) + for name, field in form.fields.items() + } + + return type( + '{}Input'.format(form.__class__.__name__), + (graphene.InputObjectType, ), + items + ) + + +@singledispatch +def get_graphene_type_from_form_field(field): + raise ImproperlyConfigured( + "Don't know how to convert the form field %s (%s) " + "to Graphene type" % (field, field.__class__) + ) + + +def convert_form_field(field, is_input=True): + """ + Converts a Django form field to a graphql field and marks the field as + required if we are creating an input type and the field itself is required + """ + + graphql_type = get_graphene_type_from_form_field(field) + + kwargs = { + 'description': field.help_text, + 'required': is_input and field.required, + } + + # if it is a tuple or a list it means that we are returning + # the graphql type and the child type + if isinstance(graphql_type, (list, tuple)): + kwargs['of_type'] = graphql_type[1] + graphql_type = graphql_type[0] + + return graphql_type(**kwargs) + + +@get_graphene_type_from_form_field.register(forms.CharField) +@get_graphene_type_from_form_field.register(forms.ChoiceField) +def convert_form_field_to_string(field): + return graphene.String + + +@get_graphene_type_from_form_field.register(forms.IntegerField) +def convert_form_field_to_int(field): + return graphene.Int + + +@get_graphene_type_from_form_field.register(forms.BooleanField) +def convert_form_field_to_bool(field): + return graphene.Boolean + + +@get_graphene_type_from_form_field.register(forms.FloatField) +@get_graphene_type_from_form_field.register(forms.DecimalField) +def convert_form_field_to_float(field): + return graphene.Float + + +@get_graphene_type_from_form_field.register(forms.DateField) +@get_graphene_type_from_form_field.register(forms.DateTimeField) +def convert_form_field_to_datetime(field): + return graphene.types.datetime.DateTime + + +@get_graphene_type_from_form_field.register(forms.TimeField) +def convert_form_field_to_time(field): + return graphene.types.datetime.Time + + +@get_graphene_type_from_form_field.register(forms.MultipleChoiceField) +def convert_form_field_to_list_of_string(field): + return (graphene.List, graphene.String) diff --git a/graphene_django/forms.py b/graphene_django/forms/forms.py similarity index 100% rename from graphene_django/forms.py rename to graphene_django/forms/forms.py diff --git a/graphene_django/forms/mutation.py b/graphene_django/forms/mutation.py new file mode 100644 index 000000000..d9e27aa0b --- /dev/null +++ b/graphene_django/forms/mutation.py @@ -0,0 +1,157 @@ +from functools import partial + +import six +import graphene +from graphene import Field, Argument +from graphene.types.mutation import MutationMeta +from graphene.types.objecttype import ObjectTypeMeta +from graphene.types.options import Options +from graphene.types.utils import get_field_as, merge +from graphene.utils.is_base_type import is_base_type +from graphene_django.registry import get_global_registry + +from .converter import convert_form_to_input_type +from .types import ErrorType + + +class FormMutationMeta(MutationMeta): + def __new__(cls, name, bases, attrs): + if not is_base_type(bases, FormMutationMeta): + return type.__new__(cls, name, bases, attrs) + + options = Options( + attrs.pop('Meta', None), + name=name, + description=attrs.pop('__doc__', None), + form_class=None, + input_field_name='input', + local_fields=None, + only_fields=(), + exclude_fields=(), + interfaces=(), + registry=None + ) + + if not options.form_class: + raise Exception('Missing form_class') + + cls = ObjectTypeMeta.__new__( + cls, name, bases, dict(attrs, _meta=options) + ) + + options.fields = merge( + options.interface_fields, options.base_fields, options.local_fields, + {'errors': get_field_as(cls.errors, Field)} + ) + + cls.Input = convert_form_to_input_type(options.form_class) + + field_kwargs = {cls.options.input_field_name: Argument(cls.Input, required=True)} + cls.Field = partial( + Field, + cls, + resolver=cls.mutate, + **field_kwargs + ) + + return cls + + +class BaseFormMutation(graphene.Mutation): + + @classmethod + def mutate(cls, root, args, context, info): + form = cls.get_form(root, args, context, info) + + if form.is_valid(): + return cls.perform_mutate(form, info) + else: + errors = [ + ErrorType(field=key, messages=value) + for key, value in form.errors.items() + ] + return cls(errors=errors) + + @classmethod + def perform_mutate(cls, form, info): + form.save() + return cls(errors=[]) + + @classmethod + def get_form(cls, root, args, context, info): + form_data = args.get(cls._meta.input_field_name) + kwargs = cls.get_form_kwargs(root, args, context, info) + return cls._meta.form_class(data=form_data, **kwargs) + + @classmethod + def get_form_kwargs(cls, root, args, context, info): + return {} + + +class FormMutation(six.with_metaclass(FormMutationMeta, BaseFormMutation)): + + errors = graphene.List(ErrorType) + + +class ModelFormMutationMeta(MutationMeta): + def __new__(cls, name, bases, attrs): + if not is_base_type(bases, ModelFormMutationMeta): + return type.__new__(cls, name, bases, attrs) + + options = Options( + attrs.pop('Meta', None), + name=name, + description=attrs.pop('__doc__', None), + form_class=None, + input_field_name='input', + return_field_name=None, + model=None, + local_fields=None, + only_fields=(), + exclude_fields=(), + interfaces=(), + registry=None + ) + + if not options.form_class: + raise Exception('Missing form_class') + + cls = ObjectTypeMeta.__new__( + cls, name, bases, dict(attrs, _meta=options) + ) + + options.fields = merge( + options.interface_fields, options.base_fields, options.local_fields, + {'errors': get_field_as(cls.errors, Field)} + ) + + cls.Input = convert_form_to_input_type(options.form_class) + + field_kwargs = {cls.options.input_field_name: Argument(cls.Input, required=True)} + cls.Field = partial( + Field, + cls, + resolver=cls.mutate, + **field_kwargs + ) + + cls.model = options.model or options.form_class.Meta.model + cls.return_field_name = cls._meta.return_field_name or cls.model._meta.model_name + + registry = get_global_registry() + model_type = registry.get_type_for_model(cls.model) + + options.fields[cls.return_field_name] = graphene.Field(model_type) + + return cls + + +class ModelFormMutation(six.with_metaclass(ModelFormMutationMeta, BaseFormMutation)): + + errors = graphene.List(ErrorType) + + @classmethod + def perform_mutate(cls, form, info): + obj = form.save() + kwargs = {cls.return_field_name: obj} + return cls(errors=[], **kwargs) diff --git a/graphene_django/forms/tests/__init__.py b/graphene_django/forms/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/graphene_django/forms/tests/test_coverter.py b/graphene_django/forms/tests/test_coverter.py new file mode 100644 index 000000000..e4a686b3c --- /dev/null +++ b/graphene_django/forms/tests/test_coverter.py @@ -0,0 +1,98 @@ +import copy + +from django import forms +from py.test import raises + +import graphene + +from ..converter import convert_form_field + + +def _get_type(form_field, **kwargs): + # prevents the following error: + # AssertionError: The `source` argument is not meaningful when applied to a `child=` field. + # Remove `source=` from the field declaration. + # since we are reusing the same child in when testing the required attribute + + if 'child' in kwargs: + kwargs['child'] = copy.deepcopy(kwargs['child']) + + field = form_field(**kwargs) + + return convert_form_field(field) + + +def assert_conversion(form_field, graphene_field, **kwargs): + graphene_type = _get_type(form_field, help_text='Custom Help Text', **kwargs) + assert isinstance(graphene_type, graphene_field) + + graphene_type_required = _get_type( + form_field, help_text='Custom Help Text', required=True, **kwargs + ) + assert isinstance(graphene_type_required, graphene_field) + + return graphene_type + + +def test_should_unknown_form_field_raise_exception(): + with raises(Exception) as excinfo: + convert_form_field(None) + assert 'Don\'t know how to convert the form field' in str(excinfo.value) + + +def test_should_charfield_convert_string(): + assert_conversion(forms.CharField, graphene.String) + + +def test_should_timefield_convert_time(): + assert_conversion(forms.TimeField, graphene.types.datetime.Time) + + +def test_should_email_convert_string(): + assert_conversion(forms.EmailField, graphene.String) + + +def test_should_slug_convert_string(): + assert_conversion(forms.SlugField, graphene.String) + + +def test_should_url_convert_string(): + assert_conversion(forms.URLField, graphene.String) + + +def test_should_choicefield_convert_string(): + assert_conversion(forms.ChoiceField, graphene.String, choices=[]) + + +def test_should_regexfield_convert_string(): + assert_conversion(forms.RegexField, graphene.String, regex='[0-9]+') + + +def test_should_uuidfield_convert_string(): + assert_conversion(forms.UUIDField, graphene.String) + + +def test_should_integer_convert_int(): + assert_conversion(forms.IntegerField, graphene.Int) + + +def test_should_boolean_convert_boolean(): + assert_conversion(forms.BooleanField, graphene.Boolean) + + +def test_should_float_convert_float(): + assert_conversion(forms.FloatField, graphene.Float) + + +def test_should_decimal_convert_float(): + assert_conversion(forms.DecimalField, graphene.Float, max_digits=4, decimal_places=2) + + +def test_should_filepath_convert_string(): + assert_conversion(forms.FilePathField, graphene.String, path='/') + + +def test_should_multiplechoicefield_convert_to_list_of_string(): + field = assert_conversion(forms.MultipleChoiceField, graphene.List, choices=[1, 2, 3]) + + assert field.of_type == graphene.String diff --git a/graphene_django/forms/tests/test_mutation.py b/graphene_django/forms/tests/test_mutation.py new file mode 100644 index 000000000..004f5d31d --- /dev/null +++ b/graphene_django/forms/tests/test_mutation.py @@ -0,0 +1,49 @@ +from django import forms +from py.test import raises + +from graphene_django.tests.models import Pet +from ..mutation import FormMutation, ModelFormMutation + + +class MyForm(forms.Form): + text = forms.CharField() + + +class PetForm(forms.ModelForm): + + class Meta: + model = Pet + fields = ('name',) + + +def test_needs_form_class(): + with raises(Exception) as exc: + class MyMutation(FormMutation): + pass + + assert exc.value.args[0] == 'Missing form_class' + + +def test_has_fields(): + class MyMutation(FormMutation): + class Meta: + form_class = MyForm + + assert 'errors' in MyMutation._meta.fields + + +def test_has_input_fields(): + class MyMutation(FormMutation): + class Meta: + form_class = MyForm + + assert 'text' in MyMutation.Input._meta.fields + + +def test_model_form(): + class PetMutation(ModelFormMutation): + class Meta: + form_class = PetForm + + assert PetMutation.model == Pet + assert PetMutation.return_field_name == 'pet' diff --git a/graphene_django/forms/types.py b/graphene_django/forms/types.py new file mode 100644 index 000000000..1fe33f38e --- /dev/null +++ b/graphene_django/forms/types.py @@ -0,0 +1,6 @@ +import graphene + + +class ErrorType(graphene.ObjectType): + field = graphene.String() + messages = graphene.List(graphene.String) From 257c69b57312c4d697fe5cdde5d502753451e234 Mon Sep 17 00:00:00 2001 From: Grant McConnaughey Date: Tue, 18 Jul 2017 10:31:17 -0500 Subject: [PATCH 2/6] Use options correctly --- graphene_django/forms/mutation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/graphene_django/forms/mutation.py b/graphene_django/forms/mutation.py index d9e27aa0b..291a7af6e 100644 --- a/graphene_django/forms/mutation.py +++ b/graphene_django/forms/mutation.py @@ -46,7 +46,7 @@ def __new__(cls, name, bases, attrs): cls.Input = convert_form_to_input_type(options.form_class) - field_kwargs = {cls.options.input_field_name: Argument(cls.Input, required=True)} + field_kwargs = {options.input_field_name: Argument(cls.Input, required=True)} cls.Field = partial( Field, cls, @@ -127,7 +127,7 @@ def __new__(cls, name, bases, attrs): cls.Input = convert_form_to_input_type(options.form_class) - field_kwargs = {cls.options.input_field_name: Argument(cls.Input, required=True)} + field_kwargs = {options.input_field_name: Argument(cls.Input, required=True)} cls.Field = partial( Field, cls, From d34fa433c36f7149e683e61cb0b15b836c80e3a3 Mon Sep 17 00:00:00 2001 From: Grant McConnaughey Date: Tue, 18 Jul 2017 10:37:08 -0500 Subject: [PATCH 3/6] Fix flake8 issue --- graphene_django/forms/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/forms/__init__.py b/graphene_django/forms/__init__.py index 9559be4a4..066eec444 100644 --- a/graphene_django/forms/__init__.py +++ b/graphene_django/forms/__init__.py @@ -1 +1 @@ -from .forms import GlobalIDFormField, GlobalIDMultipleChoiceField +from .forms import GlobalIDFormField, GlobalIDMultipleChoiceField # noqa From 37ec06d53e6b3ac00d6d5eb6578cf65862ce1640 Mon Sep 17 00:00:00 2001 From: Grant McConnaughey Date: Tue, 18 Jul 2017 10:48:09 -0500 Subject: [PATCH 4/6] Test mutation --- graphene_django/forms/tests/test_mutation.py | 26 +++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/graphene_django/forms/tests/test_mutation.py b/graphene_django/forms/tests/test_mutation.py index 004f5d31d..9874ae653 100644 --- a/graphene_django/forms/tests/test_mutation.py +++ b/graphene_django/forms/tests/test_mutation.py @@ -1,4 +1,5 @@ from django import forms +from django.test import TestCase from py.test import raises from graphene_django.tests.models import Pet @@ -40,10 +41,23 @@ class Meta: assert 'text' in MyMutation.Input._meta.fields -def test_model_form(): - class PetMutation(ModelFormMutation): - class Meta: - form_class = PetForm +class ModelFormMutationTests(TestCase): + + def test_model_form_mutation(self): + class PetMutation(ModelFormMutation): + class Meta: + form_class = PetForm + + self.assertEqual(PetMutation.model, Pet) + self.assertEqual(PetMutation.return_field_name, 'pet') + + def test_model_form_mutation_mutate(self): + class PetMutation(ModelFormMutation): + class Meta: + form_class = PetForm + + PetMutation.mutate(None, {'input': {'name': 'Fluffy'}}, None, None) - assert PetMutation.model == Pet - assert PetMutation.return_field_name == 'pet' + self.assertEqual(Pet.objects.count(), 1) + pet = Pet.objects.get() + self.assertEqual(pet.name, 'Fluffy') From d7dca66dfe51a87f83643d30d5ae2d8d8a63c89d Mon Sep 17 00:00:00 2001 From: Grant McConnaughey Date: Tue, 18 Jul 2017 11:18:58 -0500 Subject: [PATCH 5/6] Document form mutations --- docs/form-mutations.rst | 67 +++++++++++++++++++++++++++++++++++++++++ docs/index.rst | 1 + 2 files changed, 68 insertions(+) create mode 100644 docs/form-mutations.rst diff --git a/docs/form-mutations.rst b/docs/form-mutations.rst new file mode 100644 index 000000000..f010d8a46 --- /dev/null +++ b/docs/form-mutations.rst @@ -0,0 +1,67 @@ +Integration with Django forms +============================= + +Graphene-Django comes with mutation classes that will convert the fields on Django forms into inputs on a mutation. + +FormMutation +------------ + +.. code:: python + + class MyForm(forms.Form): + name = forms.CharField() + + class MyMutation(FormMutation): + class Meta: + form_class = MyForm + +``MyMutation`` will automatically receive an ``input`` argument. This argument should be a ``dict`` where the key is ``name`` and the value is a string. + +ModelFormMutation +----------------- + +``ModelFormMutation`` will pull the fields from a ``ModelForm``. + +.. code:: python + + class Pet(models.Model): + name = models.CharField() + + class PetForm(forms.ModelForm): + class Meta: + model = Pet + fields = ('name',) + + # This will get returned when the mutation completes successfully + class PetType(DjangoObjectType): + class Meta: + model = Pet + + class PetMutation(ModelFormMutation): + class Meta: + form_class = PetForm + +``PetMutation`` will grab the fields from ``PetForm`` and turn them into inputs. If the form is valid then the mutation +will lookup the ``DjangoObjectType`` for the ``Pet`` model and return that under the key ``pet``. Otherwise it will +return a list of errors. + +You can change the input name (default is ``input``) and the return field name (default is the model name lowercase). + +.. code:: python + + class PetMutation(ModelFormMutation): + class Meta: + form_class = PetForm + input_field_name = 'data' + return_field_name = 'my_pet' + +Form validation +--------------- + +Form mutations will call ``is_valid()`` on your forms. + +If the form is valid then ``perform_mutate(form, info)`` is called on the mutation. Override this method 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. diff --git a/docs/index.rst b/docs/index.rst index ccc6bd4be..74b2e6fc5 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -11,4 +11,5 @@ Contents: filtering authorization debug + form-mutations introspection From 6c5771e47d4459ee3ac4331192c9d8834d3f656b Mon Sep 17 00:00:00 2001 From: Grant McConnaughey Date: Tue, 18 Jul 2017 11:20:59 -0500 Subject: [PATCH 6/6] Change form valid method names --- docs/form-mutations.rst | 2 +- graphene_django/forms/mutation.py | 20 ++++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/docs/form-mutations.rst b/docs/form-mutations.rst index f010d8a46..a498b5669 100644 --- a/docs/form-mutations.rst +++ b/docs/form-mutations.rst @@ -60,7 +60,7 @@ Form validation Form mutations will call ``is_valid()`` on your forms. -If the form is valid then ``perform_mutate(form, info)`` is called on the mutation. Override this method to change how +If the form is valid then ``form_valid(form, info)`` is called on the mutation. Override this method 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 diff --git a/graphene_django/forms/mutation.py b/graphene_django/forms/mutation.py index 291a7af6e..43726d4bf 100644 --- a/graphene_django/forms/mutation.py +++ b/graphene_django/forms/mutation.py @@ -64,19 +64,23 @@ def mutate(cls, root, args, context, info): form = cls.get_form(root, args, context, info) if form.is_valid(): - return cls.perform_mutate(form, info) + return cls.form_valid(form, info) else: - errors = [ - ErrorType(field=key, messages=value) - for key, value in form.errors.items() - ] - return cls(errors=errors) + return cls.form_invalid(form, info) @classmethod - def perform_mutate(cls, form, info): + def form_valid(cls, form, info): form.save() return cls(errors=[]) + @classmethod + def form_invalid(cls, form, info): + errors = [ + ErrorType(field=key, messages=value) + for key, value in form.errors.items() + ] + return cls(errors=errors) + @classmethod def get_form(cls, root, args, context, info): form_data = args.get(cls._meta.input_field_name) @@ -151,7 +155,7 @@ class ModelFormMutation(six.with_metaclass(ModelFormMutationMeta, BaseFormMutati errors = graphene.List(ErrorType) @classmethod - def perform_mutate(cls, form, info): + def form_valid(cls, form, info): obj = form.save() kwargs = {cls.return_field_name: obj} return cls(errors=[], **kwargs)