From 5c191b9062ed1da958f741042ec29eedc716492a Mon Sep 17 00:00:00 2001 From: sierreis <48896364+sierreis@users.noreply.github.com> Date: Sun, 24 Mar 2019 23:42:06 -0400 Subject: [PATCH 01/21] Add support for filterset_class meta parameter * Allow for use of either filter_fields or filterset_class * Add tests to check that the behavior is similar to filter_fields * Add documentation to show how to make use of the parameter --- docs/filtering.rst | 29 +++++++++++- graphene_django/converter.py | 5 +- graphene_django/filter/fields.py | 17 ++++--- graphene_django/filter/tests/test_fields.py | 52 +++++++++++++++++++++ graphene_django/types.py | 15 ++++-- 5 files changed, 105 insertions(+), 13 deletions(-) diff --git a/docs/filtering.rst b/docs/filtering.rst index feafd40..e27f8ce 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/graphene_django/converter.py b/graphene_django/converter.py index c40313d..6fc1227 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -181,8 +181,9 @@ def convert_field_to_list_or_connection(field, registry=None): # 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) diff --git a/graphene_django/filter/fields.py b/graphene_django/filter/fields.py index cb42543..9aa629f 100644 --- a/graphene_django/filter/fields.py +++ b/graphene_django/filter/fields.py @@ -35,14 +35,17 @@ class DjangoFilterConnectionField(DjangoConnectionField): @property def filterset_class(self): if not self._filterset_class: - fields = self._fields or self.node_type._meta.filter_fields - meta = dict(model=self.model, fields=fields) - if self._extra_filter_meta: - meta.update(self._extra_filter_meta) + if not self.node_type._meta.filterset_class: + fields = self._fields or self.node_type._meta.filter_fields + meta = dict(model=self.model, fields=fields) + if self._extra_filter_meta: + meta.update(self._extra_filter_meta) - self._filterset_class = get_filterset_class( - self._provided_filterset_class, **meta - ) + self._filterset_class = get_filterset_class( + self._provided_filterset_class, **meta + ) + else: + self._filterset_class = self.node_type._meta.filterset_class return self._filterset_class diff --git a/graphene_django/filter/tests/test_fields.py b/graphene_django/filter/tests/test_fields.py index f9ef0ae..534ebb9 100644 --- a/graphene_django/filter/tests/test_fields.py +++ b/graphene_django/filter/tests/test_fields.py @@ -227,6 +227,58 @@ def test_filter_filterset_information_on_meta_related(): assert_not_orderable(articles_field) +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/types.py b/graphene_django/types.py index 4441a9a..ef72b9b 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -44,6 +44,7 @@ class DjangoObjectTypeOptions(ObjectTypeOptions): connection = None # type: Type[Connection] filter_fields = () + filterset_class = None class DjangoObjectType(ObjectType): @@ -56,6 +57,7 @@ class DjangoObjectType(ObjectType): only_fields=(), exclude_fields=(), filter_fields=None, + filterset_class=None, connection=None, connection_class=None, use_connection=None, @@ -74,9 +76,15 @@ class DjangoObjectType(ObjectType): "The attribute registry in {} needs to be an instance of " '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 @@ -107,6 +115,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 From 4d905a46ac39a2f494f94ab177c84ba7c0c859cc Mon Sep 17 00:00:00 2001 From: sierreis <48896364+sierreis@users.noreply.github.com> Date: Mon, 25 Mar 2019 10:03:54 -0400 Subject: [PATCH 02/21] Fixed flake8 lint error --- graphene_django/types.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/graphene_django/types.py b/graphene_django/types.py index ef72b9b..b33c6bf 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -76,10 +76,10 @@ class DjangoObjectType(ObjectType): "The attribute registry in {} needs to be an instance of " 'Registry, received "{}".' ).format(cls.__name__, registry) - + 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 " From 132c4cb9d4174ced2ca716609e6f730f21d799ff Mon Sep 17 00:00:00 2001 From: sierreis <48896364+sierreis@users.noreply.github.com> Date: Mon, 25 Mar 2019 23:45:14 -0400 Subject: [PATCH 03/21] Fixed so that GrapheneFilterSetMixin is used with any provided filterset_class --- graphene_django/filter/fields.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/graphene_django/filter/fields.py b/graphene_django/filter/fields.py index 9aa629f..7c85e9a 100644 --- a/graphene_django/filter/fields.py +++ b/graphene_django/filter/fields.py @@ -35,17 +35,16 @@ class DjangoFilterConnectionField(DjangoConnectionField): @property def filterset_class(self): if not self._filterset_class: - if not self.node_type._meta.filterset_class: - fields = self._fields or self.node_type._meta.filter_fields - meta = dict(model=self.model, fields=fields) - if self._extra_filter_meta: - meta.update(self._extra_filter_meta) + fields = self._fields or self.node_type._meta.filter_fields + meta = dict(model=self.model, fields=fields) + if self._extra_filter_meta: + meta.update(self._extra_filter_meta) - self._filterset_class = get_filterset_class( - self._provided_filterset_class, **meta - ) - else: - self._filterset_class = self.node_type._meta.filterset_class + filterset_class = self._provided_filterset_class or ( + self.node_type._meta.filterset_class) + self._filterset_class = get_filterset_class( + filterset_class, **meta + ) return self._filterset_class From 36ac5626e9e29d3fa2415caf154eb952fe12d9d6 Mon Sep 17 00:00:00 2001 From: Andrew Bettke Date: Wed, 27 Mar 2019 17:09:25 +1300 Subject: [PATCH 04/21] Adds enhanced support for proxy models. --- graphene_django/tests/models.py | 7 +++ graphene_django/tests/test_query.py | 93 ++++++++--------------------- graphene_django/types.py | 6 +- 3 files changed, 37 insertions(+), 69 deletions(-) diff --git a/graphene_django/tests/models.py b/graphene_django/tests/models.py index 4fe546d..b4eb3ce 100644 --- a/graphene_django/tests/models.py +++ b/graphene_django/tests/models.py @@ -65,6 +65,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 @@ -74,6 +79,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 1716034..82d7d75 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 @@ -895,8 +896,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 +905,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 +925,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 +937,26 @@ def test_proxy_model_support(): } } } + cnnReporters { + edges { + node { + id + } + } + } } """ expected = { "allReporters": { "edges": [ - {"node": {"id": "UmVwb3J0ZXJUeXBlOjE="}}, - {"node": {"id": "UmVwb3J0ZXJUeXBlOjI="}}, + {"node": {"id": base64.b64encode("ReporterType:{}".format(reporter.id))}}, + {"node": {"id": base64.b64encode("ReporterType:{}".format(cnn_reporter.id))}}, + ] + }, + "cnnReporters": { + "edges": [ + {"node": {"id": base64.b64encode("CNNReporterType:{}".format(cnn_reporter.id))}} ] } } @@ -945,65 +964,3 @@ 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 diff --git a/graphene_django/types.py b/graphene_django/types.py index 4441a9a..6c386e0 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -130,7 +130,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 From 980142dfcfb5411b39bbc95e1777475f81402558 Mon Sep 17 00:00:00 2001 From: Andrew Bettke Date: Wed, 27 Mar 2019 17:24:13 +1300 Subject: [PATCH 05/21] Fix linting. --- graphene_django/types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/types.py b/graphene_django/types.py index 6c386e0..2a402d7 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -134,7 +134,7 @@ class DjangoObjectType(ObjectType): model = root._meta.model else: model = root._meta.model._meta.concrete_model - + return model == cls._meta.model @classmethod From 83a2ad34cdb07de23038feefbe1167e45bbc8536 Mon Sep 17 00:00:00 2001 From: Andrew Bettke Date: Wed, 27 Mar 2019 17:28:56 +1300 Subject: [PATCH 06/21] Encode strings before passing to b64encode. --- graphene_django/tests/test_query.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py index 82d7d75..c99c8e1 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -950,13 +950,13 @@ def test_proxy_model_support(): expected = { "allReporters": { "edges": [ - {"node": {"id": base64.b64encode("ReporterType:{}".format(reporter.id))}}, - {"node": {"id": base64.b64encode("ReporterType:{}".format(cnn_reporter.id))}}, + {"node": {"id": base64.b64encode("ReporterType:{}".format(reporter.id).encode())}}, + {"node": {"id": base64.b64encode("ReporterType:{}".format(cnn_reporter.id).encode())}}, ] }, "cnnReporters": { "edges": [ - {"node": {"id": base64.b64encode("CNNReporterType:{}".format(cnn_reporter.id))}} + {"node": {"id": base64.b64encode("CNNReporterType:{}".format(cnn_reporter.id).encode())}} ] } } From a461e80ee461b7409f3728f747823dc75d56ce0e Mon Sep 17 00:00:00 2001 From: Andrew Bettke Date: Wed, 27 Mar 2019 17:56:06 +1300 Subject: [PATCH 07/21] Correctly encode / decode for python3+. --- graphene_django/tests/test_query.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py index c99c8e1..5c38ce5 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -947,16 +947,19 @@ def test_proxy_model_support(): } """ + def str_to_node_id(val): + return base64.b64encode(val.encode()).decode() + expected = { "allReporters": { "edges": [ - {"node": {"id": base64.b64encode("ReporterType:{}".format(reporter.id).encode())}}, - {"node": {"id": base64.b64encode("ReporterType:{}".format(cnn_reporter.id).encode())}}, + {"node": {"id": str_to_node_id("ReporterType:{}".format(reporter.id))}}, + {"node": {"id": str_to_node_id("ReporterType:{}".format(cnn_reporter.id))}}, ] }, "cnnReporters": { "edges": [ - {"node": {"id": base64.b64encode("CNNReporterType:{}".format(cnn_reporter.id).encode())}} + {"node": {"id": str_to_node_id("CNNReporterType:{}".format(cnn_reporter.id))}} ] } } From d2f8bf730bbe571dbe568621e630c8f01dec9c55 Mon Sep 17 00:00:00 2001 From: sierreis <48896364+sierreis@users.noreply.github.com> Date: Wed, 27 Mar 2019 14:05:42 -0400 Subject: [PATCH 08/21] Test exception when both filterset_class and filter_fields are set --- graphene_django/filter/tests/test_fields.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/graphene_django/filter/tests/test_fields.py b/graphene_django/filter/tests/test_fields.py index 534ebb9..eb6581b 100644 --- a/graphene_django/filter/tests/test_fields.py +++ b/graphene_django/filter/tests/test_fields.py @@ -227,6 +227,21 @@ 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: From 959e98eeb0c87295e2535b04f870ace2028a394b Mon Sep 17 00:00:00 2001 From: Andrew Bettke Date: Thu, 28 Mar 2019 09:56:10 +1300 Subject: [PATCH 09/21] Refactor to use formal to_global_id. --- graphene_django/tests/test_query.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py index 5c38ce5..e74b8d6 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -8,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 @@ -947,19 +948,16 @@ def test_proxy_model_support(): } """ - def str_to_node_id(val): - return base64.b64encode(val.encode()).decode() - expected = { "allReporters": { "edges": [ - {"node": {"id": str_to_node_id("ReporterType:{}".format(reporter.id))}}, - {"node": {"id": str_to_node_id("ReporterType:{}".format(cnn_reporter.id))}}, + {"node": {"id": to_global_id("ReporterType", reporter.id)}}, + {"node": {"id": to_global_id("ReporterType", cnn_reporter.id)}}, ] }, "cnnReporters": { "edges": [ - {"node": {"id": str_to_node_id("CNNReporterType:{}".format(cnn_reporter.id))}} + {"node": {"id": to_global_id("CNNReporterType", cnn_reporter.id)}} ] } } From cb9eed6765f0ab706fdc1e6884d1c24c8123965b Mon Sep 17 00:00:00 2001 From: Anthony Monthe Date: Mon, 7 May 2018 00:22:34 +0100 Subject: [PATCH 10/21] Added tox.ini Updated Travis YML --- .gitignore | 2 ++ .travis.yml | 98 ++++++++++++++++++++++++++--------------------------- tox.ini | 31 +++++++++++++++++ 3 files changed, 81 insertions(+), 50 deletions(-) create mode 100644 tox.ini 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/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 From ddf8d24bf5eb1911048482d04d046597f168c6be Mon Sep 17 00:00:00 2001 From: mvanlonden Date: Fri, 31 May 2019 14:38:34 -0700 Subject: [PATCH 11/21] increment version to match release tag --- graphene_django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/__init__.py b/graphene_django/__init__.py index 4538cb3..51acfd2 100644 --- a/graphene_django/__init__.py +++ b/graphene_django/__init__.py @@ -1,6 +1,6 @@ from .types import DjangoObjectType from .fields import DjangoConnectionField -__version__ = "2.2.0" +__version__ = "2.3.0" __all__ = ["__version__", "DjangoObjectType", "DjangoConnectionField"] From fc49a50cc3bceda9ac578c1746de35e2422cf99d Mon Sep 17 00:00:00 2001 From: Richard Sween Date: Wed, 5 Jun 2019 19:43:51 -0500 Subject: [PATCH 12/21] Update mutations.rst I believe the `[1]` was ommitted from the `from_global_id` call as that method returns a tuple of type and id, of which we're only interested in the id here. Took me half a day to figure out why this code wasn't working today. See function def here: https://github.com/graphql-python/graphql-relay-py/blob/master/graphql_relay/node/node.py#L67 --- docs/mutations.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/mutations.rst b/docs/mutations.rst index f6c6f14..15bef1d 100644 --- a/docs/mutations.rst +++ b/docs/mutations.rst @@ -214,7 +214,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 +226,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. From d06217d2033375d19ad0cdaf4b0afb74c0eba408 Mon Sep 17 00:00:00 2001 From: Richard Sween Date: Thu, 6 Jun 2019 13:53:16 -0500 Subject: [PATCH 13/21] Fix Mutations Relay example imports Per comment here: https://github.com/graphql-python/graphene-django/pull/657#issuecomment-499618785 --- docs/mutations.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/mutations.rst b/docs/mutations.rst index 15bef1d..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 From 67b21cb36f9ed5319a3eb15cb438cbc3762b2299 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Roberto=20Meza=20Cabrera?= Date: Sun, 9 Jun 2019 14:08:31 -0500 Subject: [PATCH 14/21] Revert "Drop old Django compatibility code" This reverts commit 6acd917cf7076397009d0ba77901f4c1c8e190fe. --- graphene_django/utils/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/graphene_django/utils/utils.py b/graphene_django/utils/utils.py index 02c47ee..c2a3b09 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) + # Django =>1.9 uses 'rel', django <1.9 uses 'related' + 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: From ce6e6dd6e1668631f35c464f4f96818046437c54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Roberto=20Meza=20Cabrera?= Date: Sun, 9 Jun 2019 14:15:46 -0500 Subject: [PATCH 15/21] Fixes O2O relations --- graphene_django/utils/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/utils/utils.py b/graphene_django/utils/utils.py index c2a3b09..b8aaba0 100644 --- a/graphene_django/utils/utils.py +++ b/graphene_django/utils/utils.py @@ -18,7 +18,7 @@ def get_reverse_fields(model, local_field_names): if name in local_field_names: continue - # Django =>1.9 uses 'rel', django <1.9 uses 'related' + # "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) From 94602c77c6ebae74c418222291d6b2ca108a2f4d Mon Sep 17 00:00:00 2001 From: mvanlonden Date: Sun, 9 Jun 2019 12:41:04 -0700 Subject: [PATCH 16/21] add reverse relation one to one query test --- graphene_django/debug/tests/test_query.py | 4 +- graphene_django/tests/test_query.py | 56 +++++++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) 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/tests/test_query.py b/graphene_django/tests/test_query.py index 58f46c7..36fad9b 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -226,6 +226,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: From c90c27f3649c31b434e8220775a93e1334e6aeaa Mon Sep 17 00:00:00 2001 From: kamilkijak Date: Mon, 10 Jun 2019 09:25:34 +1000 Subject: [PATCH 17/21] Add support for write_only fields in SerializerMutation (#555) --- graphene_django/rest_framework/models.py | 5 +++ graphene_django/rest_framework/mutation.py | 5 ++- .../rest_framework/tests/test_mutation.py | 43 ++++++++++++++++++- 3 files changed, 51 insertions(+), 2 deletions(-) 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: From 3cde872e2873197ba99de5004d59a8888d5f2223 Mon Sep 17 00:00:00 2001 From: Emil Goldsmith Olesen Date: Mon, 10 Jun 2019 01:30:48 +0200 Subject: [PATCH 18/21] Stop enforcing csrf checks in GraphQLTestCase (#658) --- graphene_django/utils/testing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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): """ From f617b2a9c2706202e281be05361834547df7c9fe Mon Sep 17 00:00:00 2001 From: Abraham Toriz Cruz Date: Sun, 9 Jun 2019 19:33:57 -0400 Subject: [PATCH 19/21] django 1.11.19 is not available, probably for security reasons (#652) --- examples/cookbook/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From fcc491fffbf35b506918983019b337c0265b2bc1 Mon Sep 17 00:00:00 2001 From: Emil Goldsmith Olesen Date: Mon, 10 Jun 2019 02:06:50 +0200 Subject: [PATCH 20/21] Add watch option to graphql_schema (#656) * Add watch option to graphql_schema * add documentation for grapql_schema --watch --- docs/introspection.rst | 2 + .../management/commands/graphql_schema.py | 37 ++++++++++++++----- 2 files changed, 30 insertions(+), 9 deletions(-) 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/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) From 96934c46141de56c4c607625f56f785ea5276387 Mon Sep 17 00:00:00 2001 From: Alexandre Kirszenberg Date: Mon, 10 Jun 2019 02:19:05 +0200 Subject: [PATCH 21/21] Correctly propagate help_text as description for many-to-* relations (#579) * Correctly propagate help_text as description for many-to-* relations * Trigger build --- graphene_django/converter.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/graphene_django/converter.py b/graphene_django/converter.py index 6fc1227..158355a 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -177,6 +177,8 @@ 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: @@ -186,11 +188,11 @@ def convert_field_to_list_or_connection(field, registry=None): 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)