diff --git a/Makefile b/Makefile index 061ad4e..3d4f152 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ dev-setup: pip install -e ".[dev]" tests: - py.test graphene_django --cov=graphene_django -vv + py.test graphene_django --cov=graphene_django -vv -x format: black graphene_django diff --git a/docs/index.rst b/docs/index.rst index 602f8dd..9ae944b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -26,6 +26,7 @@ For more advanced use, check out the Relay tutorial. schema queries mutations + types filtering authorization debug diff --git a/docs/settings.rst b/docs/settings.rst index 547e77f..ef5ef28 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -30,7 +30,7 @@ Default: ``None`` ``SCHEMA_OUTPUT`` ----------- +----------------- The name of the file where the GraphQL schema output will go. @@ -44,7 +44,7 @@ Default: ``schema.json`` ``SCHEMA_INDENT`` ----------- +----------------- The indentation level of the schema output. @@ -58,7 +58,7 @@ Default: ``2`` ``MIDDLEWARE`` ----------- +-------------- A tuple of middleware that will be executed for each GraphQL query. @@ -76,7 +76,7 @@ Default: ``()`` ``RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST`` ----------- +------------------------------------------ Enforces relay queries to have the ``first`` or ``last`` argument. @@ -90,7 +90,7 @@ Default: ``False`` ``RELAY_CONNECTION_MAX_LIMIT`` ----------- +------------------------------ The maximum size of objects that can be requested through a relay connection. @@ -100,4 +100,4 @@ Default: ``100`` GRAPHENE = { 'RELAY_CONNECTION_MAX_LIMIT': 100, - } + } \ No newline at end of file diff --git a/docs/types.rst b/docs/types.rst new file mode 100644 index 0000000..a7fed7f --- /dev/null +++ b/docs/types.rst @@ -0,0 +1,84 @@ +Types +===== + +This page documents specific features of Types related to Graphene-Django. + +DjangoChoicesEnum +----------------- + +``DjangoChoicesEnum`` is a helper class that lets you keep Graphene style enums +and ``models.Field.choices`` in sync. Some Django fields accept a ``choices`` list like this: + +.. code:: python + + choices = [ + ('FOO', 'foo'), + ('BAR', 'bar'), + ] + + class MyModel(models.Model): + options = models.CharField(max_length='3', choices=choices) + +With Graphene-Django it is useful to represent these choices as an enum: + +.. code:: + + query getEnumType { + __type(name: "MyModelOptions" ) { + name + enumValues { + name + description + } + } + } + +Which will return a data structure like this: + +.. code:: + + { + "data": { + "__type": { + "name": "MyModelOptions", + "enumValues": [ + { + "name": "FOO", + "description": "foo" + }, + { + "name": "BAR", + "description": "bar" + } + ] + } + } + } + +We can use ``DjangoChoicesEnum`` to support both of these for us: + +.. code:: python + + from graphene_django import DjangoObjectType, DjangoChoicesEnum + from django.db import models + + # Declare your DjangoChoicesEnum + class MyModelChoices(DjangoChoicesEnum): + FOO = 'foo' + BAR = 'bar' + + # Your model should use the .choices method + class MyModel(models.Model): + options = models.CharField( + max_length='3', + choices=DjangoChoicesEnum.choices(), + default=DjangoChoicesEnum.choices()[0][0], + ) + + # And your ObjectType should explicitly declare the type: + class MyModelType(DjangoObjectType): + class Meta: + model = MyModel + fields = ('options',) + + options = MyModelChoices.as_enum() \ No newline at end of file diff --git a/graphene_django/__init__.py b/graphene_django/__init__.py index 4538cb3..08acfec 100644 --- a/graphene_django/__init__.py +++ b/graphene_django/__init__.py @@ -1,6 +1,11 @@ -from .types import DjangoObjectType +from .types import DjangoObjectType, DjangoChoicesEnum from .fields import DjangoConnectionField __version__ = "2.2.0" -__all__ = ["__version__", "DjangoObjectType", "DjangoConnectionField"] +__all__ = [ + "__version__", + "DjangoObjectType", + "DjangoConnectionField", + "DjangoChoicesEnum", +] diff --git a/graphene_django/tests/models.py b/graphene_django/tests/models.py index 4fe546d..03cc40e 100644 --- a/graphene_django/tests/models.py +++ b/graphene_django/tests/models.py @@ -1,5 +1,7 @@ from __future__ import absolute_import +from ..types import DjangoChoicesEnum + from django.db import models from django.utils.translation import ugettext_lazy as _ @@ -103,3 +105,17 @@ class Article(models.Model): class Meta: ordering = ("headline",) + + +class MyCustomChoices(DjangoChoicesEnum): + DO = "Documentary" + OT = "Other" + + +class FilmWithChoices(models.Model): + genre = models.CharField( + max_length=2, + help_text="Genre", + choices=MyCustomChoices.choices(), + default=MyCustomChoices.choices()[0][0], + ) diff --git a/graphene_django/tests/test_converter.py b/graphene_django/tests/test_converter.py index bb176b3..9fcfb81 100644 --- a/graphene_django/tests/test_converter.py +++ b/graphene_django/tests/test_converter.py @@ -10,11 +10,11 @@ from graphene.types.json import JSONString from ..compat import JSONField, ArrayField, HStoreField, RangeField, MissingType from ..converter import convert_django_field, convert_django_field_with_choices +from ..settings import graphene_settings from ..registry import Registry from ..types import DjangoObjectType from .models import Article, Film, FilmDetails, Reporter - # from graphene.core.types.custom_scalars import DateTime, Time, JSONString @@ -128,10 +128,12 @@ def test_should_nullboolean_convert_boolean(): assert_conversion(models.NullBooleanField, graphene.Boolean) -def test_field_with_choices_convert_enum(): - field = models.CharField( - help_text="Language", choices=(("es", "Spanish"), ("en", "English")) - ) +@pytest.mark.parametrize( + "choices", + ((("es", "Spanish"), ("en", "English")), [("es", "Spanish"), ("en", "English")]), +) +def test_field_with_choices_convert_enum(choices): + field = models.CharField(help_text="Language", choices=choices) class TranslatedModel(models.Model): language = field diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py index 58f46c7..9ee39ea 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -14,8 +14,17 @@ from ..utils import DJANGO_FILTER_INSTALLED from ..compat import MissingType, JSONField from ..fields import DjangoConnectionField from ..types import DjangoObjectType +from ..registry import reset_global_registry from ..settings import graphene_settings -from .models import Article, CNNReporter, Reporter, Film, FilmDetails +from .models import ( + Article, + CNNReporter, + Reporter, + Film, + FilmWithChoices, + MyCustomChoices, + FilmDetails, +) pytestmark = pytest.mark.django_db @@ -145,9 +154,6 @@ def test_should_query_postgres_fields(): def test_should_node(): - # reset_global_registry() - # Node._meta.registry = get_global_registry() - class ReporterNode(DjangoObjectType): class Meta: model = Reporter @@ -413,9 +419,6 @@ def test_should_query_node_filtering_with_distinct_queryset(): class Query(graphene.ObjectType): films = DjangoConnectionField(FilmType) - # def resolve_all_reporters_with_berlin_films(self, args, context, info): - # return Reporter.objects.filter(Q(films__film__location__contains="Berlin") | Q(a_choice=1)) - def resolve_films(self, info, **args): return Film.objects.filter( Q(details__location__contains="Berlin") | Q(genre__in=["ot"]) @@ -1051,3 +1054,57 @@ def test_should_resolve_get_queryset_connectionfields(): result = schema.execute(query) assert not result.errors assert result.data == expected + + +def test_using_django_choices_enum(): + reset_global_registry() + + class FilmWithChoicesType(DjangoObjectType): + class Meta: + model = FilmWithChoices + interfaces = (Node,) + + genre = MyCustomChoices.as_enum() + + class Query(graphene.ObjectType): + films_with_choices = DjangoConnectionField(FilmWithChoicesType) + + def resolve_films(self, info, **args): + return Film.objects.all() + + f = FilmWithChoices.objects.create() + + query = """ + query NodeFilteringQuery { + filmsWithChoices { + edges { + node { + genre + } + } + } + } + """ + schema = graphene.Schema(query=Query) + result = schema.execute(query) + assert not result.errors + assert result.data["filmsWithChoices"]["edges"][0]["node"]["genre"] == "DO" + + query = """ + query getEnumType { + __type(name: "FilmWithChoicesGenre" ) { + name + enumValues { + name + description + } + } + } + """ + result = schema.execute(query) + assert not result.errors + enum_values = result.data["__type"]["enumValues"] + assert enum_values == [ + {"name": "DO", "description": "Documentary"}, + {"name": "OT", "description": "Other"}, + ] diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py index 8a8643b..c09876d 100644 --- a/graphene_django/tests/test_types.py +++ b/graphene_django/tests/test_types.py @@ -2,9 +2,10 @@ from mock import patch from graphene import Interface, ObjectType, Schema, Connection, String from graphene.relay import Node +from graphene.types.enum import Enum, EnumMeta, EnumOptions from .. import registry -from ..types import DjangoObjectType, DjangoObjectTypeOptions +from ..types import DjangoObjectType, DjangoObjectTypeOptions, DjangoChoicesEnum from .models import Article as ArticleModel from .models import Reporter as ReporterModel @@ -224,3 +225,21 @@ def test_django_objecttype_exclude_fields(): fields = list(Reporter._meta.fields.keys()) assert "email" not in fields + + +def test_custom_django_choices_enum(): + class MyChoicesEnum(DjangoChoicesEnum): + FOO = "foo" + BAR = "bar" + + # As a Graphene enum + graphene_enum = MyChoicesEnum.as_enum() + assert isinstance(graphene_enum, EnumMeta) + assert isinstance(graphene_enum._meta, EnumOptions) + assert graphene_enum.FOO.value == "foo" + assert graphene_enum.FOO.name == "FOO" + assert graphene_enum._meta.name == "MyChoicesEnum" + assert graphene_enum._meta.description(graphene_enum.FOO) == "foo" + + # As a Django choices option + assert MyChoicesEnum.choices() == [("FOO", "foo"), ("BAR", "bar")] diff --git a/graphene_django/types.py b/graphene_django/types.py index 3f99cef..5641421 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -1,6 +1,8 @@ import six from collections import OrderedDict +from enum import Enum + from django.db.models import Model from django.utils.functional import SimpleLazyObject import graphene @@ -150,3 +152,49 @@ class DjangoObjectType(ObjectType): class ErrorType(ObjectType): field = graphene.String(required=True) messages = graphene.List(graphene.NonNull(graphene.String), required=True) + + +def get_attr_fields_name_from_class(enum): + """ + Used to get field names from a class like below: + class ExampleEnum: + FOO = 'FOO VALUE' + BAR = 'BAR VALUE' + it will return ['FOO', 'BAR'] + """ + return [x for x in enum.__dict__ if not x.startswith("__")] + + +class DjangoChoicesEnum(object): + @classmethod + def choices(cls): + """ + Used to convert the this class into django CharField choices. + Example: + + # enums.py: + ChoicesType: + TEST = 'test' + + # models.py: + class Model(models.model) + some_field = CharField(choices=ChoicesType.choices()) + """ + return [(k, v) for k, v in cls.__dict__.items() if not k.startswith("__")] + + @classmethod + def as_enum(cls): + """ + Gets a Python class that looks like enum e.g. + class TestEnum: + FOO = 'BAR' + FOO2 = 'BAR2' + and returns a Graphene enum representing the Python enum + """ + props = get_attr_fields_name_from_class(cls) + new_enum = Enum(cls.__name__, ((prop, getattr(cls, prop)) for prop in props)) + + def get_description(value): + return getattr(cls, value.name, None) if value else None + + return graphene.Enum.from_enum(new_enum, description=get_description)