From 8d95596ffbb10712e4911dae79ecb4703c743a13 Mon Sep 17 00:00:00 2001 From: Jens Diemer Date: Tue, 1 Oct 2019 15:59:52 +0200 Subject: [PATCH 01/76] Note that release information are on github release page (#790) --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 33f71f3..0f1ee77 100644 --- a/README.md +++ b/README.md @@ -101,3 +101,7 @@ To learn more check out the following [examples](examples/): ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) + +## Release Notes + +* See [Releases page on github](https://github.com/graphql-python/graphene-django/releases) From e17582e1a1b8a9b595bf8aca295026db6ecd8c5e Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Fri, 18 Oct 2019 10:12:03 +0100 Subject: [PATCH 02/76] Update stale.yml --- .github/stale.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/stale.yml b/.github/stale.yml index dc90e5a..c9418f6 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -1,7 +1,7 @@ # Number of days of inactivity before an issue becomes stale -daysUntilStale: 60 +daysUntilStale: 90 # Number of days of inactivity before a stale issue is closed -daysUntilClose: 7 +daysUntilClose: 14 # Issues with these labels will never be considered stale exemptLabels: - pinned From b085b5922a69f1cadfa2bb72519c19dafebb2e39 Mon Sep 17 00:00:00 2001 From: Misha K Date: Fri, 18 Oct 2019 12:38:59 +0200 Subject: [PATCH 03/76] add Django 3.0 to the test matrix (#793) * add Django 3.0 to the test matrix * fix six imports --- .travis.yml | 4 ++++ graphene_django/debug/sql/tracking.py | 2 +- graphene_django/settings.py | 2 +- graphene_django/utils/utils.py | 2 +- tox.ini | 3 +++ 5 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 871d4e3..3531b56 100644 --- a/.travis.yml +++ b/.travis.yml @@ -35,6 +35,8 @@ matrix: env: DJANGO=2.1 - python: 3.6 env: DJANGO=2.2 + - python: 3.6 + env: DJANGO=3.0 - python: 3.6 env: DJANGO=master @@ -46,6 +48,8 @@ matrix: env: DJANGO=2.1 - python: 3.7 env: DJANGO=2.2 + - python: 3.7 + env: DJANGO=3.0 - python: 3.7 env: DJANGO=master diff --git a/graphene_django/debug/sql/tracking.py b/graphene_django/debug/sql/tracking.py index f96583b..8391eac 100644 --- a/graphene_django/debug/sql/tracking.py +++ b/graphene_django/debug/sql/tracking.py @@ -5,7 +5,7 @@ import json from threading import local from time import time -from django.utils import six +import six from django.utils.encoding import force_text from .types import DjangoDebugSQL diff --git a/graphene_django/settings.py b/graphene_django/settings.py index af63890..9a5e8a9 100644 --- a/graphene_django/settings.py +++ b/graphene_django/settings.py @@ -13,9 +13,9 @@ back to the defaults. """ from __future__ import unicode_literals +import six from django.conf import settings from django.test.signals import setting_changed -from django.utils import six try: import importlib # Available in Python 3.1+ diff --git a/graphene_django/utils/utils.py b/graphene_django/utils/utils.py index 47c0c37..c1d3572 100644 --- a/graphene_django/utils/utils.py +++ b/graphene_django/utils/utils.py @@ -1,8 +1,8 @@ import inspect +import six from django.db import models from django.db.models.manager import Manager -from django.utils import six from django.utils.encoding import force_text from django.utils.functional import Promise diff --git a/tox.ini b/tox.ini index a1b599a..e7287ff 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,7 @@ [tox] envlist = py{27,35,36,37}-django{111,20,21,22,master}, + py{36,37}-django30, black,flake8 [travis:env] @@ -9,6 +10,7 @@ DJANGO = 2.0: django20 2.1: django21 2.2: django22 + 3.0: django30 master: djangomaster [testenv] @@ -23,6 +25,7 @@ deps = django20: Django>=2.0,<2.1 django21: Django>=2.1,<2.2 django22: Django>=2.2,<3.0 + django30: Django>=3.0a1,<3.1 djangomaster: https://github.com/django/django/archive/master.zip commands = {posargs:py.test --cov=graphene_django graphene_django examples} From def6b15e5bf6bb0129932b2286938a2fbb45cfca Mon Sep 17 00:00:00 2001 From: Brett Jackson Date: Sat, 19 Oct 2019 14:33:33 -0500 Subject: [PATCH 04/76] Update schema introspection docs to show SCHEMA_INDENT option (#802) * Update schema introspection docs to show indent settings * fix whitespace --- docs/introspection.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/introspection.rst b/docs/introspection.rst index c1d6ede..dea55bd 100644 --- a/docs/introspection.rst +++ b/docs/introspection.rst @@ -44,7 +44,8 @@ specify the parameters in your settings.py: GRAPHENE = { 'SCHEMA': 'tutorial.quickstart.schema', - 'SCHEMA_OUTPUT': 'data/schema.json' # defaults to schema.json + 'SCHEMA_OUTPUT': 'data/schema.json', # defaults to schema.json, + 'SCHEMA_INDENT': 2, # Defaults to None (displays all data on a single line) } From e51e60209ac7331af669c8bb231c970a75ad1b72 Mon Sep 17 00:00:00 2001 From: Athul Cyriac Ajay Date: Fri, 1 Nov 2019 05:01:31 +0530 Subject: [PATCH 05/76] Updated Tutorial with Highlights (#801) --- docs/schema.py | 58 +++++++++++++++++++++++++++++++++++++ docs/tutorial-plain.rst | 63 ++--------------------------------------- 2 files changed, 61 insertions(+), 60 deletions(-) create mode 100644 docs/schema.py diff --git a/docs/schema.py b/docs/schema.py new file mode 100644 index 0000000..3d9b2fa --- /dev/null +++ b/docs/schema.py @@ -0,0 +1,58 @@ + import graphene + + from graphene_django.types import DjangoObjectType + + from cookbook.ingredients.models import Category, Ingredient + + + class CategoryType(DjangoObjectType): + class Meta: + model = Category + + + class IngredientType(DjangoObjectType): + class Meta: + model = Ingredient + + + class Query(object): + category = graphene.Field(CategoryType, + id=graphene.Int(), + name=graphene.String()) + all_categories = graphene.List(CategoryType) + + + ingredient = graphene.Field(IngredientType, + id=graphene.Int(), + name=graphene.String()) + all_ingredients = graphene.List(IngredientType) + + def resolve_all_categories(self, info, **kwargs): + return Category.objects.all() + + def resolve_all_ingredients(self, info, **kwargs): + return Ingredient.objects.all() + + def resolve_category(self, info, **kwargs): + id = kwargs.get('id') + name = kwargs.get('name') + + if id is not None: + return Category.objects.get(pk=id) + + if name is not None: + return Category.objects.get(name=name) + + return None + + def resolve_ingredient(self, info, **kwargs): + id = kwargs.get('id') + name = kwargs.get('name') + + if id is not None: + return Ingredient.objects.get(pk=id) + + if name is not None: + return Ingredient.objects.get(name=name) + + return None \ No newline at end of file diff --git a/docs/tutorial-plain.rst b/docs/tutorial-plain.rst index 29df56e..c3ee269 100644 --- a/docs/tutorial-plain.rst +++ b/docs/tutorial-plain.rst @@ -417,67 +417,10 @@ Getting single objects So far, we have been able to fetch list of objects and follow relation. But what about single objects? We can update our schema to support that, by adding new query for ``ingredient`` and ``category`` and adding arguments, so we can query for specific objects. +Add the **Highlighted** lines to ``cookbook/ingredients/schema.py`` -.. code:: python - - import graphene - - from graphene_django.types import DjangoObjectType - - from cookbook.ingredients.models import Category, Ingredient - - - class CategoryType(DjangoObjectType): - class Meta: - model = Category - - - class IngredientType(DjangoObjectType): - class Meta: - model = Ingredient - - - class Query(object): - category = graphene.Field(CategoryType, - id=graphene.Int(), - name=graphene.String()) - all_categories = graphene.List(CategoryType) - - - ingredient = graphene.Field(IngredientType, - id=graphene.Int(), - name=graphene.String()) - all_ingredients = graphene.List(IngredientType) - - def resolve_all_categories(self, info, **kwargs): - return Category.objects.all() - - def resolve_all_ingredients(self, info, **kwargs): - return Ingredient.objects.all() - - def resolve_category(self, info, **kwargs): - id = kwargs.get('id') - name = kwargs.get('name') - - if id is not None: - return Category.objects.get(pk=id) - - if name is not None: - return Category.objects.get(name=name) - - return None - - def resolve_ingredient(self, info, **kwargs): - id = kwargs.get('id') - name = kwargs.get('name') - - if id is not None: - return Ingredient.objects.get(pk=id) - - if name is not None: - return Ingredient.objects.get(name=name) - - return None +.. literalinclude:: schema.py + :emphasize-lines: 19-21,25-27,36-58 Now, with the code in place, we can query for single objects. From 3ce44908c9ddfcf6a7b603acc6ee7aefb64ce03c Mon Sep 17 00:00:00 2001 From: Jason Kraus Date: Thu, 28 Nov 2019 02:48:03 -0800 Subject: [PATCH 06/76] =?UTF-8?q?django-filter:=20resolve=20field=20along?= =?UTF-8?q?=20with=20lookup=20expression=20to=20pro=E2=80=A6=20(#805)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * django-filter: resolve field along with lookup expression to properly resolve field * bring back django-filter with method test * remove dangling comment * refactor based on better knowledge of django-filters --- graphene_django/filter/utils.py | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/graphene_django/filter/utils.py b/graphene_django/filter/utils.py index abb03a9..c5f18e2 100644 --- a/graphene_django/filter/utils.py +++ b/graphene_django/filter/utils.py @@ -1,5 +1,6 @@ import six +from django_filters.utils import get_model_field from .filterset import custom_filterset_factory, setup_filterset @@ -18,22 +19,12 @@ def get_filtering_args_from_filterset(filterset_class, type): if name in filterset_class.declared_filters: form_field = filter_field.field else: - try: - field_name, filter_type = name.rsplit("__", 1) - except ValueError: - field_name = name - filter_type = None - - # If the filter type is `isnull` then use the filter provided by - # DjangoFilter (a BooleanFilter). - # Otherwise try and get a filter based on the actual model field - if filter_type != "isnull" and hasattr(model, field_name): - model_field = model._meta.get_field(field_name) - - if hasattr(model_field, "formfield"): - form_field = model_field.formfield( - required=filter_field.extra.get("required", False) - ) + model_field = get_model_field(model, filter_field.field_name) + filter_type = filter_field.lookup_expr + if filter_type != "isnull" and hasattr(model_field, "formfield"): + form_field = model_field.formfield( + required=filter_field.extra.get("required", False) + ) # Fallback to field defined on filter if we can't get it from the # model field From a818ec9017c82105d2dcfb605946b890b319fa97 Mon Sep 17 00:00:00 2001 From: Jason Kraus Date: Thu, 28 Nov 2019 02:49:37 -0800 Subject: [PATCH 07/76] replace merge_queryset with resolve_queryset pattern (#796) * replace merge_queryset with resolve_queryset pattern * skip double limit test * Update graphene_django/fields.py Co-Authored-By: Jonathan Kim * yank skipped test * fix bad variable ref * add test for annotations * add test for using queryset with django filters * document ththat one should use defer instead of values with queysets and DjangoObjectTypes --- docs/queries.rst | 7 ++ graphene_django/fields.py | 35 ++++---- graphene_django/filter/fields.py | 68 ++------------- graphene_django/filter/tests/test_fields.py | 96 +++++++++------------ graphene_django/tests/test_query.py | 58 ++++++++++++- 5 files changed, 132 insertions(+), 132 deletions(-) diff --git a/docs/queries.rst b/docs/queries.rst index 67ebb06..36cdab1 100644 --- a/docs/queries.rst +++ b/docs/queries.rst @@ -282,6 +282,13 @@ of Django's ``HTTPRequest`` in your resolve methods, such as checking for authen return Question.objects.none() +DjangoObjectTypes +~~~~~~~~~~~~~~~~~ + +A Resolver that maps to a defined `DjangoObjectType` should only use methods that return a queryset. +Queryset methods like `values` will return dictionaries, use `defer` instead. + + Plain ObjectTypes ----------------- diff --git a/graphene_django/fields.py b/graphene_django/fields.py index e6daa88..47b44f6 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -39,9 +39,9 @@ class DjangoListField(Field): if queryset is None: # Default to Django Model queryset # N.B. This happens if DjangoListField is used in the top level Query object - model = django_object_type._meta.model + model_manager = django_object_type._meta.model.objects queryset = maybe_queryset( - django_object_type.get_queryset(model.objects, info) + django_object_type.get_queryset(model_manager, info) ) return queryset @@ -108,25 +108,13 @@ class DjangoConnectionField(ConnectionField): @classmethod def resolve_queryset(cls, connection, queryset, info, args): + # queryset is the resolved iterable from ObjectType return connection._meta.node.get_queryset(queryset, info) @classmethod - def merge_querysets(cls, default_queryset, queryset): - if default_queryset.query.distinct and not queryset.query.distinct: - queryset = queryset.distinct() - elif queryset.query.distinct and not default_queryset.query.distinct: - default_queryset = default_queryset.distinct() - return queryset & default_queryset - - @classmethod - def resolve_connection(cls, connection, default_manager, args, iterable): - if iterable is None: - iterable = default_manager + def resolve_connection(cls, connection, args, iterable): iterable = maybe_queryset(iterable) if isinstance(iterable, QuerySet): - if iterable.model.objects is not default_manager: - default_queryset = maybe_queryset(default_manager) - iterable = cls.merge_querysets(default_queryset, iterable) _len = iterable.count() else: _len = len(iterable) @@ -150,6 +138,7 @@ class DjangoConnectionField(ConnectionField): resolver, connection, default_manager, + queryset_resolver, max_limit, enforce_first_or_last, root, @@ -177,9 +166,15 @@ class DjangoConnectionField(ConnectionField): ).format(last, info.field_name, max_limit) args["last"] = min(last, max_limit) + # eventually leads to DjangoObjectType's get_queryset (accepts queryset) + # or a resolve_foo (does not accept queryset) iterable = resolver(root, info, **args) - queryset = cls.resolve_queryset(connection, default_manager, info, args) - on_resolve = partial(cls.resolve_connection, connection, queryset, args) + if iterable is None: + iterable = default_manager + # thus the iterable gets refiltered by resolve_queryset + # but iterable might be promise + iterable = queryset_resolver(connection, iterable, info, args) + on_resolve = partial(cls.resolve_connection, connection, args) if Promise.is_thenable(iterable): return Promise.resolve(iterable).then(on_resolve) @@ -192,6 +187,10 @@ class DjangoConnectionField(ConnectionField): parent_resolver, self.connection_type, self.get_manager(), + self.get_queryset_resolver(), self.max_limit, self.enforce_first_or_last, ) + + def get_queryset_resolver(self): + return self.resolve_queryset diff --git a/graphene_django/filter/fields.py b/graphene_django/filter/fields.py index 338becb..9943346 100644 --- a/graphene_django/filter/fields.py +++ b/graphene_django/filter/fields.py @@ -52,69 +52,17 @@ class DjangoFilterConnectionField(DjangoConnectionField): return get_filtering_args_from_filterset(self.filterset_class, self.node_type) @classmethod - def merge_querysets(cls, default_queryset, queryset): - # There could be the case where the default queryset (returned from the filterclass) - # and the resolver queryset have some limits on it. - # We only would be able to apply one of those, but not both - # at the same time. - - # See related PR: https://github.com/graphql-python/graphene-django/pull/126 - - assert not ( - default_queryset.query.low_mark and queryset.query.low_mark - ), "Received two sliced querysets (low mark) in the connection, please slice only in one." - assert not ( - default_queryset.query.high_mark and queryset.query.high_mark - ), "Received two sliced querysets (high mark) in the connection, please slice only in one." - low = default_queryset.query.low_mark or queryset.query.low_mark - high = default_queryset.query.high_mark or queryset.query.high_mark - default_queryset.query.clear_limits() - queryset = super(DjangoFilterConnectionField, cls).merge_querysets( - default_queryset, queryset - ) - queryset.query.set_limits(low, high) - return queryset - - @classmethod - def connection_resolver( - cls, - resolver, - connection, - default_manager, - max_limit, - enforce_first_or_last, - filterset_class, - filtering_args, - root, - info, - **args + def resolve_queryset( + cls, connection, iterable, info, args, filtering_args, filterset_class ): filter_kwargs = {k: v for k, v in args.items() if k in filtering_args} - qs = filterset_class( - data=filter_kwargs, - queryset=default_manager.get_queryset(), - request=info.context, + return filterset_class( + data=filter_kwargs, queryset=iterable, request=info.context ).qs - return super(DjangoFilterConnectionField, cls).connection_resolver( - resolver, - connection, - qs, - max_limit, - enforce_first_or_last, - root, - info, - **args - ) - - def get_resolver(self, parent_resolver): + def get_queryset_resolver(self): return partial( - self.connection_resolver, - parent_resolver, - self.connection_type, - self.get_manager(), - self.max_limit, - self.enforce_first_or_last, - self.filterset_class, - self.filtering_args, + self.resolve_queryset, + filterset_class=self.filterset_class, + filtering_args=self.filtering_args, ) diff --git a/graphene_django/filter/tests/test_fields.py b/graphene_django/filter/tests/test_fields.py index 1ffa0f4..1eba601 100644 --- a/graphene_django/filter/tests/test_fields.py +++ b/graphene_django/filter/tests/test_fields.py @@ -608,58 +608,6 @@ def test_should_query_filter_node_limit(): assert result.data == expected -def test_should_query_filter_node_double_limit_raises(): - class ReporterFilter(FilterSet): - limit = NumberFilter(method="filter_limit") - - def filter_limit(self, queryset, name, value): - return queryset[:value] - - class Meta: - model = Reporter - fields = ["first_name"] - - class ReporterType(DjangoObjectType): - class Meta: - model = Reporter - interfaces = (Node,) - - class Query(ObjectType): - all_reporters = DjangoFilterConnectionField( - ReporterType, filterset_class=ReporterFilter - ) - - def resolve_all_reporters(self, info, **args): - return Reporter.objects.order_by("a_choice")[:2] - - Reporter.objects.create( - first_name="Bob", last_name="Doe", email="bobdoe@example.com", a_choice=2 - ) - Reporter.objects.create( - first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1 - ) - - schema = Schema(query=Query) - query = """ - query NodeFilteringQuery { - allReporters(limit: 1) { - edges { - node { - id - firstName - } - } - } - } - """ - - result = schema.execute(query) - assert len(result.errors) == 1 - assert str(result.errors[0]) == ( - "Received two sliced querysets (high mark) in the connection, please slice only in one." - ) - - def test_order_by_is_perserved(): class ReporterType(DjangoObjectType): class Meta: @@ -721,7 +669,7 @@ def test_order_by_is_perserved(): assert reverse_result.data == reverse_expected -def test_annotation_is_perserved(): +def test_annotation_is_preserved(): class ReporterType(DjangoObjectType): full_name = String() @@ -766,6 +714,48 @@ def test_annotation_is_perserved(): assert result.data == expected +def test_annotation_with_only(): + class ReporterType(DjangoObjectType): + full_name = String() + + class Meta: + model = Reporter + interfaces = (Node,) + filter_fields = () + + class Query(ObjectType): + all_reporters = DjangoFilterConnectionField(ReporterType) + + def resolve_all_reporters(self, info, **args): + return Reporter.objects.only("first_name", "last_name").annotate( + full_name=Concat( + "first_name", Value(" "), "last_name", output_field=TextField() + ) + ) + + Reporter.objects.create(first_name="John", last_name="Doe") + + schema = Schema(query=Query) + + query = """ + query NodeFilteringQuery { + allReporters(first: 1) { + edges { + node { + fullName + } + } + } + } + """ + expected = {"allReporters": {"edges": [{"node": {"fullName": "John Doe"}}]}} + + result = schema.execute(query) + + assert not result.errors + assert result.data == expected + + def test_integer_field_filter_type(): class PetType(DjangoObjectType): class Meta: diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py index f24f84b..95db2d1 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -638,6 +638,8 @@ def test_should_error_if_first_is_greater_than_max(): class Query(graphene.ObjectType): all_reporters = DjangoConnectionField(ReporterType) + assert Query.all_reporters.max_limit == 100 + r = Reporter.objects.create( first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1 ) @@ -679,6 +681,8 @@ def test_should_error_if_last_is_greater_than_max(): class Query(graphene.ObjectType): all_reporters = DjangoConnectionField(ReporterType) + assert Query.all_reporters.max_limit == 100 + r = Reporter.objects.create( first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1 ) @@ -804,7 +808,7 @@ def test_should_query_connectionfields_with_manager(): schema = graphene.Schema(query=Query) query = """ query ReporterLastQuery { - allReporters(first: 2) { + allReporters(first: 1) { edges { node { id @@ -1116,3 +1120,55 @@ def test_should_preserve_prefetch_related(django_assert_num_queries): with django_assert_num_queries(3) as captured: result = schema.execute(query) assert not result.errors + + +def test_should_preserve_annotations(): + class ReporterType(DjangoObjectType): + class Meta: + model = Reporter + interfaces = (graphene.relay.Node,) + + class FilmType(DjangoObjectType): + reporters = DjangoConnectionField(ReporterType) + reporters_count = graphene.Int() + + class Meta: + model = Film + interfaces = (graphene.relay.Node,) + + class Query(graphene.ObjectType): + films = DjangoConnectionField(FilmType) + + def resolve_films(root, info): + qs = Film.objects.prefetch_related("reporters") + return qs.annotate(reporters_count=models.Count("reporters")) + + r1 = Reporter.objects.create(first_name="Dave", last_name="Smith") + r2 = Reporter.objects.create(first_name="Jane", last_name="Doe") + + f1 = Film.objects.create() + f1.reporters.set([r1, r2]) + f2 = Film.objects.create() + f2.reporters.set([r2]) + + query = """ + query { + films { + edges { + node { + reportersCount + } + } + } + } + """ + schema = graphene.Schema(query=Query) + result = schema.execute(query) + assert not result.errors, str(result) + + expected = { + "films": { + "edges": [{"node": {"reportersCount": 2}}, {"node": {"reportersCount": 1}}] + } + } + assert result.data == expected, str(result.data) From e82a2d75c645989ade5c51b578230f5d313e6a7c Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Thu, 28 Nov 2019 19:23:31 +0000 Subject: [PATCH 08/76] v2.7.0 --- 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 7650dd2..bc01752 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.6.0" +__version__ = "2.7.0" __all__ = ["__version__", "DjangoObjectType", "DjangoConnectionField"] From 7e7f18ee0e96562f57628c2cd5cbd9a5a6941f57 Mon Sep 17 00:00:00 2001 From: Thiago Bellini Ribeiro Date: Fri, 29 Nov 2019 06:13:16 -0300 Subject: [PATCH 09/76] Keep original queryset on DjangoFilterConnectionField (#816) * Keep original queryset on DjangoFilterConnectionField The PR #796 broke DjangoFilterConnectionField making it always get the raw queryset from the model to apply the filters in it. This makes sure that the DjangoObjectType's .get_queryset is called, keeping any filtering it might have made. * Add regression test --- graphene_django/filter/fields.py | 7 ++-- graphene_django/filter/tests/test_fields.py | 38 +++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/graphene_django/filter/fields.py b/graphene_django/filter/fields.py index 9943346..a46a4b7 100644 --- a/graphene_django/filter/fields.py +++ b/graphene_django/filter/fields.py @@ -55,10 +55,11 @@ class DjangoFilterConnectionField(DjangoConnectionField): def resolve_queryset( cls, connection, iterable, info, args, filtering_args, filterset_class ): + qs = super(DjangoFilterConnectionField, cls).resolve_queryset( + connection, iterable, info, args + ) filter_kwargs = {k: v for k, v in args.items() if k in filtering_args} - return filterset_class( - data=filter_kwargs, queryset=iterable, request=info.context - ).qs + return filterset_class(data=filter_kwargs, queryset=qs, request=info.context).qs def get_queryset_resolver(self): return partial( diff --git a/graphene_django/filter/tests/test_fields.py b/graphene_django/filter/tests/test_fields.py index 1eba601..de366ba 100644 --- a/graphene_django/filter/tests/test_fields.py +++ b/graphene_django/filter/tests/test_fields.py @@ -756,6 +756,44 @@ def test_annotation_with_only(): assert result.data == expected +def test_node_get_queryset_is_called(): + class ReporterType(DjangoObjectType): + class Meta: + model = Reporter + interfaces = (Node,) + filter_fields = () + + @classmethod + def get_queryset(cls, queryset, info): + return queryset.filter(first_name="b") + + class Query(ObjectType): + all_reporters = DjangoFilterConnectionField( + ReporterType, reverse_order=Boolean() + ) + + Reporter.objects.create(first_name="b") + Reporter.objects.create(first_name="a") + + schema = Schema(query=Query) + query = """ + query NodeFilteringQuery { + allReporters(first: 10) { + edges { + node { + firstName + } + } + } + } + """ + expected = {"allReporters": {"edges": [{"node": {"firstName": "b"}}]}} + + result = schema.execute(query) + assert not result.errors + assert result.data == expected + + def test_integer_field_filter_type(): class PetType(DjangoObjectType): class Meta: From 374d8a8a9e6fd2f2fdd373183f35d75229617768 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Fri, 29 Nov 2019 09:13:36 +0000 Subject: [PATCH 10/76] v2.7.1 --- 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 bc01752..df58a5a 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.7.0" +__version__ = "2.7.1" __all__ = ["__version__", "DjangoObjectType", "DjangoConnectionField"] From a73d6532744fac27af049d3aa6fbf7e1acc831fa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 7 Dec 2019 19:56:40 +0000 Subject: [PATCH 11/76] Bump django from 2.2.4 to 2.2.8 in /examples/cookbook-plain (#822) Bumps [django](https://github.com/django/django) from 2.2.4 to 2.2.8. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/2.2.4...2.2.8) Signed-off-by: dependabot[bot] --- examples/cookbook-plain/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/cookbook-plain/requirements.txt b/examples/cookbook-plain/requirements.txt index 802aa37..beed53b 100644 --- a/examples/cookbook-plain/requirements.txt +++ b/examples/cookbook-plain/requirements.txt @@ -1,4 +1,4 @@ graphene graphene-django graphql-core>=2.1rc1 -django==2.2.4 +django==2.2.8 From 968002f1554e3a7a1c0617682be64b67823b2581 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 7 Dec 2019 19:56:57 +0000 Subject: [PATCH 12/76] Bump django from 2.2.4 to 2.2.8 in /examples/cookbook (#821) Bumps [django](https://github.com/django/django) from 2.2.4 to 2.2.8. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/2.2.4...2.2.8) Signed-off-by: dependabot[bot] --- 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 0537103..3209a5e 100644 --- a/examples/cookbook/requirements.txt +++ b/examples/cookbook/requirements.txt @@ -1,5 +1,5 @@ graphene graphene-django graphql-core>=2.1rc1 -django==2.2.4 +django==2.2.8 django-filter>=2 From b66a3f347947804d0ab7d9763309e2977b5bcd5a Mon Sep 17 00:00:00 2001 From: Chibuotu Amadi Date: Thu, 26 Dec 2019 12:45:18 +0100 Subject: [PATCH 13/76] Add headers arg to GraphQLTestCase.query (#827) * Add headers arg to GraphQLTestCase.query * fix headers NoneType case in GraphQLTestCase.query * Run format Co-authored-by: Jonathan Kim --- graphene_django/utils/testing.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/graphene_django/utils/testing.py b/graphene_django/utils/testing.py index 5b694b2..8a9b994 100644 --- a/graphene_django/utils/testing.py +++ b/graphene_django/utils/testing.py @@ -24,7 +24,7 @@ class GraphQLTestCase(TestCase): cls._client = Client() - def query(self, query, op_name=None, input_data=None, variables=None): + def query(self, query, op_name=None, input_data=None, variables=None, headers=None): """ Args: query (string) - GraphQL query to run @@ -36,7 +36,9 @@ class GraphQLTestCase(TestCase): are provided, the ``input`` field in the ``variables`` dict will be overwritten with this value. variables (dict) - If provided, the "variables" field in GraphQL will be - set to this value. + set to this value. + headers (dict) - If provided, the headers in POST request to GRAPHQL_URL + will be set to this value. Returns: Response object from client @@ -51,10 +53,17 @@ class GraphQLTestCase(TestCase): body["variables"]["input"] = input_data else: body["variables"] = {"input": input_data} - - resp = self._client.post( - self.GRAPHQL_URL, json.dumps(body), content_type="application/json" - ) + if headers: + resp = self._client.post( + self.GRAPHQL_URL, + json.dumps(body), + content_type="application/json", + **headers + ) + else: + resp = self._client.post( + self.GRAPHQL_URL, json.dumps(body), content_type="application/json" + ) return resp def assertResponseNoErrors(self, resp): From 3d01acf169601c7c644da45c2b608a7c125003f0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Dec 2019 14:25:34 +0000 Subject: [PATCH 14/76] Bump django from 2.2.8 to 3.0 in /examples/cookbook (#825) Bumps [django](https://github.com/django/django) from 2.2.8 to 3.0. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/2.2.8...3.0) Signed-off-by: dependabot[bot] --- 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 3209a5e..b1baa57 100644 --- a/examples/cookbook/requirements.txt +++ b/examples/cookbook/requirements.txt @@ -1,5 +1,5 @@ graphene graphene-django graphql-core>=2.1rc1 -django==2.2.8 +django==3.0 django-filter>=2 From 45df7445f4dd21d08a6bddd084a113b73c957091 Mon Sep 17 00:00:00 2001 From: cbergmiller Date: Fri, 27 Dec 2019 15:26:42 +0100 Subject: [PATCH 15/76] Read csrftoken from DOM if no cookie is set (#826) --- graphene_django/static/graphene_django/graphiql.js | 5 ++++- graphene_django/templates/graphene/graphiql.html | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/graphene_django/static/graphene_django/graphiql.js b/graphene_django/static/graphene_django/graphiql.js index 2be7e3c..e38cd62 100644 --- a/graphene_django/static/graphene_django/graphiql.js +++ b/graphene_django/static/graphene_django/graphiql.js @@ -3,8 +3,11 @@ // Parse the cookie value for a CSRF token var csrftoken; var cookies = ('; ' + document.cookie).split('; csrftoken='); - if (cookies.length == 2) + if (cookies.length == 2) { csrftoken = cookies.pop().split(';').shift(); + } else { + csrftoken = document.querySelector("[name=csrfmiddlewaretoken]").value; + } // Collect the URL parameters var parameters = {}; diff --git a/graphene_django/templates/graphene/graphiql.html b/graphene_django/templates/graphene/graphiql.html index d0fb5a8..a0d0e1a 100644 --- a/graphene_django/templates/graphene/graphiql.html +++ b/graphene_django/templates/graphene/graphiql.html @@ -31,6 +31,7 @@ add "&raw" to the end of the URL within a browser. crossorigin="anonymous"> + {% csrf_token %} From 7940a7b954ef56fa9e538c3d0ff0cecb3ff54d42 Mon Sep 17 00:00:00 2001 From: dan-klasson Date: Fri, 27 Dec 2019 15:46:48 +0100 Subject: [PATCH 16/76] added support for partial updates in serializers (#731) * added support for partial updates in serializers * Add test to verify partial updates Co-authored-by: Jonathan Kim --- graphene_django/rest_framework/mutation.py | 3 +++ graphene_django/rest_framework/tests/test_mutation.py | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/graphene_django/rest_framework/mutation.py b/graphene_django/rest_framework/mutation.py index d9c695e..060b370 100644 --- a/graphene_django/rest_framework/mutation.py +++ b/graphene_django/rest_framework/mutation.py @@ -102,8 +102,10 @@ class SerializerMutation(ClientIDMutation): instance = get_object_or_404( model_class, **{lookup_field: input[lookup_field]} ) + partial = True elif "create" in cls._meta.model_operations: instance = None + partial = False else: raise Exception( 'Invalid update operation. Input parameter "{}" required.'.format( @@ -115,6 +117,7 @@ class SerializerMutation(ClientIDMutation): "instance": instance, "data": input, "context": {"request": info.context}, + "partial": partial, } return {"data": input, "context": {"request": info.context}} diff --git a/graphene_django/rest_framework/tests/test_mutation.py b/graphene_django/rest_framework/tests/test_mutation.py index 9d8b950..bfb247d 100644 --- a/graphene_django/rest_framework/tests/test_mutation.py +++ b/graphene_django/rest_framework/tests/test_mutation.py @@ -183,6 +183,16 @@ def test_model_update_mutate_and_get_payload_success(): assert result.cool_name == "New Narf" +@mark.django_db +def test_model_partial_update_mutate_and_get_payload_success(): + instance = MyFakeModel.objects.create(cool_name="Narf") + result = MyModelMutation.mutate_and_get_payload( + None, mock_info(), **{"id": instance.id} + ) + assert result.errors is None + assert result.cool_name == "Narf" + + @mark.django_db def test_model_invalid_update_mutate_and_get_payload_success(): class InvalidModelMutation(SerializerMutation): From f661cf83355fb41e78625ecaae857fc4a609be13 Mon Sep 17 00:00:00 2001 From: Vyacheslav Matyukhin Date: Mon, 30 Dec 2019 17:14:41 +0300 Subject: [PATCH 17/76] Fix typo in exclude type checking test (#841) --- graphene_django/tests/test_types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py index 5e9d1c2..5186623 100644 --- a/graphene_django/tests/test_types.py +++ b/graphene_django/tests/test_types.py @@ -315,7 +315,7 @@ def test_django_objecttype_fields_exclude_type_checking(): class Reporter2(DjangoObjectType): class Meta: model = ReporterModel - fields = "foo" + exclude = "foo" class TestDjangoObjectType: From efe210f8acda0d88a9763a3805959e4e7317b5c3 Mon Sep 17 00:00:00 2001 From: Vyacheslav Matyukhin Date: Tue, 31 Dec 2019 16:55:45 +0300 Subject: [PATCH 18/76] Validate Meta.fields and Meta.exclude on DjangoObjectType (#842) Resolves #840 --- graphene_django/tests/models.py | 3 +++ graphene_django/tests/test_types.py | 24 ++++++++++++++++++++++++ graphene_django/types.py | 18 ++++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/graphene_django/tests/models.py b/graphene_django/tests/models.py index 14a8367..44a5d8a 100644 --- a/graphene_django/tests/models.py +++ b/graphene_django/tests/models.py @@ -64,6 +64,9 @@ class Reporter(models.Model): if self.reporter_type == 2: # quick and dirty way without enums self.__class__ = CNNReporter + def some_method(self): + return 123 + class CNNReporterManager(models.Manager): def get_queryset(self): diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py index 5186623..cb31a9c 100644 --- a/graphene_django/tests/test_types.py +++ b/graphene_django/tests/test_types.py @@ -318,6 +318,30 @@ def test_django_objecttype_fields_exclude_type_checking(): exclude = "foo" +@with_local_registry +def test_django_objecttype_fields_exclude_exist_on_model(): + with pytest.raises(Exception, match=r"Field .* doesn't exist"): + + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + fields = ["first_name", "foo", "email"] + + with pytest.raises(Exception, match=r"Field .* doesn't exist"): + + class Reporter2(DjangoObjectType): + class Meta: + model = ReporterModel + exclude = ["first_name", "foo", "email"] + + with pytest.raises(Exception, match=r".* exists on model .* but it's not a field"): + + class Reporter3(DjangoObjectType): + class Meta: + model = ReporterModel + fields = ["first_name", "some_method", "email"] + + class TestDjangoObjectType: @pytest.fixture def PetModel(self): diff --git a/graphene_django/types.py b/graphene_django/types.py index ec426f1..4824c45 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -33,6 +33,24 @@ def construct_fields( ): _model_fields = get_model_fields(model) + # Validate the given fields against the model's fields. + model_field_names = set(field[0] for field in _model_fields) + for fields_list in (only_fields, exclude_fields): + if not fields_list: + continue + for name in fields_list: + if name in model_field_names: + continue + + if hasattr(model, name): + raise Exception( + '"{}" exists on model {} but it\'s not a field.'.format(name, model) + ) + else: + raise Exception( + 'Field "{}" doesn\'t exist on model {}.'.format(name, model) + ) + fields = OrderedDict() for name, field in _model_fields: is_not_in_only = only_fields and name not in only_fields From 3dd04f68ab4b4dab0116b2f0b8d230581b96519d Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Tue, 31 Dec 2019 13:56:04 +0000 Subject: [PATCH 19/76] Update travis config to only run deploy once (#837) --- .travis.yml | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/.travis.yml b/.travis.yml index 3531b56..1718d79 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,8 +12,17 @@ after_success: - pip install coveralls - coveralls -matrix: +stages: + - test + - name: deploy + if: tag IS present + +jobs: fast_finish: true + + allow_failures: + - env: DJANGO=master + include: - python: 2.7 env: DJANGO=1.11 @@ -56,14 +65,14 @@ matrix: - python: 3.7 env: TOXENV=black,flake8 - allow_failures: - - env: DJANGO=master - -deploy: - provider: pypi - user: syrusakbary - on: - tags: true - password: - secure: kymIFCEPUbkgRqe2NAXkWfxMmGRfWvWBOP6LIXdVdkOOkm91fU7bndPGrAjos+/7gN0Org609ZmHSlVXNMJUWcsL2or/x5LcADJ4cZDe+79qynuoRb9xs1Ri4O4SBAuVMZxuVJvs8oUzT2R11ql5vASSMtXgbX+ZDGpmPRVZStkCuXgOc4LBhbPKyl3OFy7UQFPgAEmy3Yjh4ZSKzlXheK+S6mmr60+DCIjpaA0BWPxYK9FUE0qm7JJbHLUbwsUP/QMp5MmGjwFisXCNsIe686B7QKRaiOw62eJc2R7He8AuEC8T9OM4kRwDlecSn8mMpkoSB7QWtlJ+6XdLrJFPNvtrOfgfzS9/96Qrw9WlOslk68hMlhJeRb0s2YUD8tiV3UUkvbL1mfFoS4SI9U+rojS55KhUEJWHg1w7DjoOPoZmaIL2ChRupmvrFYNAGae1cxwG3Urh+t3wYlN3gpKsRDe5GOT7Wm2tr0ad3McCpDGUwSChX59BAJXe/MoLxkKScTrMyR8yMxHOF0b4zpVn5l7xB/o2Ik4zavx5q/0rGBMK2D+5d+gpQogKShoquTPsZUwO7sB5hYeH2hqGqpeGzZtb76E2zZYd18pJ0FsBudm5+KWjYdZ+vbtGrLxdTXJ1EEtzVXm0lscykTpqUucbXSa51dhStJvW2xEEz6p3rHo= - distributions: "sdist bdist_wheel" + - stage: deploy + python: 3.7 + after_success: true + deploy: + provider: pypi + user: syrusakbary + on: + tags: true + password: + secure: kymIFCEPUbkgRqe2NAXkWfxMmGRfWvWBOP6LIXdVdkOOkm91fU7bndPGrAjos+/7gN0Org609ZmHSlVXNMJUWcsL2or/x5LcADJ4cZDe+79qynuoRb9xs1Ri4O4SBAuVMZxuVJvs8oUzT2R11ql5vASSMtXgbX+ZDGpmPRVZStkCuXgOc4LBhbPKyl3OFy7UQFPgAEmy3Yjh4ZSKzlXheK+S6mmr60+DCIjpaA0BWPxYK9FUE0qm7JJbHLUbwsUP/QMp5MmGjwFisXCNsIe686B7QKRaiOw62eJc2R7He8AuEC8T9OM4kRwDlecSn8mMpkoSB7QWtlJ+6XdLrJFPNvtrOfgfzS9/96Qrw9WlOslk68hMlhJeRb0s2YUD8tiV3UUkvbL1mfFoS4SI9U+rojS55KhUEJWHg1w7DjoOPoZmaIL2ChRupmvrFYNAGae1cxwG3Urh+t3wYlN3gpKsRDe5GOT7Wm2tr0ad3McCpDGUwSChX59BAJXe/MoLxkKScTrMyR8yMxHOF0b4zpVn5l7xB/o2Ik4zavx5q/0rGBMK2D+5d+gpQogKShoquTPsZUwO7sB5hYeH2hqGqpeGzZtb76E2zZYd18pJ0FsBudm5+KWjYdZ+vbtGrLxdTXJ1EEtzVXm0lscykTpqUucbXSa51dhStJvW2xEEz6p3rHo= + distributions: "sdist bdist_wheel" From 399ad13a705db243f3add181ef5a5bc95123b506 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Tue, 31 Dec 2019 14:10:18 +0000 Subject: [PATCH 20/76] v2.8.0 --- 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 df58a5a..1ddc2cb 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.7.1" +__version__ = "2.8.0" __all__ = ["__version__", "DjangoObjectType", "DjangoConnectionField"] From b8a2d5953a32c3bbd31fd7799271e879384ca000 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Tue, 31 Dec 2019 14:34:47 +0000 Subject: [PATCH 21/76] Don't run tests during deploy stage --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 1718d79..bbeeb80 100644 --- a/.travis.yml +++ b/.travis.yml @@ -66,6 +66,7 @@ jobs: env: TOXENV=black,flake8 - stage: deploy + script: skip python: 3.7 after_success: true deploy: From de87573e0c6c5f70c756e4b7eedfd90ba8945593 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Sat, 11 Jan 2020 14:49:17 +0100 Subject: [PATCH 22/76] Add information on how to deal with CSRF protection (#838) --- docs/installation.rst | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/docs/installation.rst b/docs/installation.rst index a2dc665..52f2520 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -66,4 +66,26 @@ The most basic ``schema.py`` looks like this: schema = graphene.Schema(query=Query) -To learn how to extend the schema object for your project, read the basic tutorial. \ No newline at end of file +To learn how to extend the schema object for your project, read the basic tutorial. + +CSRF exempt +----------- + +If have enabled `CSRF protection `_ in your Django app +you will find that it prevents your API clients from POSTing to the ``graphql`` endpoint. You can either +update your API client to pass the CSRF token with each request (the Django docs have a guide on how to do that: https://docs.djangoproject.com/en/3.0/ref/csrf/#ajax) or you can exempt your Graphql endpoint from CSRF protection by wrapping the ``GraphQLView`` with the ``csrf_exempt`` +decorator: + +.. code:: python + + # urls.py + + from django.urls import path + from django.views.decorators.csrf import csrf_exempt + + from graphene_django.views import GraphQLView + + urlpatterns = [ + # ... + path("graphql", csrf_exempt(GraphQLView.as_view(graphiql=True))), + ] From 96c38b4349f5c9680531c301e3f2baf2d05b7fc9 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Sat, 11 Jan 2020 14:49:44 +0100 Subject: [PATCH 23/76] Update Django model form tests (#839) * Clean up code and raise an exception if the model type is not found * Update tests * Fix tests --- graphene_django/forms/mutation.py | 26 +------ graphene_django/forms/tests/test_mutation.py | 74 ++++++++++++++++++-- 2 files changed, 70 insertions(+), 30 deletions(-) diff --git a/graphene_django/forms/mutation.py b/graphene_django/forms/mutation.py index f5921e8..1eeeb97 100644 --- a/graphene_django/forms/mutation.py +++ b/graphene_django/forms/mutation.py @@ -66,28 +66,6 @@ class BaseDjangoFormMutation(ClientIDMutation): return kwargs -# class DjangoFormInputObjectTypeOptions(InputObjectTypeOptions): -# form_class = None - - -# class DjangoFormInputObjectType(InputObjectType): -# class Meta: -# abstract = True - -# @classmethod -# def __init_subclass_with_meta__(cls, form_class=None, -# only_fields=(), exclude_fields=(), _meta=None, **options): -# if not _meta: -# _meta = DjangoFormInputObjectTypeOptions(cls) -# assert isinstance(form_class, forms.Form), ( -# 'form_class must be an instance of django.forms.Form' -# ) -# _meta.form_class = form_class -# form = form_class() -# fields = fields_for_form(form, only_fields, exclude_fields) -# super(DjangoFormInputObjectType, cls).__init_subclass_with_meta__(_meta=_meta, fields=fields, **options) - - class DjangoFormMutationOptions(MutationOptions): form_class = None @@ -163,7 +141,9 @@ class DjangoModelFormMutation(BaseDjangoFormMutation): registry = get_global_registry() model_type = registry.get_type_for_model(model) - return_field_name = return_field_name + if not model_type: + raise Exception("No type registered for model: {}".format(model.__name__)) + if not return_field_name: model_name = model.__name__ return_field_name = model_name[:1].lower() + model_name[1:] diff --git a/graphene_django/forms/tests/test_mutation.py b/graphene_django/forms/tests/test_mutation.py index 2de5113..494c77c 100644 --- a/graphene_django/forms/tests/test_mutation.py +++ b/graphene_django/forms/tests/test_mutation.py @@ -2,6 +2,8 @@ from django import forms from django.test import TestCase from py.test import raises +from graphene import ObjectType, Schema, String, Field +from graphene_django import DjangoObjectType from graphene_django.tests.models import Film, FilmDetails, Pet from ...settings import graphene_settings @@ -18,6 +20,24 @@ class PetForm(forms.ModelForm): fields = "__all__" +class PetType(DjangoObjectType): + class Meta: + model = Pet + fields = "__all__" + + +class FilmType(DjangoObjectType): + class Meta: + model = Film + fields = "__all__" + + +class FilmDetailsType(DjangoObjectType): + class Meta: + model = FilmDetails + fields = "__all__" + + def test_needs_form_class(): with raises(Exception) as exc: @@ -59,6 +79,10 @@ def test_mutation_error_camelcased(): graphene_settings.CAMELCASE_ERRORS = False +class MockQuery(ObjectType): + a = String() + + class ModelFormMutationTests(TestCase): def test_default_meta_fields(self): class PetMutation(DjangoModelFormMutation): @@ -113,34 +137,70 @@ class ModelFormMutationTests(TestCase): self.assertEqual(PetMutation._meta.return_field_name, "animal") self.assertIn("animal", PetMutation._meta.fields) - def test_model_form_mutation_mutate(self): + def test_model_form_mutation_mutate_existing(self): class PetMutation(DjangoModelFormMutation): + pet = Field(PetType) + class Meta: form_class = PetForm + class Mutation(ObjectType): + pet_mutation = PetMutation.Field() + + schema = Schema(query=MockQuery, mutation=Mutation) + pet = Pet.objects.create(name="Axel", age=10) - result = PetMutation.mutate_and_get_payload( - None, None, id=pet.pk, name="Mia", age=10 + result = schema.execute( + """ mutation PetMutation($pk: ID!) { + petMutation(input: { id: $pk, name: "Mia", age: 10 }) { + pet { + name + age + } + } + } + """, + variables={"pk": pet.pk}, ) + self.assertIs(result.errors, None) + self.assertEqual(result.data["petMutation"]["pet"], {"name": "Mia", "age": 10}) + 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): + def test_model_form_mutation_creates_new(self): class PetMutation(DjangoModelFormMutation): + pet = Field(PetType) + class Meta: form_class = PetForm - result = PetMutation.mutate_and_get_payload(None, None, name="Mia", age=10) + class Mutation(ObjectType): + pet_mutation = PetMutation.Field() + + schema = Schema(query=MockQuery, mutation=Mutation) + + result = schema.execute( + """ mutation PetMutation { + petMutation(input: { name: "Mia", age: 10 }) { + pet { + name + age + } + } + } + """ + ) + self.assertIs(result.errors, None) + self.assertEqual(result.data["petMutation"]["pet"], {"name": "Mia", "age": 10}) self.assertEqual(Pet.objects.count(), 1) pet = Pet.objects.get() self.assertEqual(pet.name, "Mia") self.assertEqual(pet.age, 10) - self.assertEqual(result.errors, []) def test_model_form_mutation_mutate_invalid_form(self): class PetMutation(DjangoModelFormMutation): From 08f67797d8080765cd5069861bb10054f5f48289 Mon Sep 17 00:00:00 2001 From: luto Date: Sat, 11 Jan 2020 14:52:41 +0100 Subject: [PATCH 24/76] resolve django translation deprecation warnings (#847) https://docs.djangoproject.com/en/3.0/releases/3.0/#id3 --- graphene_django/forms/forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/forms/forms.py b/graphene_django/forms/forms.py index 14e68c8..4b81859 100644 --- a/graphene_django/forms/forms.py +++ b/graphene_django/forms/forms.py @@ -2,7 +2,7 @@ import binascii from django.core.exceptions import ValidationError from django.forms import CharField, Field, MultipleChoiceField -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from graphql_relay import from_global_id From 62ecbae61449c080d0651895840dea1ed079cf0a Mon Sep 17 00:00:00 2001 From: luto Date: Mon, 20 Jan 2020 22:05:20 +0100 Subject: [PATCH 25/76] resolve django encoding deprecation warnings (#853) https://docs.djangoproject.com/en/3.0/ref/utils/#django.utils.encoding.force_text --- graphene_django/debug/sql/tracking.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/graphene_django/debug/sql/tracking.py b/graphene_django/debug/sql/tracking.py index 8391eac..a7c9d8d 100644 --- a/graphene_django/debug/sql/tracking.py +++ b/graphene_django/debug/sql/tracking.py @@ -6,7 +6,7 @@ from threading import local from time import time import six -from django.utils.encoding import force_text +from django.utils.encoding import force_str from .types import DjangoDebugSQL @@ -78,7 +78,7 @@ class NormalCursorWrapper(object): def _quote_expr(self, element): if isinstance(element, six.string_types): - return "'%s'" % force_text(element).replace("'", "''") + return "'%s'" % force_str(element).replace("'", "''") else: return repr(element) @@ -91,7 +91,7 @@ class NormalCursorWrapper(object): def _decode(self, param): try: - return force_text(param, strings_only=True) + return force_str(param, strings_only=True) except UnicodeDecodeError: return "(encoded string)" From 8ec456285bb84fad6e1ee6644543140335f613af Mon Sep 17 00:00:00 2001 From: Ilya Zhelyabuzhsky Date: Wed, 29 Jan 2020 15:06:38 +0500 Subject: [PATCH 26/76] Fix force_str deprecation warning (#858) --- graphene_django/converter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/graphene_django/converter.py b/graphene_django/converter.py index b59c906..8b93d17 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -1,6 +1,6 @@ from collections import OrderedDict from django.db import models -from django.utils.encoding import force_text +from django.utils.encoding import force_str from graphene import ( ID, @@ -30,7 +30,7 @@ singledispatch = import_single_dispatch() def convert_choice_name(name): - name = to_const(force_text(name)) + name = to_const(force_str(name)) try: assert_valid_name(name) except AssertionError: From 5c3199883f72279b5f453cd27378521d4589d72e Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Fri, 31 Jan 2020 14:20:18 +0000 Subject: [PATCH 27/76] Fix dependencies for examples (#861) --- examples/cookbook-plain/requirements.txt | 8 ++++---- examples/cookbook/requirements.txt | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/examples/cookbook-plain/requirements.txt b/examples/cookbook-plain/requirements.txt index beed53b..abbe96b 100644 --- a/examples/cookbook-plain/requirements.txt +++ b/examples/cookbook-plain/requirements.txt @@ -1,4 +1,4 @@ -graphene -graphene-django -graphql-core>=2.1rc1 -django==2.2.8 +graphene>=2.1,<3 +graphene-django>=2.1,<3 +graphql-core>=2.1,<3 +django==3.0 diff --git a/examples/cookbook/requirements.txt b/examples/cookbook/requirements.txt index b1baa57..c062358 100644 --- a/examples/cookbook/requirements.txt +++ b/examples/cookbook/requirements.txt @@ -1,5 +1,5 @@ -graphene -graphene-django -graphql-core>=2.1rc1 +graphene>=2.1,<3 +graphene-django>=2.1,<3 +graphql-core>=2.1,<3 django==3.0 django-filter>=2 From 1310509fa150088660783db80b2fc1eafe3ffc44 Mon Sep 17 00:00:00 2001 From: Jason Kraus Date: Wed, 5 Feb 2020 14:16:51 -0800 Subject: [PATCH 28/76] feature(stalebot): bug, documentation, help wanted, and enhancement added to exempt labels (#869) --- .github/stale.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/stale.yml b/.github/stale.yml index c9418f6..dab9fb3 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -6,6 +6,10 @@ daysUntilClose: 14 exemptLabels: - pinned - security + - 🐛bug + - 📖 documentation + - help wanted + - ✨enhancement # Label to use when marking an issue as stale staleLabel: wontfix # Comment to post when marking an issue as stale. Set to `false` to disable From 280b38f804f4619066062e28bcbaba9914e54ab2 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Fri, 7 Feb 2020 09:55:38 +0000 Subject: [PATCH 29/76] Only warn if a field doesn't exist on the Django model (#862) * Only warn if a field doesn't exist on the Django model Also don't warn if the field name matches a custom field. * Expand warning messages --- graphene_django/tests/test_types.py | 18 ++++++--- graphene_django/types.py | 59 ++++++++++++++++++++--------- 2 files changed, 54 insertions(+), 23 deletions(-) diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py index cb31a9c..a25383f 100644 --- a/graphene_django/tests/test_types.py +++ b/graphene_django/tests/test_types.py @@ -320,26 +320,34 @@ def test_django_objecttype_fields_exclude_type_checking(): @with_local_registry def test_django_objecttype_fields_exclude_exist_on_model(): - with pytest.raises(Exception, match=r"Field .* doesn't exist"): + with pytest.warns(UserWarning, match=r"Field name .* doesn't exist"): class Reporter(DjangoObjectType): class Meta: model = ReporterModel fields = ["first_name", "foo", "email"] - with pytest.raises(Exception, match=r"Field .* doesn't exist"): + with pytest.warns( + UserWarning, + match=r"Field name .* matches an attribute on Django model .* but it's not a model field", + ) as record: class Reporter2(DjangoObjectType): class Meta: model = ReporterModel - exclude = ["first_name", "foo", "email"] + fields = ["first_name", "some_method", "email"] - with pytest.raises(Exception, match=r".* exists on model .* but it's not a field"): + # Don't warn if selecting a custom field + with pytest.warns(None) as record: class Reporter3(DjangoObjectType): + custom_field = String() + class Meta: model = ReporterModel - fields = ["first_name", "some_method", "email"] + fields = ["first_name", "custom_field", "email"] + + assert len(record) == 0 class TestDjangoObjectType: diff --git a/graphene_django/types.py b/graphene_django/types.py index 4824c45..129dbe1 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -33,24 +33,6 @@ def construct_fields( ): _model_fields = get_model_fields(model) - # Validate the given fields against the model's fields. - model_field_names = set(field[0] for field in _model_fields) - for fields_list in (only_fields, exclude_fields): - if not fields_list: - continue - for name in fields_list: - if name in model_field_names: - continue - - if hasattr(model, name): - raise Exception( - '"{}" exists on model {} but it\'s not a field.'.format(name, model) - ) - else: - raise Exception( - 'Field "{}" doesn\'t exist on model {}.'.format(name, model) - ) - fields = OrderedDict() for name, field in _model_fields: is_not_in_only = only_fields and name not in only_fields @@ -80,6 +62,44 @@ def construct_fields( return fields +def validate_fields(type_, model, fields, only_fields, exclude_fields): + # Validate the given fields against the model's fields and custom fields + all_field_names = set(fields.keys()) + for fields_list in (only_fields, exclude_fields): + if not fields_list: + continue + for name in fields_list: + if name in all_field_names: + continue + + if hasattr(model, name): + warnings.warn( + ( + 'Field name "{field_name}" matches an attribute on Django model "{app_label}.{object_name}" ' + "but it's not a model field so Graphene cannot determine what type it should be. " + 'Either define the type of the field on DjangoObjectType "{type_}" or remove it from the "fields" list.' + ).format( + field_name=name, + app_label=model._meta.app_label, + object_name=model._meta.object_name, + type_=type_, + ) + ) + + else: + warnings.warn( + ( + 'Field name "{field_name}" doesn\'t exist on Django model "{app_label}.{object_name}". ' + 'Consider removing the field from the "fields" list of DjangoObjectType "{type_}" because it has no effect.' + ).format( + field_name=name, + app_label=model._meta.app_label, + object_name=model._meta.object_name, + type_=type_, + ) + ) + + class DjangoObjectTypeOptions(ObjectTypeOptions): model = None # type: Model registry = None # type: Registry @@ -211,6 +231,9 @@ class DjangoObjectType(ObjectType): _meta=_meta, interfaces=interfaces, **options ) + # Validate fields + validate_fields(cls, model, _meta.fields, fields, exclude) + if not skip_registry: registry.register(cls) From f3f06086065831704093543a7728cc7852cfcf59 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Fri, 7 Feb 2020 09:59:05 +0000 Subject: [PATCH 30/76] v2.8.1 --- 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 1ddc2cb..f8a942d 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.8.0" +__version__ = "2.8.1" __all__ = ["__version__", "DjangoObjectType", "DjangoConnectionField"] From 6b8c5bdefc20dca773f8321774a94e627b5abca5 Mon Sep 17 00:00:00 2001 From: Ben Howes Date: Fri, 7 Feb 2020 10:16:11 +0000 Subject: [PATCH 31/76] Allow for easier template overrides in graphiql (#863) * don't replace * Update graphene_django/templates/graphene/graphiql.html Co-Authored-By: Jonathan Kim * Fix editor styling and initialisation Co-authored-by: Jonathan Kim --- graphene_django/static/graphene_django/graphiql.js | 2 +- graphene_django/templates/graphene/graphiql.html | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/graphene_django/static/graphene_django/graphiql.js b/graphene_django/static/graphene_django/graphiql.js index e38cd62..c939216 100644 --- a/graphene_django/static/graphene_django/graphiql.js +++ b/graphene_django/static/graphene_django/graphiql.js @@ -97,6 +97,6 @@ // Render into the body. ReactDOM.render( React.createElement(GraphiQL, options), - document.body + document.getElementById("editor") ); })(); diff --git a/graphene_django/templates/graphene/graphiql.html b/graphene_django/templates/graphene/graphiql.html index a0d0e1a..d0546bd 100644 --- a/graphene_django/templates/graphene/graphiql.html +++ b/graphene_django/templates/graphene/graphiql.html @@ -10,7 +10,7 @@ add "&raw" to the end of the URL within a browser.