Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Django form mutations #217

Merged
merged 14 commits into from Jun 5, 2018
67 changes: 67 additions & 0 deletions 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 ``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
containing the name of the invalid form field, and ``messages``, a list of strings with the validation messages.
1 change: 1 addition & 0 deletions docs/index.rst
Expand Up @@ -12,4 +12,5 @@ Contents:
authorization
debug
rest-framework
form-mutations
introspection
2 changes: 1 addition & 1 deletion graphene_django/filter/utils.py
Expand Up @@ -8,7 +8,7 @@ def get_filtering_args_from_filterset(filterset_class, type):
a Graphene Field. These arguments will be available to
filter against in the GraphQL
"""
from ..form_converter import convert_form_field
from ..forms.converter import convert_form_field

args = {}
for name, filter_field in six.iteritems(filterset_class.base_filters):
Expand Down
1 change: 1 addition & 0 deletions graphene_django/forms/__init__.py
@@ -0,0 +1 @@
from .forms import GlobalIDFormField, GlobalIDMultipleChoiceField # noqa
@@ -1,12 +1,10 @@
from django import forms
from django.forms.fields import BaseTemporalField
from django.core.exceptions import ImproperlyConfigured

from graphene import ID, Boolean, Float, Int, List, String, UUID
import graphene

from .forms import GlobalIDFormField, GlobalIDMultipleChoiceField
from .utils import import_single_dispatch

singledispatch = import_single_dispatch()
from ..utils import import_single_dispatch

try:
UUIDField = forms.UUIDField
Expand All @@ -15,16 +13,19 @@ class UUIDField(object):
pass


singledispatch = import_single_dispatch()


@singledispatch
def convert_form_field(field):
raise Exception(
raise ImproperlyConfigured(
"Don't know how to convert the Django form field %s (%s) "
"to Graphene type" %
(field, field.__class__)
)


@convert_form_field.register(BaseTemporalField)
@convert_form_field.register(forms.fields.BaseTemporalField)
@convert_form_field.register(forms.CharField)
@convert_form_field.register(forms.EmailField)
@convert_form_field.register(forms.SlugField)
Expand All @@ -33,43 +34,54 @@ def convert_form_field(field):
@convert_form_field.register(forms.RegexField)
@convert_form_field.register(forms.Field)
def convert_form_field_to_string(field):
return String(description=field.help_text, required=field.required)
return graphene.String(description=field.help_text, required=field.required)


@convert_form_field.register(UUIDField)
def convert_form_field_to_uuid(field):
return UUID(description=field.help_text, required=field.required)
return graphene.UUID(description=field.help_text, required=field.required)


@convert_form_field.register(forms.IntegerField)
@convert_form_field.register(forms.NumberInput)
def convert_form_field_to_int(field):
return Int(description=field.help_text, required=field.required)
return graphene.Int(description=field.help_text, required=field.required)


@convert_form_field.register(forms.BooleanField)
def convert_form_field_to_boolean(field):
return Boolean(description=field.help_text, required=True)
return graphene.Boolean(description=field.help_text, required=True)


@convert_form_field.register(forms.NullBooleanField)
def convert_form_field_to_nullboolean(field):
return Boolean(description=field.help_text)
return graphene.Boolean(description=field.help_text)


@convert_form_field.register(forms.DecimalField)
@convert_form_field.register(forms.FloatField)
def convert_form_field_to_float(field):
return Float(description=field.help_text, required=field.required)
return graphene.Float(description=field.help_text, required=field.required)


@convert_form_field.register(forms.ModelMultipleChoiceField)
@convert_form_field.register(GlobalIDMultipleChoiceField)
def convert_form_field_to_list(field):
return List(ID, required=field.required)
return graphene.List(graphene.ID, required=field.required)


@convert_form_field.register(forms.ModelChoiceField)
@convert_form_field.register(GlobalIDFormField)
def convert_form_field_to_id(field):
return ID(required=field.required)
return graphene.ID(required=field.required)


@convert_form_field.register(forms.DateField)
@convert_form_field.register(forms.DateTimeField)
def convert_form_field_to_datetime(field):
return graphene.types.datetime.DateTime(description=field.help_text, required=field.required)


@convert_form_field.register(forms.TimeField)
def convert_form_field_to_time(field):
return graphene.types.datetime.Time(description=field.help_text, required=field.required)
File renamed without changes.
141 changes: 141 additions & 0 deletions graphene_django/forms/mutation.py
@@ -0,0 +1,141 @@
from collections import OrderedDict

import graphene
from graphene import Field, InputField
from graphene.relay.mutation import ClientIDMutation
from graphene.types.mutation import MutationOptions
from graphene.types.utils import yank_fields_from_attrs
from graphene_django.registry import get_global_registry

from .converter import convert_form_field
from .types import ErrorType


def fields_for_form(form, only_fields, exclude_fields):
fields = OrderedDict()
for name, field in form.fields.items():
is_not_in_only = only_fields and name not in only_fields
is_excluded = (
name in exclude_fields # or
# name in already_created_fields
)

if is_not_in_only or is_excluded:
continue

fields[name] = convert_form_field(field)
return fields


class BaseFormMutation(ClientIDMutation):
class Meta:
abstract = True

@classmethod
def mutate_and_get_payload(cls, root, info, **input):
form = cls._meta.form_class(data=input)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on some of the other requests coming in on the SerializerMutation stuff, I have a few suggestions here for extensibility.

  1. Add a get_form classmethod that can be overridden to do any custom form logic.
  2. Add a get_form_kwargs classmethod for a simpler override use case where you just want to send some extra kwargs to the form class.


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)


class FormMutationOptions(MutationOptions):
form_class = None


class FormMutation(BaseFormMutation):
class Meta:
abstract = True

errors = graphene.List(ErrorType)

@classmethod
def __init_subclass_with_meta__(cls, form_class=None,
only_fields=(), exclude_fields=(), **options):

if not form_class:
raise Exception('form_class is required for FormMutation')

form = form_class()
input_fields = fields_for_form(form, only_fields, exclude_fields)
output_fields = fields_for_form(form, only_fields, exclude_fields)

_meta = FormMutationOptions(cls)
_meta.form_class = form_class
_meta.fields = yank_fields_from_attrs(
output_fields,
_as=Field,
)

input_fields = yank_fields_from_attrs(
input_fields,
_as=InputField,
)
super(FormMutation, cls).__init_subclass_with_meta__(_meta=_meta, input_fields=input_fields, **options)

@classmethod
def perform_mutate(cls, form, info):
form.save()
return cls(errors=[])


class ModelFormMutationOptions(FormMutationOptions):
model = None
return_field_name = None


class ModelFormMutation(BaseFormMutation):
class Meta:
abstract = True

errors = graphene.List(ErrorType)

@classmethod
def __init_subclass_with_meta__(cls, form_class=None, model=None, return_field_name=None,
only_fields=(), exclude_fields=(), **options):

if not form_class:
raise Exception('form_class is required for ModelFormMutation')

if not model:
model = form_class._meta.model

if not model:
raise Exception('model is required for ModelFormMutation')

form = form_class()
input_fields = fields_for_form(form, only_fields, exclude_fields)

registry = get_global_registry()
model_type = registry.get_type_for_model(model)
return_field_name = return_field_name or model._meta.model_name
output_fields = OrderedDict()
output_fields[return_field_name] = graphene.Field(model_type)

_meta = ModelFormMutationOptions(cls)
_meta.form_class = form_class
_meta.model = model
_meta.return_field_name = return_field_name
_meta.fields = yank_fields_from_attrs(
output_fields,
_as=Field,
)

input_fields = yank_fields_from_attrs(
input_fields,
_as=InputField,
)
super(ModelFormMutation, cls).__init_subclass_with_meta__(_meta=_meta, input_fields=input_fields, **options)

@classmethod
def perform_mutate(cls, form, info):
obj = form.save()
kwargs = {cls._meta.return_field_name: obj}
return cls(errors=[], **kwargs)
Empty file.
Expand Up @@ -4,8 +4,7 @@
import graphene
from graphene import ID, List, NonNull

from ..form_converter import convert_form_field
from .models import Reporter
from ..converter import convert_form_field


def assert_conversion(django_field, graphene_field, *args):
Expand All @@ -24,15 +23,15 @@ def test_should_unknown_django_field_raise_exception():


def test_should_date_convert_string():
assert_conversion(forms.DateField, graphene.String)
assert_conversion(forms.DateField, graphene.types.datetime.DateTime)


def test_should_time_convert_string():
assert_conversion(forms.TimeField, graphene.String)
assert_conversion(forms.TimeField, graphene.types.datetime.Time)


def test_should_date_time_convert_string():
assert_conversion(forms.DateTimeField, graphene.String)
assert_conversion(forms.DateTimeField, graphene.types.datetime.DateTime)


def test_should_char_convert_string():
Expand Down Expand Up @@ -91,13 +90,13 @@ def test_should_decimal_convert_float():


def test_should_multiple_choice_convert_connectionorlist():
field = forms.ModelMultipleChoiceField(Reporter.objects.all())
field = forms.ModelMultipleChoiceField(queryset=None)
graphene_type = convert_form_field(field)
assert isinstance(graphene_type, List)
assert graphene_type.of_type == ID


def test_should_manytoone_convert_connectionorlist():
field = forms.ModelChoiceField(Reporter.objects.all())
field = forms.ModelChoiceField(queryset=None)
graphene_type = convert_form_field(field)
assert isinstance(graphene_type, graphene.ID)