diff --git a/.gitignore b/.gitignore index 0b25625..150025a 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,5 @@ Session.vim *~ # auto-generated tag files tags +.tox/ +.pytest_cache/ diff --git a/.travis.yml b/.travis.yml index 07ee59f..5c4725f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,62 +1,60 @@ language: python sudo: required dist: xenial + python: -- 2.7 -- 3.4 -- 3.5 -- 3.6 -- 3.7 -install: -- | - if [ "$TEST_TYPE" = build ]; then - pip install -e .[test] - pip install psycopg2==2.8.2 # Required for Django postgres fields testing - pip install django==$DJANGO_VERSION - python setup.py develop - elif [ "$TEST_TYPE" = lint ]; then - pip install flake8==3.7.7 - fi -script: -- | - if [ "$TEST_TYPE" = lint ]; then - echo "Checking Python code lint." - flake8 graphene_django - exit - elif [ "$TEST_TYPE" = build ]; then - py.test --cov=graphene_django graphene_django examples - fi -after_success: -- | - if [ "$TEST_TYPE" = build ]; then - coveralls - fi + - 2.7 + - 3.4 + - 3.5 + - 3.6 + - 3.7 + env: matrix: - - TEST_TYPE=build DJANGO_VERSION=1.11 + - DJANGO=1.11 + - DJANGO=2.1 + - DJANGO=2.2 + - DJANGO=master + +install: + - TOX_ENV=py${TRAVIS_PYTHON_VERSION}-django${DJANGO} + - pip install tox + - tox -e $TOX_ENV --notest +script: + - tox -e $TOX_ENV + +after_success: + - tox -e $TOX_ENV -- pip install coveralls + - tox -e $TOX_ENV -- coveralls $COVERALLS_OPTION + matrix: fast_finish: true include: - - python: '3.4' - env: TEST_TYPE=build DJANGO_VERSION=2.0 - - python: '3.5' - env: TEST_TYPE=build DJANGO_VERSION=2.0 - - python: '3.6' - env: TEST_TYPE=build DJANGO_VERSION=2.0 - - python: '3.5' - env: TEST_TYPE=build DJANGO_VERSION=2.1 - - python: '3.6' - env: TEST_TYPE=build DJANGO_VERSION=2.1 - - python: '3.6' - env: TEST_TYPE=build DJANGO_VERSION=2.2 - - python: '3.7' - env: TEST_TYPE=build DJANGO_VERSION=2.2 - - python: '2.7' - env: TEST_TYPE=lint - - python: '3.6' - env: TEST_TYPE=lint - - python: '3.7' - env: TEST_TYPE=lint + - python: 3.5 + script: tox -e lint + exclude: + - python: 2.7 + env: DJANGO=2.1 + - python: 2.7 + env: DJANGO=2.2 + - python: 2.7 + env: DJANGO=master + - python: 3.4 + env: DJANGO=2.1 + - python: 3.4 + env: DJANGO=2.2 + - python: 3.4 + env: DJANGO=master + - python: 3.5 + env: DJANGO=master + - python: 3.7 + env: DJANGO=1.10 + - python: 3.7 + env: DJANGO=1.11 + allow_failures: + - python: 3.7 + - env: DJANGO=master + deploy: provider: pypi user: syrusakbary diff --git a/docs/filtering.rst b/docs/filtering.rst index d02366f..7661928 100644 --- a/docs/filtering.rst +++ b/docs/filtering.rst @@ -100,7 +100,7 @@ features of ``django-filter``. This is done by transparently creating a ``filter_fields``. However, you may find this to be insufficient. In these cases you can -create your own ``Filterset`` as follows: +create your own ``FilterSet``. You can pass it directly as follows: .. code:: python @@ -127,6 +127,33 @@ create your own ``Filterset`` as follows: all_animals = DjangoFilterConnectionField(AnimalNode, filterset_class=AnimalFilter) +You can also specify the ``FilterSet`` class using the ``filerset_class`` +parameter when defining your ``DjangoObjectType``, however, this can't be used +in unison with the ``filter_fields`` parameter: + +.. code:: python + + class AnimalFilter(django_filters.FilterSet): + # Do case-insensitive lookups on 'name' + name = django_filters.CharFilter(lookup_expr=['iexact']) + + class Meta: + # Assume you have an Animal model defined with the following fields + model = Animal + fields = ['name', 'genus', 'is_domesticated'] + + + class AnimalNode(DjangoObjectType): + class Meta: + model = Animal + filterset_class = AnimalFilter + interfaces = (relay.Node, ) + + + class Query(ObjectType): + animal = relay.Node.Field(AnimalNode) + all_animals = DjangoFilterConnectionField(AnimalNode) + The context argument is passed on as the `request argument `__ in a ``django_filters.FilterSet`` instance. You can use this to customize your filters to be context-dependent. We could modify the ``AnimalFilter`` above to diff --git a/docs/introspection.rst b/docs/introspection.rst index 0fc6776..c1d6ede 100644 --- a/docs/introspection.rst +++ b/docs/introspection.rst @@ -35,6 +35,8 @@ Advanced Usage The ``--indent`` option can be used to specify the number of indentation spaces to be used in the output. Defaults to `None` which displays all data on a single line. +The ``--watch`` option can be used to run ``./manage.py graphql_schema`` in watch mode, where it will automatically output a new schema every time there are file changes in your project + To simplify the command to ``./manage.py graphql_schema``, you can specify the parameters in your settings.py: diff --git a/docs/mutations.rst b/docs/mutations.rst index f6c6f14..6610151 100644 --- a/docs/mutations.rst +++ b/docs/mutations.rst @@ -199,7 +199,9 @@ You can use relay with mutations. A Relay mutation must inherit from .. code:: python - import graphene import relay, DjangoObjectType + import graphene + from graphene import relay + from graphene_django import DjangoObjectType from graphql_relay import from_global_id from .queries import QuestionType @@ -214,7 +216,7 @@ You can use relay with mutations. A Relay mutation must inherit from @classmethod def mutate_and_get_payload(cls, root, info, text, id): - question = Question.objects.get(pk=from_global_id(id)) + question = Question.objects.get(pk=from_global_id(id)[1]) question.text = text question.save() return QuestionMutation(question=question) @@ -226,4 +228,4 @@ Relay ClientIDMutation accept a ``clientIDMutation`` argument. This argument is also sent back to the client with the mutation result (you do not have to do anything). For services that manage a pool of many GraphQL requests in bulk, the ``clientIDMutation`` -allows you to match up a specific mutation with the response. \ No newline at end of file +allows you to match up a specific mutation with the response. diff --git a/examples/cookbook/requirements.txt b/examples/cookbook/requirements.txt index 3fed30f1..fe0527a 100644 --- a/examples/cookbook/requirements.txt +++ b/examples/cookbook/requirements.txt @@ -1,5 +1,5 @@ graphene graphene-django graphql-core>=2.1rc1 -django==1.11.19 +django==1.11.20 django-filter>=2 diff --git a/graphene_django/converter.py b/graphene_django/converter.py index 2d1f159..8b00dd9 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -180,19 +180,22 @@ def convert_field_to_list_or_connection(field, registry=None): if not _type: return + description = field.help_text if isinstance(field, models.ManyToManyField) else field.field.help_text + # If there is a connection, we should transform the field # into a DjangoConnectionField if _type._meta.connection: # Use a DjangoFilterConnectionField if there are - # defined filter_fields in the DjangoObjectType Meta - if _type._meta.filter_fields: + # defined filter_fields or a filterset_class in the + # DjangoObjectType Meta + if _type._meta.filter_fields or _type._meta.filterset_class: from .filter.fields import DjangoFilterConnectionField - return DjangoFilterConnectionField(_type) + return DjangoFilterConnectionField(_type, description=description) - return DjangoConnectionField(_type) + return DjangoConnectionField(_type, description=description) - return DjangoListField(_type) + return DjangoListField(_type, description=description) return Dynamic(dynamic_type) diff --git a/graphene_django/debug/tests/test_query.py b/graphene_django/debug/tests/test_query.py index 592899b..af69715 100644 --- a/graphene_django/debug/tests/test_query.py +++ b/graphene_django/debug/tests/test_query.py @@ -50,9 +50,7 @@ def test_should_query_field(): """ expected = { "reporter": {"lastName": "ABA"}, - "_debug": { - "sql": [{"rawSql": str(Reporter.objects.order_by("pk")[:1].query)}] - }, + "_debug": {"sql": [{"rawSql": str(Reporter.objects.order_by("pk")[:1].query)}]}, } schema = graphene.Schema(query=Query) result = schema.execute( diff --git a/graphene_django/filter/fields.py b/graphene_django/filter/fields.py index cb42543..7c85e9a 100644 --- a/graphene_django/filter/fields.py +++ b/graphene_django/filter/fields.py @@ -40,8 +40,10 @@ class DjangoFilterConnectionField(DjangoConnectionField): if self._extra_filter_meta: meta.update(self._extra_filter_meta) + filterset_class = self._provided_filterset_class or ( + self.node_type._meta.filterset_class) self._filterset_class = get_filterset_class( - self._provided_filterset_class, **meta + filterset_class, **meta ) return self._filterset_class diff --git a/graphene_django/filter/tests/test_fields.py b/graphene_django/filter/tests/test_fields.py index f9ef0ae..eb6581b 100644 --- a/graphene_django/filter/tests/test_fields.py +++ b/graphene_django/filter/tests/test_fields.py @@ -227,6 +227,73 @@ def test_filter_filterset_information_on_meta_related(): assert_not_orderable(articles_field) +def test_filter_filterset_class_filter_fields_exception(): + with pytest.raises(Exception): + class ReporterFilter(FilterSet): + class Meta: + model = Reporter + fields = ["first_name", "articles"] + + class ReporterFilterNode(DjangoObjectType): + class Meta: + model = Reporter + interfaces = (Node,) + filterset_class = ReporterFilter + filter_fields = ["first_name", "articles"] + + +def test_filter_filterset_class_information_on_meta(): + class ReporterFilter(FilterSet): + class Meta: + model = Reporter + fields = ["first_name", "articles"] + + class ReporterFilterNode(DjangoObjectType): + class Meta: + model = Reporter + interfaces = (Node,) + filterset_class = ReporterFilter + + field = DjangoFilterConnectionField(ReporterFilterNode) + assert_arguments(field, "first_name", "articles") + assert_not_orderable(field) + + +def test_filter_filterset_class_information_on_meta_related(): + class ReporterFilter(FilterSet): + class Meta: + model = Reporter + fields = ["first_name", "articles"] + + class ArticleFilter(FilterSet): + class Meta: + model = Article + fields = ["headline", "reporter"] + + class ReporterFilterNode(DjangoObjectType): + class Meta: + model = Reporter + interfaces = (Node,) + filterset_class = ReporterFilter + + class ArticleFilterNode(DjangoObjectType): + class Meta: + model = Article + interfaces = (Node,) + filterset_class = ArticleFilter + + class Query(ObjectType): + all_reporters = DjangoFilterConnectionField(ReporterFilterNode) + all_articles = DjangoFilterConnectionField(ArticleFilterNode) + reporter = Field(ReporterFilterNode) + article = Field(ArticleFilterNode) + + schema = Schema(query=Query) + articles_field = ReporterFilterNode._meta.fields["articles"].get_type() + assert_arguments(articles_field, "headline", "reporter") + assert_not_orderable(articles_field) + + def test_filter_filterset_related_results(): class ReporterFilterNode(DjangoObjectType): class Meta: diff --git a/graphene_django/management/commands/graphql_schema.py b/graphene_django/management/commands/graphql_schema.py index 9f8689e..1e8baf6 100644 --- a/graphene_django/management/commands/graphql_schema.py +++ b/graphene_django/management/commands/graphql_schema.py @@ -1,7 +1,9 @@ import importlib import json +import functools from django.core.management.base import BaseCommand, CommandError +from django.utils import autoreload from graphene_django.settings import graphene_settings @@ -32,6 +34,14 @@ class CommandArguments(BaseCommand): help="Output file indent (default: None)", ) + parser.add_argument( + "--watch", + dest="watch", + default=False, + action="store_true", + help="Updates the schema on file changes (default: False)", + ) + class Command(CommandArguments): help = "Dump Graphene schema JSON to file" @@ -41,6 +51,18 @@ class Command(CommandArguments): with open(out, "w") as outfile: json.dump(schema_dict, outfile, indent=indent, sort_keys=True) + def get_schema(self, schema, out, indent): + schema_dict = {"data": schema.introspect()} + if out == "-": + self.stdout.write(json.dumps(schema_dict, indent=indent, sort_keys=True)) + else: + self.save_file(out, schema_dict, indent) + + style = getattr(self, "style", None) + success = getattr(style, "SUCCESS", lambda x: x) + + self.stdout.write(success("Successfully dumped GraphQL schema to %s" % out)) + def handle(self, *args, **options): options_schema = options.get("schema") @@ -63,13 +85,10 @@ class Command(CommandArguments): ) indent = options.get("indent") - schema_dict = {"data": schema.introspect()} - if out == "-": - self.stdout.write(json.dumps(schema_dict, indent=indent, sort_keys=True)) + watch = options.get("watch") + if watch: + autoreload.run_with_reloader( + functools.partial(self.get_schema, schema, out, indent) + ) else: - self.save_file(out, schema_dict, indent) - - style = getattr(self, "style", None) - success = getattr(style, "SUCCESS", lambda x: x) - - self.stdout.write(success("Successfully dumped GraphQL schema to %s" % out)) + self.get_schema(schema, out, indent) diff --git a/graphene_django/rest_framework/models.py b/graphene_django/rest_framework/models.py index 848837b..06d9b60 100644 --- a/graphene_django/rest_framework/models.py +++ b/graphene_django/rest_framework/models.py @@ -4,3 +4,8 @@ from django.db import models class MyFakeModel(models.Model): cool_name = models.CharField(max_length=50) created = models.DateTimeField(auto_now_add=True) + + +class MyFakeModelWithPassword(models.Model): + cool_name = models.CharField(max_length=50) + password = models.CharField(max_length=50) diff --git a/graphene_django/rest_framework/mutation.py b/graphene_django/rest_framework/mutation.py index b8025f6..0fe9a02 100644 --- a/graphene_django/rest_framework/mutation.py +++ b/graphene_django/rest_framework/mutation.py @@ -27,6 +27,8 @@ def fields_for_serializer(serializer, only_fields, exclude_fields, is_input=Fals name in exclude_fields # or # name in already_created_fields + ) or ( + field.write_only and not is_input # don't show write_only fields in Query ) if is_not_in_only or is_excluded: @@ -138,6 +140,7 @@ class SerializerMutation(ClientIDMutation): kwargs = {} for f, field in serializer.fields.items(): - kwargs[f] = field.get_attribute(obj) + if not field.write_only: + kwargs[f] = field.get_attribute(obj) return cls(errors=None, **kwargs) diff --git a/graphene_django/rest_framework/tests/test_mutation.py b/graphene_django/rest_framework/tests/test_mutation.py index 4dccc18..a0c861d 100644 --- a/graphene_django/rest_framework/tests/test_mutation.py +++ b/graphene_django/rest_framework/tests/test_mutation.py @@ -7,7 +7,7 @@ from py.test import mark from rest_framework import serializers from ...types import DjangoObjectType -from ..models import MyFakeModel +from ..models import MyFakeModel, MyFakeModelWithPassword from ..mutation import SerializerMutation @@ -86,6 +86,47 @@ def test_exclude_fields(): assert "created" not in MyMutation.Input._meta.fields +@mark.django_db +def test_write_only_field(): + class WriteOnlyFieldModelSerializer(serializers.ModelSerializer): + password = serializers.CharField(write_only=True) + + class Meta: + model = MyFakeModelWithPassword + fields = ["cool_name", "password"] + + class MyMutation(SerializerMutation): + class Meta: + serializer_class = WriteOnlyFieldModelSerializer + + result = MyMutation.mutate_and_get_payload( + None, mock_info(), **{"cool_name": "New Narf", "password": "admin"} + ) + + assert hasattr(result, "cool_name") + assert not hasattr(result, "password"), "'password' is write_only field and shouldn't be visible" + + +@mark.django_db +def test_write_only_field_using_extra_kwargs(): + class WriteOnlyFieldModelSerializer(serializers.ModelSerializer): + class Meta: + model = MyFakeModelWithPassword + fields = ["cool_name", "password"] + extra_kwargs = {"password": {"write_only": True}} + + class MyMutation(SerializerMutation): + class Meta: + serializer_class = WriteOnlyFieldModelSerializer + + result = MyMutation.mutate_and_get_payload( + None, mock_info(), **{"cool_name": "New Narf", "password": "admin"} + ) + + assert hasattr(result, "cool_name") + assert not hasattr(result, "password"), "'password' is write_only field and shouldn't be visible" + + def test_nested_model(): class MyFakeModelGrapheneType(DjangoObjectType): class Meta: diff --git a/graphene_django/tests/models.py b/graphene_django/tests/models.py index 39e626b..2dca015 100644 --- a/graphene_django/tests/models.py +++ b/graphene_django/tests/models.py @@ -66,6 +66,11 @@ class Reporter(models.Model): self.__class__ = CNNReporter +class CNNReporterManager(models.Manager): + def get_queryset(self): + return super(CNNReporterManager, self).get_queryset().filter(reporter_type=2) + + class CNNReporter(Reporter): """ This class is a proxy model for Reporter, used for testing @@ -75,6 +80,8 @@ class CNNReporter(Reporter): class Meta: proxy = True + objects = CNNReporterManager() + class Article(models.Model): headline = models.CharField(max_length=100) diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py index 58f46c7..9ef217e 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -1,3 +1,4 @@ +import base64 import datetime import pytest @@ -7,6 +8,7 @@ from py.test import raises from django.db.models import Q +from graphql_relay import to_global_id import graphene from graphene.relay import Node @@ -226,6 +228,62 @@ def test_should_node(): assert result.data == expected +def test_should_query_onetoone_fields(): + film = Film(id=1) + film_details = FilmDetails(id=1, film=film) + + class FilmNode(DjangoObjectType): + class Meta: + model = Film + interfaces = (Node,) + + class FilmDetailsNode(DjangoObjectType): + class Meta: + model = FilmDetails + interfaces = (Node,) + + class Query(graphene.ObjectType): + film = graphene.Field(FilmNode) + film_details = graphene.Field(FilmDetailsNode) + + def resolve_film(root, info): + return film + + def resolve_film_details(root, info): + return film_details + + query = """ + query FilmQuery { + filmDetails { + id + film { + id + } + } + film { + id + details { + id + } + } + } + """ + expected = { + "filmDetails": { + "id": "RmlsbURldGFpbHNOb2RlOjE=", + "film": {"id": "RmlsbU5vZGU6MQ=="}, + }, + "film": { + "id": "RmlsbU5vZGU6MQ==", + "details": {"id": "RmlsbURldGFpbHNOb2RlOjE="}, + }, + } + schema = graphene.Schema(query=Query) + result = schema.execute(query) + assert not result.errors + assert result.data == expected + + def test_should_query_connectionfields(): class ReporterType(DjangoObjectType): class Meta: @@ -895,8 +953,7 @@ def test_should_handle_inherited_choices(): def test_proxy_model_support(): """ - This test asserts that we can query for all Reporters, - even if some are of a proxy model type at runtime. + This test asserts that we can query for all Reporters and proxied Reporters. """ class ReporterType(DjangoObjectType): @@ -905,11 +962,17 @@ def test_proxy_model_support(): interfaces = (Node,) use_connection = True - reporter_1 = Reporter.objects.create( + class CNNReporterType(DjangoObjectType): + class Meta: + model = CNNReporter + interfaces = (Node,) + use_connection = True + + reporter = Reporter.objects.create( first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1 ) - reporter_2 = CNNReporter.objects.create( + cnn_reporter = CNNReporter.objects.create( first_name="Some", last_name="Guy", email="someguy@cnn.com", @@ -919,6 +982,7 @@ def test_proxy_model_support(): class Query(graphene.ObjectType): all_reporters = DjangoConnectionField(ReporterType) + cnn_reporters = DjangoConnectionField(CNNReporterType) schema = graphene.Schema(query=Query) query = """ @@ -930,14 +994,26 @@ def test_proxy_model_support(): } } } + cnnReporters { + edges { + node { + id + } + } + } } """ expected = { "allReporters": { "edges": [ - {"node": {"id": "UmVwb3J0ZXJUeXBlOjE="}}, - {"node": {"id": "UmVwb3J0ZXJUeXBlOjI="}}, + {"node": {"id": to_global_id("ReporterType", reporter.id)}}, + {"node": {"id": to_global_id("ReporterType", cnn_reporter.id)}}, + ] + }, + "cnnReporters": { + "edges": [ + {"node": {"id": to_global_id("CNNReporterType", cnn_reporter.id)}} ] } } @@ -945,69 +1021,7 @@ def test_proxy_model_support(): result = schema.execute(query) assert not result.errors assert result.data == expected - - -def test_proxy_model_fails(): - """ - This test asserts that if you try to query for a proxy model, - that query will fail with: - GraphQLError('Expected value of type "CNNReporterType" but got: - CNNReporter.',) - - This is because a proxy model has the identical model definition - to its superclass, and defines its behavior at runtime, rather than - at the database level. Currently, filtering objects of the proxy models' - type isn't supported. It would require a field on the model that would - represent the type, and it doesn't seem like there is a clear way to - enforce this pattern across all projects - """ - - class CNNReporterType(DjangoObjectType): - class Meta: - model = CNNReporter - interfaces = (Node,) - use_connection = True - - reporter_1 = Reporter.objects.create( - first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1 - ) - - reporter_2 = CNNReporter.objects.create( - first_name="Some", - last_name="Guy", - email="someguy@cnn.com", - a_choice=1, - reporter_type=2, # set this guy to be CNN - ) - - class Query(graphene.ObjectType): - all_reporters = DjangoConnectionField(CNNReporterType) - - schema = graphene.Schema(query=Query) - query = """ - query ProxyModelQuery { - allReporters { - edges { - node { - id - } - } - } - } - """ - - expected = { - "allReporters": { - "edges": [ - {"node": {"id": "UmVwb3J0ZXJUeXBlOjE="}}, - {"node": {"id": "UmVwb3J0ZXJUeXBlOjI="}}, - ] - } - } - - result = schema.execute(query) - assert result.errors - + def test_should_resolve_get_queryset_connectionfields(): reporter_1 = Reporter.objects.create( diff --git a/graphene_django/types.py b/graphene_django/types.py index 3f99cef..ded8a15 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -45,6 +45,7 @@ class DjangoObjectTypeOptions(ObjectTypeOptions): connection = None # type: Type[Connection] filter_fields = () + filterset_class = None class DjangoObjectType(ObjectType): @@ -57,6 +58,7 @@ class DjangoObjectType(ObjectType): only_fields=(), exclude_fields=(), filter_fields=None, + filterset_class=None, connection=None, connection_class=None, use_connection=None, @@ -76,8 +78,14 @@ class DjangoObjectType(ObjectType): 'Registry, received "{}".' ).format(cls.__name__, registry) - if not DJANGO_FILTER_INSTALLED and filter_fields: - raise Exception("Can only set filter_fields if Django-Filter is installed") + if filter_fields and filterset_class: + raise Exception("Can't set both filter_fields and filterset_class") + + if not DJANGO_FILTER_INSTALLED and (filter_fields or filterset_class): + raise Exception(( + "Can only set filter_fields or filterset_class if " + "Django-Filter is installed" + )) django_fields = yank_fields_from_attrs( construct_fields(model, registry, only_fields, exclude_fields), _as=Field @@ -108,6 +116,7 @@ class DjangoObjectType(ObjectType): _meta.model = model _meta.registry = registry _meta.filter_fields = filter_fields + _meta.filterset_class = filterset_class _meta.fields = django_fields _meta.connection = connection @@ -131,7 +140,11 @@ class DjangoObjectType(ObjectType): if not is_valid_django_model(type(root)): raise Exception(('Received incompatible instance "{}".').format(root)) - model = root._meta.model._meta.concrete_model + if cls._meta.model._meta.proxy: + model = root._meta.model + else: + model = root._meta.model._meta.concrete_model + return model == cls._meta.model @classmethod diff --git a/graphene_django/utils/testing.py b/graphene_django/utils/testing.py index 47f8d04..db3e9f4 100644 --- a/graphene_django/utils/testing.py +++ b/graphene_django/utils/testing.py @@ -22,7 +22,7 @@ class GraphQLTestCase(TestCase): "Variable GRAPHQL_SCHEMA not defined in GraphQLTestCase." ) - cls._client = Client(cls.GRAPHQL_SCHEMA) + cls._client = Client() def query(self, query, op_name=None, input_data=None): """ diff --git a/graphene_django/utils/utils.py b/graphene_django/utils/utils.py index 02c47ee..b8aaba0 100644 --- a/graphene_django/utils/utils.py +++ b/graphene_django/utils/utils.py @@ -18,7 +18,8 @@ def get_reverse_fields(model, local_field_names): if name in local_field_names: continue - related = getattr(attr, "rel", None) + # "rel" for FK and M2M relations and "related" for O2O Relations + related = getattr(attr, "rel", None) or getattr(attr, "related", None) if isinstance(related, models.ManyToOneRel): yield (name, related) elif isinstance(related, models.ManyToManyRel) and not related.symmetrical: diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..8e21c74 --- /dev/null +++ b/tox.ini @@ -0,0 +1,31 @@ +[tox] +envlist = py{2.7,3.4,3.5,3.6,3.7,pypy,pypy3}-django{1.10,1.11,2.0,2.1,2.2,master},lint + +[testenv] +passenv = * +usedevelop = True +setenv = + DJANGO_SETTINGS_MODULE=django_test_settings +basepython = + py2.7: python2.7 + py3.4: python3.4 + py3.5: python3.5 + py3.6: python3.6 + py3.7: python3.7 + pypypy: pypy + pypypy3: pypy3 +deps = + -e.[test] + psycopg2 + django1.10: Django>=1.10,<1.11 + django1.11: Django>=1.11,<1.12 + django2.0: Django>=2.0 + django2.1: Django>=2.1 + djangomaster: https://github.com/django/django/archive/master.zip +commands = {posargs:py.test --cov=graphene_django graphene_django examples} + +[testenv:lint] +basepython = python +deps = + prospector +commands = prospector graphene_django -0