diff --git a/docs/form-mutations.rst b/docs/form-mutations.rst new file mode 100644 index 000000000..a498b5669 --- /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 ``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. diff --git a/docs/index.rst b/docs/index.rst index 256da680d..7c64ae709 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -12,4 +12,5 @@ Contents: authorization debug rest-framework + form-mutations introspection diff --git a/graphene_django/filter/utils.py b/graphene_django/filter/utils.py index 6b938ce40..cfa5621a1 100644 --- a/graphene_django/filter/utils.py +++ b/graphene_django/filter/utils.py @@ -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): diff --git a/graphene_django/forms/__init__.py b/graphene_django/forms/__init__.py new file mode 100644 index 000000000..066eec444 --- /dev/null +++ b/graphene_django/forms/__init__.py @@ -0,0 +1 @@ +from .forms import GlobalIDFormField, GlobalIDMultipleChoiceField # noqa diff --git a/graphene_django/form_converter.py b/graphene_django/forms/converter.py similarity index 58% rename from graphene_django/form_converter.py rename to graphene_django/forms/converter.py index 46a38b32a..9d878110f 100644 --- a/graphene_django/form_converter.py +++ b/graphene_django/forms/converter.py @@ -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 @@ -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) @@ -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) 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..4ce9a5d7a --- /dev/null +++ b/graphene_django/forms/mutation.py @@ -0,0 +1,166 @@ +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 BaseDjangoFormMutation(ClientIDMutation): + class Meta: + abstract = True + + @classmethod + def mutate_and_get_payload(cls, root, info, **input): + form = cls.get_form(root, info, **input) + + 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 get_form(cls, root, info, **input): + form_kwargs = cls.get_form_kwargs(root, info, **input) + return cls._meta.form_class(**form_kwargs) + + @classmethod + def get_form_kwargs(cls, root, info, **input): + kwargs = {'data': input} + + pk = input.pop('id', None) + if pk: + instance = cls._meta.model._default_manager.get(pk=pk) + kwargs['instance'] = instance + + return kwargs + + +class DjangoFormMutationOptions(MutationOptions): + form_class = None + + +class DjangoFormMutation(BaseDjangoFormMutation): + 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 DjangoFormMutation') + + form = form_class() + input_fields = fields_for_form(form, only_fields, exclude_fields) + output_fields = fields_for_form(form, only_fields, exclude_fields) + + _meta = DjangoFormMutationOptions(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(DjangoFormMutation, 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 DjangoModelDjangoFormMutationOptions(DjangoFormMutationOptions): + model = None + return_field_name = None + + +class DjangoModelFormMutation(BaseDjangoFormMutation): + 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 DjangoModelFormMutation') + + if not model: + model = form_class._meta.model + + if not model: + raise Exception('model is required for DjangoModelFormMutation') + + form = form_class() + input_fields = fields_for_form(form, only_fields, exclude_fields) + input_fields['id'] = graphene.ID() + + registry = get_global_registry() + model_type = registry.get_type_for_model(model) + return_field_name = return_field_name + if not return_field_name: + model_name = model.__name__ + return_field_name = model_name[:1].lower() + model_name[1:] + + output_fields = OrderedDict() + output_fields[return_field_name] = graphene.Field(model_type) + + _meta = DjangoModelDjangoFormMutationOptions(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(DjangoModelFormMutation, 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) 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/tests/test_form_converter.py b/graphene_django/forms/tests/test_converter.py similarity index 87% rename from graphene_django/tests/test_form_converter.py rename to graphene_django/forms/tests/test_converter.py index 5a13554b1..f0020315d 100644 --- a/graphene_django/tests/test_form_converter.py +++ b/graphene_django/forms/tests/test_converter.py @@ -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): @@ -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(): @@ -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) diff --git a/graphene_django/forms/tests/test_mutation.py b/graphene_django/forms/tests/test_mutation.py new file mode 100644 index 000000000..10a15ae0a --- /dev/null +++ b/graphene_django/forms/tests/test_mutation.py @@ -0,0 +1,113 @@ +from django import forms +from django.test import TestCase +from py.test import raises + +from graphene_django.tests.models import Pet, Film, FilmDetails +from ..mutation import DjangoFormMutation, DjangoModelFormMutation + + +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(DjangoFormMutation): + pass + + assert exc.value.args[0] == 'form_class is required for DjangoFormMutation' + + +def test_has_output_fields(): + class MyMutation(DjangoFormMutation): + class Meta: + form_class = MyForm + + assert 'errors' in MyMutation._meta.fields + + +def test_has_input_fields(): + class MyMutation(DjangoFormMutation): + class Meta: + form_class = MyForm + + assert 'text' in MyMutation.Input._meta.fields + + +class ModelFormMutationTests(TestCase): + + def test_default_meta_fields(self): + class PetMutation(DjangoModelFormMutation): + class Meta: + form_class = PetForm + + self.assertEqual(PetMutation._meta.model, Pet) + self.assertEqual(PetMutation._meta.return_field_name, 'pet') + self.assertIn('pet', PetMutation._meta.fields) + + def test_return_field_name_is_camelcased(self): + class PetMutation(DjangoModelFormMutation): + class Meta: + form_class = PetForm + model = FilmDetails + + self.assertEqual(PetMutation._meta.model, FilmDetails) + self.assertEqual(PetMutation._meta.return_field_name, 'filmDetails') + + def test_custom_return_field_name(self): + class PetMutation(DjangoModelFormMutation): + class Meta: + form_class = PetForm + model = Film + return_field_name = 'animal' + + self.assertEqual(PetMutation._meta.model, Film) + self.assertEqual(PetMutation._meta.return_field_name, 'animal') + self.assertIn('animal', PetMutation._meta.fields) + + def test_model_form_mutation_mutate(self): + class PetMutation(DjangoModelFormMutation): + class Meta: + form_class = PetForm + + pet = Pet.objects.create(name='Axel') + + result = PetMutation.mutate_and_get_payload(None, None, id=pet.pk, name='Mia') + + self.assertEqual(Pet.objects.count(), 1) + pet.refresh_from_db() + self.assertEqual(pet.name, 'Mia') + self.assertEqual(result.errors, []) + + def test_model_form_mutation_updates_existing_(self): + class PetMutation(DjangoModelFormMutation): + class Meta: + form_class = PetForm + + result = PetMutation.mutate_and_get_payload(None, None, name='Mia') + + self.assertEqual(Pet.objects.count(), 1) + pet = Pet.objects.get() + self.assertEqual(pet.name, 'Mia') + self.assertEqual(result.errors, []) + + def test_model_form_mutation_mutate_invalid_form(self): + class PetMutation(DjangoModelFormMutation): + class Meta: + form_class = PetForm + + result = PetMutation.mutate_and_get_payload(None, None) + + # A pet was not created + self.assertEqual(Pet.objects.count(), 0) + + self.assertEqual(len(result.errors), 1) + self.assertEqual(result.errors[0].field, 'name') + self.assertEqual(result.errors[0].messages, ['This field is required.']) 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)