From 219005952a1d143fce6363be3d4d3bc8f8d0ecc5 Mon Sep 17 00:00:00 2001 From: Dan Palmer Date: Thu, 30 Aug 2018 19:29:33 +0100 Subject: [PATCH 001/171] Don't execute on GET for GraphiQL We can also now return GraphiQL earlier in the request handling. --- graphene_django/views.py | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/graphene_django/views.py b/graphene_django/views.py index be7ccf9..9a530de 100644 --- a/graphene_django/views.py +++ b/graphene_django/views.py @@ -124,6 +124,12 @@ class GraphQLView(View): data = self.parse_body(request) show_graphiql = self.graphiql and self.can_display_graphiql(request, data) + if show_graphiql: + return self.render_graphiql( + request, + graphiql_version=self.graphiql_version, + ) + if self.batch: responses = [self.get_response(request, entry) for entry in data] result = "[{}]".format( @@ -137,19 +143,6 @@ class GraphQLView(View): else: result, status_code = self.get_response(request, data, show_graphiql) - if show_graphiql: - query, variables, operation_name, id = self.get_graphql_params( - request, data - ) - return self.render_graphiql( - request, - graphiql_version=self.graphiql_version, - query=query or "", - variables=json.dumps(variables) or "", - operation_name=operation_name or "", - result=result or "", - ) - return HttpResponse( status=status_code, content=result, content_type="application/json" ) From 3755850c2e5de11eb80f7d39d0f8fd31f3cc5d66 Mon Sep 17 00:00:00 2001 From: Dan Palmer Date: Thu, 30 Aug 2018 19:47:48 +0100 Subject: [PATCH 002/171] Use the fragment for the URL --- graphene_django/templates/graphene/graphiql.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/graphene_django/templates/graphene/graphiql.html b/graphene_django/templates/graphene/graphiql.html index 1ba0613..5bc5e04 100644 --- a/graphene_django/templates/graphene/graphiql.html +++ b/graphene_django/templates/graphene/graphiql.html @@ -32,7 +32,7 @@ add "&raw" to the end of the URL within a browser. // Collect the URL parameters var parameters = {}; - window.location.search.substr(1).split('&').forEach(function (entry) { + window.location.hash.substr(1).split('&').forEach(function (entry) { var eq = entry.indexOf('='); if (eq >= 0) { parameters[decodeURIComponent(entry.slice(0, eq))] = @@ -41,7 +41,7 @@ add "&raw" to the end of the URL within a browser. }); // Produce a Location query string from a parameter object. function locationQuery(params) { - return '?' + Object.keys(params).map(function (key) { + return '#' + Object.keys(params).map(function (key) { return encodeURIComponent(key) + '=' + encodeURIComponent(params[key]); }).join('&'); From 0d8f9db3fbeef93be194d386f87dea627f69715e Mon Sep 17 00:00:00 2001 From: Dan Palmer Date: Thu, 30 Aug 2018 19:48:21 +0100 Subject: [PATCH 003/171] Pass options from the fragment, not the template context --- .../templates/graphene/graphiql.html | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/graphene_django/templates/graphene/graphiql.html b/graphene_django/templates/graphene/graphiql.html index 5bc5e04..6515da8 100644 --- a/graphene_django/templates/graphene/graphiql.html +++ b/graphene_django/templates/graphene/graphiql.html @@ -100,22 +100,27 @@ add "&raw" to the end of the URL within a browser. function updateURL() { history.replaceState(null, null, locationQuery(parameters)); } - // Render into the body. - ReactDOM.render( - React.createElement(GraphiQL, { - fetcher: graphQLFetcher, + // If there are any fragment parameters, confirm the user wants to use them. + if (Object.keys(parameters).length + && !window.confirm("An untrusted query has been loaded, continue loading query?")) { + parameters = {}; + } + var options = { + fetcher: graphQLFetcher, onEditQuery: onEditQuery, onEditVariables: onEditVariables, onEditOperationName: onEditOperationName, - query: '{{ query|escapejs }}', - response: '{{ result|escapejs }}', - {% if variables %} - variables: '{{ variables|escapejs }}', - {% endif %} - {% if operation_name %} - operationName: '{{ operation_name|escapejs }}', - {% endif %} - }), + query: parameters.query, + } + if (parameters.variables) { + options.variables = parameters.variables; + } + if (parameters.operation_name) { + options.operationName = parameters.operation_name; + } + // Render into the body. + ReactDOM.render( + React.createElement(GraphiQL, options), document.body ); From 9a5b3556d3d13ec3c46ea4bc953f1df844f0925d Mon Sep 17 00:00:00 2001 From: Dan Palmer Date: Thu, 30 Aug 2018 19:48:38 +0100 Subject: [PATCH 004/171] Special case reloads as allowed if we can --- graphene_django/templates/graphene/graphiql.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/graphene_django/templates/graphene/graphiql.html b/graphene_django/templates/graphene/graphiql.html index 6515da8..0303883 100644 --- a/graphene_django/templates/graphene/graphiql.html +++ b/graphene_django/templates/graphene/graphiql.html @@ -101,7 +101,9 @@ add "&raw" to the end of the URL within a browser. history.replaceState(null, null, locationQuery(parameters)); } // If there are any fragment parameters, confirm the user wants to use them. + var isReload = window.performance ? performance.navigation.type === 1 : false; if (Object.keys(parameters).length + && !isReload && !window.confirm("An untrusted query has been loaded, continue loading query?")) { parameters = {}; } From d1b734f07df87f97f3acc557d990952e3a250e7d Mon Sep 17 00:00:00 2001 From: Dan Palmer Date: Thu, 30 Aug 2018 20:31:39 +0100 Subject: [PATCH 005/171] Allow the user to see the query before prompting This also allows the introspection query through so that the user can edit with intellisense before being prompted. --- .../templates/graphene/graphiql.html | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/graphene_django/templates/graphene/graphiql.html b/graphene_django/templates/graphene/graphiql.html index 0303883..de3126a 100644 --- a/graphene_django/templates/graphene/graphiql.html +++ b/graphene_django/templates/graphene/graphiql.html @@ -58,9 +58,31 @@ add "&raw" to the end of the URL within a browser. otherParams[k] = parameters[k]; } } + + // If there are any fragment parameters, confirm the user wants to use them. + var isReload = window.performance ? performance.navigation.type === 1 : false; + var isQueryTrusted = Object.keys(parameters).length === 0 || isReload; + var fetchURL = locationQuery(otherParams); + // Defines a GraphQL fetcher using the fetch API. function graphQLFetcher(graphQLParams) { + var isIntrospectionQuery = ( + graphQLParams.query !== parameters.query + && graphQLParams.query.indexOf('IntrospectionQuery') !== -1 + ); + + if (!isQueryTrusted + && !isIntrospectionQuery + && !window.confirm("This query was loaded from a link, are you sure you want to execute it?")) { + return Promise.resolve('Aborting query.'); + } + + // We don't want to set this for the introspection query + if (!isIntrospectionQuery) { + isQueryTrusted = true; + } + var headers = { 'Accept': 'application/json', 'Content-Type': 'application/json' @@ -100,13 +122,6 @@ add "&raw" to the end of the URL within a browser. function updateURL() { history.replaceState(null, null, locationQuery(parameters)); } - // If there are any fragment parameters, confirm the user wants to use them. - var isReload = window.performance ? performance.navigation.type === 1 : false; - if (Object.keys(parameters).length - && !isReload - && !window.confirm("An untrusted query has been loaded, continue loading query?")) { - parameters = {}; - } var options = { fetcher: graphQLFetcher, onEditQuery: onEditQuery, From 24ebc20bf449b4688220e2dac0b43240d0fc5a6c Mon Sep 17 00:00:00 2001 From: Dan Palmer Date: Thu, 30 Aug 2018 20:32:38 +0100 Subject: [PATCH 006/171] Fix comment --- graphene_django/templates/graphene/graphiql.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/templates/graphene/graphiql.html b/graphene_django/templates/graphene/graphiql.html index de3126a..cf61686 100644 --- a/graphene_django/templates/graphene/graphiql.html +++ b/graphene_django/templates/graphene/graphiql.html @@ -39,7 +39,7 @@ add "&raw" to the end of the URL within a browser. decodeURIComponent(entry.slice(eq + 1)); } }); - // Produce a Location query string from a parameter object. + // Produce a Location fragment string from a parameter object. function locationQuery(params) { return '#' + Object.keys(params).map(function (key) { return encodeURIComponent(key) + '=' + From e50e12bc9fa693bde1af1b61f2a97eaba90007bd Mon Sep 17 00:00:00 2001 From: Dan Palmer Date: Thu, 30 Aug 2018 20:36:26 +0100 Subject: [PATCH 007/171] Move GraphiQL's JS into a separate file for ease of CSP --- .../static/graphene_django/graphiql.js | 119 +++++++++++++++++ .../templates/graphene/graphiql.html | 120 +----------------- 2 files changed, 121 insertions(+), 118 deletions(-) create mode 100644 graphene_django/static/graphene_django/graphiql.js diff --git a/graphene_django/static/graphene_django/graphiql.js b/graphene_django/static/graphene_django/graphiql.js new file mode 100644 index 0000000..ad55e03 --- /dev/null +++ b/graphene_django/static/graphene_django/graphiql.js @@ -0,0 +1,119 @@ +(function() { + + // Parse the cookie value for a CSRF token + var csrftoken; + var cookies = ('; ' + document.cookie).split('; csrftoken='); + if (cookies.length == 2) + csrftoken = cookies.pop().split(';').shift(); + + // Collect the URL parameters + var parameters = {}; + window.location.hash.substr(1).split('&').forEach(function (entry) { + var eq = entry.indexOf('='); + if (eq >= 0) { + parameters[decodeURIComponent(entry.slice(0, eq))] = + decodeURIComponent(entry.slice(eq + 1)); + } + }); + // Produce a Location fragment string from a parameter object. + function locationQuery(params) { + return '#' + Object.keys(params).map(function (key) { + return encodeURIComponent(key) + '=' + + encodeURIComponent(params[key]); + }).join('&'); + } + // Derive a fetch URL from the current URL, sans the GraphQL parameters. + var graphqlParamNames = { + query: true, + variables: true, + operationName: true + }; + var otherParams = {}; + for (var k in parameters) { + if (parameters.hasOwnProperty(k) && graphqlParamNames[k] !== true) { + otherParams[k] = parameters[k]; + } + } + + // If there are any fragment parameters, confirm the user wants to use them. + var isReload = window.performance ? performance.navigation.type === 1 : false; + var isQueryTrusted = Object.keys(parameters).length === 0 || isReload; + + var fetchURL = locationQuery(otherParams); + + // Defines a GraphQL fetcher using the fetch API. + function graphQLFetcher(graphQLParams) { + var isIntrospectionQuery = ( + graphQLParams.query !== parameters.query + && graphQLParams.query.indexOf('IntrospectionQuery') !== -1 + ); + + if (!isQueryTrusted + && !isIntrospectionQuery + && !window.confirm("This query was loaded from a link, are you sure you want to execute it?")) { + return Promise.resolve('Aborting query.'); + } + + // We don't want to set this for the introspection query + if (!isIntrospectionQuery) { + isQueryTrusted = true; + } + + var headers = { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }; + if (csrftoken) { + headers['X-CSRFToken'] = csrftoken; + } + return fetch(fetchURL, { + method: 'post', + headers: headers, + body: JSON.stringify(graphQLParams), + credentials: 'include', + }).then(function (response) { + return response.text(); + }).then(function (responseBody) { + try { + return JSON.parse(responseBody); + } catch (error) { + return responseBody; + } + }); + } + // When the query and variables string is edited, update the URL bar so + // that it can be easily shared. + function onEditQuery(newQuery) { + parameters.query = newQuery; + updateURL(); + } + function onEditVariables(newVariables) { + parameters.variables = newVariables; + updateURL(); + } + function onEditOperationName(newOperationName) { + parameters.operationName = newOperationName; + updateURL(); + } + function updateURL() { + history.replaceState(null, null, locationQuery(parameters)); + } + var options = { + fetcher: graphQLFetcher, + onEditQuery: onEditQuery, + onEditVariables: onEditVariables, + onEditOperationName: onEditOperationName, + query: parameters.query, + } + if (parameters.variables) { + options.variables = parameters.variables; + } + if (parameters.operation_name) { + options.operationName = parameters.operation_name; + } + // Render into the body. + ReactDOM.render( + React.createElement(GraphiQL, options), + document.body + ); +})(); diff --git a/graphene_django/templates/graphene/graphiql.html b/graphene_django/templates/graphene/graphiql.html index cf61686..7bd1178 100644 --- a/graphene_django/templates/graphene/graphiql.html +++ b/graphene_django/templates/graphene/graphiql.html @@ -5,6 +5,7 @@ exploring GraphQL. If you wish to receive JSON, provide the header "Accept: application/json" or add "&raw" to the end of the URL within a browser. --> +{% load static %} @@ -23,123 +24,6 @@ add "&raw" to the end of the URL within a browser. - + From 7e8f6dbd4ec0fe633507e5ba6bc22bda4a0e59fc Mon Sep 17 00:00:00 2001 From: Dan Palmer Date: Thu, 30 Aug 2018 20:58:00 +0100 Subject: [PATCH 008/171] Change quotes to improve some syntax highlighting --- graphene_django/templates/graphene/graphiql.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/templates/graphene/graphiql.html b/graphene_django/templates/graphene/graphiql.html index 7bd1178..af11274 100644 --- a/graphene_django/templates/graphene/graphiql.html +++ b/graphene_django/templates/graphene/graphiql.html @@ -24,6 +24,6 @@ add "&raw" to the end of the URL within a browser. - + From cb87f4016546aac5f4973e6d01fb31b0cbc10d4e Mon Sep 17 00:00:00 2001 From: Dan Palmer Date: Thu, 30 Aug 2018 20:59:09 +0100 Subject: [PATCH 009/171] Document that staticfiles is now a dependency. --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 4e0b01d..ef3f40c 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ pip install "graphene-django>=2.0" ```python INSTALLED_APPS = ( # ... + 'django.contrib.staticfiles', # Required for GraphiQL 'graphene_django', ) From 2b08e59bea2a0d84dc68832dc6d2deae98e77d3a Mon Sep 17 00:00:00 2001 From: Dan Palmer Date: Sun, 9 Sep 2018 21:44:30 +0100 Subject: [PATCH 010/171] Revert to default query execution behaviour The only security risk here is persuading a user to execute a mutation, which is probably not a big risk. To mitigate this risk and still keep the same UX (that is so valuable), would require more work than is proportionate for this PR. --- .../static/graphene_django/graphiql.js | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/graphene_django/static/graphene_django/graphiql.js b/graphene_django/static/graphene_django/graphiql.js index ad55e03..2be7e3c 100644 --- a/graphene_django/static/graphene_django/graphiql.js +++ b/graphene_django/static/graphene_django/graphiql.js @@ -35,30 +35,10 @@ } } - // If there are any fragment parameters, confirm the user wants to use them. - var isReload = window.performance ? performance.navigation.type === 1 : false; - var isQueryTrusted = Object.keys(parameters).length === 0 || isReload; - var fetchURL = locationQuery(otherParams); // Defines a GraphQL fetcher using the fetch API. function graphQLFetcher(graphQLParams) { - var isIntrospectionQuery = ( - graphQLParams.query !== parameters.query - && graphQLParams.query.indexOf('IntrospectionQuery') !== -1 - ); - - if (!isQueryTrusted - && !isIntrospectionQuery - && !window.confirm("This query was loaded from a link, are you sure you want to execute it?")) { - return Promise.resolve('Aborting query.'); - } - - // We don't want to set this for the introspection query - if (!isIntrospectionQuery) { - isQueryTrusted = true; - } - var headers = { 'Accept': 'application/json', 'Content-Type': 'application/json' From f3144bf996cac8f137b1838a0d2f3717b32907bf Mon Sep 17 00:00:00 2001 From: Alonso Date: Wed, 26 Sep 2018 13:07:50 -0500 Subject: [PATCH 011/171] fix order in params --- docs/authorization.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/authorization.rst b/docs/authorization.rst index 7a08481..86ad66a 100644 --- a/docs/authorization.rst +++ b/docs/authorization.rst @@ -114,7 +114,7 @@ method to your ``DjangoObjectType``. interfaces = (relay.Node, ) @classmethod - def get_node(cls, id, info): + def get_node(cls, info, id): try: post = cls._meta.model.objects.get(id=id) except cls._meta.model.DoesNotExist: From 19ef9a094ad8dbf20234624b21d567fe86f7b413 Mon Sep 17 00:00:00 2001 From: Khaled Alqenaei Date: Thu, 18 Oct 2018 11:33:53 -0700 Subject: [PATCH 012/171] Making the example working for Django 2.1.2 --- .../cookbook/ingredients/models.py | 4 +++- .../cookbook/ingredients/schema.py | 18 +++++++++--------- .../cookbook-plain/cookbook/recipes/models.py | 9 +++++---- .../cookbook-plain/cookbook/recipes/schema.py | 16 ++++++++-------- examples/cookbook-plain/cookbook/settings.py | 3 +-- examples/cookbook-plain/cookbook/urls.py | 6 +++--- examples/cookbook-plain/requirements.txt | 2 +- 7 files changed, 30 insertions(+), 28 deletions(-) diff --git a/examples/cookbook-plain/cookbook/ingredients/models.py b/examples/cookbook-plain/cookbook/ingredients/models.py index 2f0eba3..5836949 100644 --- a/examples/cookbook-plain/cookbook/ingredients/models.py +++ b/examples/cookbook-plain/cookbook/ingredients/models.py @@ -2,6 +2,8 @@ from django.db import models class Category(models.Model): + class Meta: + verbose_name_plural = 'Categories' name = models.CharField(max_length=100) def __str__(self): @@ -11,7 +13,7 @@ class Category(models.Model): class Ingredient(models.Model): name = models.CharField(max_length=100) notes = models.TextField(null=True, blank=True) - category = models.ForeignKey(Category, related_name='ingredients') + category = models.ForeignKey(Category, related_name='ingredients', on_delete=models.CASCADE) def __str__(self): return self.name diff --git a/examples/cookbook-plain/cookbook/ingredients/schema.py b/examples/cookbook-plain/cookbook/ingredients/schema.py index 1f3bb18..51f25ed 100644 --- a/examples/cookbook-plain/cookbook/ingredients/schema.py +++ b/examples/cookbook-plain/cookbook/ingredients/schema.py @@ -1,7 +1,7 @@ import graphene from graphene_django.types import DjangoObjectType -from cookbook.ingredients.models import Category, Ingredient +from .models import Category, Ingredient class CategoryType(DjangoObjectType): @@ -25,16 +25,16 @@ class Query(object): name=graphene.String()) all_ingredients = graphene.List(IngredientType) - def resolve_all_categories(self, args, context, info): + def resolve_all_categories(self, context, **kwargs): return Category.objects.all() - def resolve_all_ingredients(self, args, context, info): + def resolve_all_ingredients(self, context, **kwargs): # We can easily optimize query count in the resolve method return Ingredient.objects.select_related('category').all() - def resolve_category(self, args, context, info): - id = args.get('id') - name = args.get('name') + def resolve_category(self, context, **kwargs): + id = kwargs.get('id') + name = kwargs.get('name') if id is not None: return Category.objects.get(pk=id) @@ -44,9 +44,9 @@ class Query(object): return None - def resolve_ingredient(self, args, context, info): - id = args.get('id') - name = args.get('name') + def resolve_ingredient(self, context, **kwargs): + id = kwargs.get('id') + name = kwargs.get('name') if id is not None: return Ingredient.objects.get(pk=id) diff --git a/examples/cookbook-plain/cookbook/recipes/models.py b/examples/cookbook-plain/cookbook/recipes/models.py index ca12fac..382b88e 100644 --- a/examples/cookbook-plain/cookbook/recipes/models.py +++ b/examples/cookbook-plain/cookbook/recipes/models.py @@ -1,17 +1,18 @@ from django.db import models -from cookbook.ingredients.models import Ingredient +from ..ingredients.models import Ingredient class Recipe(models.Model): title = models.CharField(max_length=100) instructions = models.TextField() - __unicode__ = lambda self: self.title + def __str__(self): + return self.title class RecipeIngredient(models.Model): - recipe = models.ForeignKey(Recipe, related_name='amounts') - ingredient = models.ForeignKey(Ingredient, related_name='used_by') + recipe = models.ForeignKey(Recipe, related_name='amounts', on_delete=models.CASCADE) + ingredient = models.ForeignKey(Ingredient, related_name='used_by', on_delete=models.CASCADE) amount = models.FloatField() unit = models.CharField(max_length=20, choices=( ('unit', 'Units'), diff --git a/examples/cookbook-plain/cookbook/recipes/schema.py b/examples/cookbook-plain/cookbook/recipes/schema.py index 040c985..620d510 100644 --- a/examples/cookbook-plain/cookbook/recipes/schema.py +++ b/examples/cookbook-plain/cookbook/recipes/schema.py @@ -1,7 +1,7 @@ import graphene from graphene_django.types import DjangoObjectType -from cookbook.recipes.models import Recipe, RecipeIngredient +from .models import Recipe, RecipeIngredient class RecipeType(DjangoObjectType): @@ -24,9 +24,9 @@ class Query(object): id=graphene.Int()) all_recipeingredients = graphene.List(RecipeIngredientType) - def resolve_recipe(self, args, context, info): - id = args.get('id') - title = args.get('title') + def resolve_recipe(self, context, **kwargs): + id = kwargs.get('id') + title = kwargs.get('title') if id is not None: return Recipe.objects.get(pk=id) @@ -36,17 +36,17 @@ class Query(object): return None - def resolve_recipeingredient(self, args, context, info): - id = args.get('id') + def resolve_recipeingredient(self, context, **kwargs): + id = kwargs.get('id') if id is not None: return RecipeIngredient.objects.get(pk=id) return None - def resolve_all_recipes(self, args, context, info): + def resolve_all_recipes(self, context, **kwargs): return Recipe.objects.all() - def resolve_all_recipeingredients(self, args, context, info): + def resolve_all_recipeingredients(self, context, **kwargs): related = ['recipe', 'ingredient'] return RecipeIngredient.objects.select_related(*related).all() diff --git a/examples/cookbook-plain/cookbook/settings.py b/examples/cookbook-plain/cookbook/settings.py index 948292d..d846db4 100644 --- a/examples/cookbook-plain/cookbook/settings.py +++ b/examples/cookbook-plain/cookbook/settings.py @@ -44,13 +44,12 @@ INSTALLED_APPS = [ 'cookbook.recipes.apps.RecipesConfig', ] -MIDDLEWARE_CLASSES = [ +MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] diff --git a/examples/cookbook-plain/cookbook/urls.py b/examples/cookbook-plain/cookbook/urls.py index 9f8755b..4f87da0 100644 --- a/examples/cookbook-plain/cookbook/urls.py +++ b/examples/cookbook-plain/cookbook/urls.py @@ -1,10 +1,10 @@ -from django.conf.urls import url +from django.urls import path from django.contrib import admin from graphene_django.views import GraphQLView urlpatterns = [ - url(r'^admin/', admin.site.urls), - url(r'^graphql', GraphQLView.as_view(graphiql=True)), + path('admin/', admin.site.urls), + path('graphql/', GraphQLView.as_view(graphiql=True)), ] diff --git a/examples/cookbook-plain/requirements.txt b/examples/cookbook-plain/requirements.txt index 362a39a..539fd67 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==1.9 +django==2.1.2 From 4359e1f312d3d27b203dec2a5bfd4c9a8346d42b Mon Sep 17 00:00:00 2001 From: Khaled Alqenaei Date: Thu, 18 Oct 2018 11:36:35 -0700 Subject: [PATCH 013/171] Making the example working for Django 2.1.2 --- .../migrations/0003_auto_20181018_1746.py | 17 +++++++++++++++++ .../migrations/0003_auto_20181018_1728.py | 18 ++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 examples/cookbook-plain/cookbook/ingredients/migrations/0003_auto_20181018_1746.py create mode 100644 examples/cookbook-plain/cookbook/recipes/migrations/0003_auto_20181018_1728.py diff --git a/examples/cookbook-plain/cookbook/ingredients/migrations/0003_auto_20181018_1746.py b/examples/cookbook-plain/cookbook/ingredients/migrations/0003_auto_20181018_1746.py new file mode 100644 index 0000000..184e79e --- /dev/null +++ b/examples/cookbook-plain/cookbook/ingredients/migrations/0003_auto_20181018_1746.py @@ -0,0 +1,17 @@ +# Generated by Django 2.0 on 2018-10-18 17:46 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('ingredients', '0002_auto_20161104_0050'), + ] + + operations = [ + migrations.AlterModelOptions( + name='category', + options={'verbose_name_plural': 'Categories'}, + ), + ] diff --git a/examples/cookbook-plain/cookbook/recipes/migrations/0003_auto_20181018_1728.py b/examples/cookbook-plain/cookbook/recipes/migrations/0003_auto_20181018_1728.py new file mode 100644 index 0000000..7a8df49 --- /dev/null +++ b/examples/cookbook-plain/cookbook/recipes/migrations/0003_auto_20181018_1728.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0 on 2018-10-18 17:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recipes', '0002_auto_20161104_0106'), + ] + + operations = [ + migrations.AlterField( + model_name='recipeingredient', + name='unit', + field=models.CharField(choices=[('unit', 'Units'), ('kg', 'Kilograms'), ('l', 'Litres'), ('st', 'Shots')], max_length=20), + ), + ] From 905b4249f3ff71b35105638242c3f908ead70324 Mon Sep 17 00:00:00 2001 From: Khaled Alqenaei Date: Thu, 18 Oct 2018 12:27:23 -0700 Subject: [PATCH 014/171] Updated ingredients/schema.py and recipes/schema.py to be more readable. --- .../cookbook-plain/cookbook/ingredients/schema.py | 14 ++++---------- examples/cookbook-plain/cookbook/recipes/schema.py | 13 ++++--------- 2 files changed, 8 insertions(+), 19 deletions(-) diff --git a/examples/cookbook-plain/cookbook/ingredients/schema.py b/examples/cookbook-plain/cookbook/ingredients/schema.py index 51f25ed..e7ef688 100644 --- a/examples/cookbook-plain/cookbook/ingredients/schema.py +++ b/examples/cookbook-plain/cookbook/ingredients/schema.py @@ -25,17 +25,14 @@ class Query(object): name=graphene.String()) all_ingredients = graphene.List(IngredientType) - def resolve_all_categories(self, context, **kwargs): + def resolve_all_categories(self, context): return Category.objects.all() - def resolve_all_ingredients(self, context, **kwargs): + def resolve_all_ingredients(self, context): # We can easily optimize query count in the resolve method return Ingredient.objects.select_related('category').all() - def resolve_category(self, context, **kwargs): - id = kwargs.get('id') - name = kwargs.get('name') - + def resolve_category(self, context, id=None, name=None): if id is not None: return Category.objects.get(pk=id) @@ -44,10 +41,7 @@ class Query(object): return None - def resolve_ingredient(self, context, **kwargs): - id = kwargs.get('id') - name = kwargs.get('name') - + def resolve_ingredient(self, context, id=None, name=None): if id is not None: return Ingredient.objects.get(pk=id) diff --git a/examples/cookbook-plain/cookbook/recipes/schema.py b/examples/cookbook-plain/cookbook/recipes/schema.py index 620d510..74692f8 100644 --- a/examples/cookbook-plain/cookbook/recipes/schema.py +++ b/examples/cookbook-plain/cookbook/recipes/schema.py @@ -24,10 +24,7 @@ class Query(object): id=graphene.Int()) all_recipeingredients = graphene.List(RecipeIngredientType) - def resolve_recipe(self, context, **kwargs): - id = kwargs.get('id') - title = kwargs.get('title') - + def resolve_recipe(self, context, id=None, title=None): if id is not None: return Recipe.objects.get(pk=id) @@ -36,17 +33,15 @@ class Query(object): return None - def resolve_recipeingredient(self, context, **kwargs): - id = kwargs.get('id') - + def resolve_recipeingredient(self, context, id=None): if id is not None: return RecipeIngredient.objects.get(pk=id) return None - def resolve_all_recipes(self, context, **kwargs): + def resolve_all_recipes(self, context): return Recipe.objects.all() - def resolve_all_recipeingredients(self, context, **kwargs): + def resolve_all_recipeingredients(self, context): related = ['recipe', 'ingredient'] return RecipeIngredient.objects.select_related(*related).all() From ce8fa7f9f2fa6afabae0d7fa93c8cc85dc5945b9 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Sat, 9 Mar 2019 22:39:04 +0100 Subject: [PATCH 015/171] Fix lint error --- graphene_django/types.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/graphene_django/types.py b/graphene_django/types.py index aa8b5a3..4441a9a 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -1,5 +1,7 @@ +import six from collections import OrderedDict +from django.db.models import Model from django.utils.functional import SimpleLazyObject from graphene import Field from graphene.relay import Connection, Node @@ -11,6 +13,10 @@ from .registry import Registry, get_global_registry from .utils import DJANGO_FILTER_INSTALLED, get_model_fields, is_valid_django_model +if six.PY3: + from typing import Type + + def construct_fields(model, registry, only_fields, exclude_fields): _model_fields = get_model_fields(model) From ae126a6dc3d7ebc24121e6201ad078f413c81455 Mon Sep 17 00:00:00 2001 From: Charles Bradshaw Date: Tue, 19 Mar 2019 16:20:26 -0400 Subject: [PATCH 016/171] Add Introspection Schema Link in Documentation (#489) By the end of the Graphene and Django Tutorial using Relay, one might think they have finished everything needed server side for Relay, but six sections later, in a section that doesn't mention Relay in the title, the final required step for Relay is documented. --- docs/tutorial-relay.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/tutorial-relay.rst b/docs/tutorial-relay.rst index f2502d7..188a108 100644 --- a/docs/tutorial-relay.rst +++ b/docs/tutorial-relay.rst @@ -345,3 +345,10 @@ Or you can get only 'meat' ingredients containing the letter 'e': } } } + + + +Final Steps +^^^^^^^^^^^ + +We have created a GraphQL endpoint that will work with Relay, but for Relay to work it needs access to a (non python) schema. Instructions to export the schema can be found on the `Introspection Schema `__ part of this guide. From 75bf39852326c06236efe93f50c8d2ade3cd933f Mon Sep 17 00:00:00 2001 From: Liam O'Flynn Date: Tue, 19 Mar 2019 13:22:04 -0700 Subject: [PATCH 017/171] Ensure code example is fully functional (#477) Simple change, it took me a while to figure out why the documentation's code was throwing an AssertionError :) --- docs/tutorial-relay.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorial-relay.rst b/docs/tutorial-relay.rst index 188a108..3627311 100644 --- a/docs/tutorial-relay.rst +++ b/docs/tutorial-relay.rst @@ -147,7 +147,7 @@ Create ``cookbook/ingredients/schema.py`` and type the following: interfaces = (relay.Node, ) - class Query(object): + class Query(graphene.ObjectType): category = relay.Node.Field(CategoryNode) all_categories = DjangoFilterConnectionField(CategoryNode) From 263c7267cbc3ba1511a28234edf484896f38d3d2 Mon Sep 17 00:00:00 2001 From: Atul Varma Date: Tue, 19 Mar 2019 16:24:24 -0400 Subject: [PATCH 018/171] Fix code errors in form-mutations.rst (#499) This fixes what appear to be some code errors/typos: * The `FormMutation` class was renamed to `DjangoFormMutation`, and `ModelFormMutation` to `DjangoModelFormMutation`, in 40610c64a3be003719d88db439e442085ed18072. * `form_valid` was renamed to `perform_mutate` in 463ce68b16b070c0a49637dc71755a33acdf9d49. It also clarifies a few things that I found confusing: * It explicitly mentions that `perform_mutate` is a class method. * The code samples now import the form classes from their packages, so readers know where to import them from too. --- docs/form-mutations.rst | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/docs/form-mutations.rst b/docs/form-mutations.rst index e721a78..bbaadb1 100644 --- a/docs/form-mutations.rst +++ b/docs/form-mutations.rst @@ -4,27 +4,31 @@ Integration with Django forms Graphene-Django comes with mutation classes that will convert the fields on Django forms into inputs on a mutation. *Note: the API is experimental and will likely change in the future.* -FormMutation ------------- +DjangoFormMutation +------------------ .. code:: python + from graphene_django.forms.mutation import DjangoFormMutation + class MyForm(forms.Form): name = forms.CharField() - class MyMutation(FormMutation): + class MyMutation(DjangoFormMutation): class Meta: form_class = MyForm ``MyMutation`` will automatically receive an ``input`` argument. This argument should be a ``dict`` where the key is ``name`` and the value is a string. -ModelFormMutation ------------------ +DjangoModelFormMutation +----------------------- -``ModelFormMutation`` will pull the fields from a ``ModelForm``. +``DjangoModelFormMutation`` will pull the fields from a ``ModelForm``. .. code:: python + from graphene_django.forms.mutation import DjangoModelFormMutation + class Pet(models.Model): name = models.CharField() @@ -61,8 +65,8 @@ Form validation Form mutations will call ``is_valid()`` on your forms. -If the form is valid then ``form_valid(form, info)`` is called on the mutation. Override this method to change how -the form is saved or to return a different Graphene object type. +If the form is valid then the class method ``perform_mutate(form, info)`` is called on the mutation. Override this method +to change how the form is saved or to return a different Graphene object type. If the form is *not* valid then a list of errors will be returned. These errors have two fields: ``field``, a string containing the name of the invalid form field, and ``messages``, a list of strings with the validation messages. From ea2cd9894fa1c8a65c365d1b7c042e27df519324 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Tue, 19 Mar 2019 20:34:10 +0000 Subject: [PATCH 019/171] Always use HTTPS for CDN files (#498) * Always use HTTPS for CDN files There's no point using insecure, deprecated HTTP even if the current page is on HTTP. * add integrity and crossorigin attributes --- .../templates/graphene/graphiql.html | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/graphene_django/templates/graphene/graphiql.html b/graphene_django/templates/graphene/graphiql.html index af11274..c0c9af1 100644 --- a/graphene_django/templates/graphene/graphiql.html +++ b/graphene_django/templates/graphene/graphiql.html @@ -17,11 +17,20 @@ add "&raw" to the end of the URL within a browser. width: 100%; } - - - - - + + + + + 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 020/171] 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 021/171] 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 367c077a49699ce822e37ce43432e47ce1d10260 Mon Sep 17 00:00:00 2001 From: sierreis <48896364+sierreis@users.noreply.github.com> Date: Mon, 25 Mar 2019 12:45:43 -0400 Subject: [PATCH 022/171] Add static files to MANIFEST.in At the moment, static files are not included in the package data when installing using setuptools. This is necessary for the GraphiQL view. --- MANIFEST.in | 1 + 1 file changed, 1 insertion(+) diff --git a/MANIFEST.in b/MANIFEST.in index 3c3d4f9..4677330 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,3 @@ include README.md LICENSE recursive-include graphene_django/templates * +recursive-include graphene_django/static * 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 023/171] 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 024/171] 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 025/171] 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 026/171] 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 027/171] 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 d5d0c519ceaf18e470cd4e2e9cf5270d7a9b079d Mon Sep 17 00:00:00 2001 From: Ronny Vedrilla Date: Wed, 27 Mar 2019 15:21:15 +0100 Subject: [PATCH 028/171] Replaced a copy-paste error causing one test case not to run --- graphene_django/tests/test_converter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/graphene_django/tests/test_converter.py b/graphene_django/tests/test_converter.py index 5dc0184..eac5851 100644 --- a/graphene_django/tests/test_converter.py +++ b/graphene_django/tests/test_converter.py @@ -83,7 +83,7 @@ def test_should_image_convert_string(): assert_conversion(models.ImageField, graphene.String) -def test_should_url_convert_string(): +def test_should_file_path_field_convert_string(): assert_conversion(models.FilePathField, graphene.String) @@ -91,7 +91,7 @@ def test_should_auto_convert_id(): assert_conversion(models.AutoField, graphene.ID, primary_key=True) -def test_should_auto_convert_id(): +def test_should_uuid_convert_id(): assert_conversion(models.UUIDField, graphene.UUID) From 547a4cb5767d97ba5d2c364bd525c14330f6330d Mon Sep 17 00:00:00 2001 From: Ronny Vedrilla Date: Wed, 27 Mar 2019 16:30:35 +0100 Subject: [PATCH 029/171] Missing LOC in django model form documentation (fixes #602) --- docs/form-mutations.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/form-mutations.rst b/docs/form-mutations.rst index bbaadb1..85b89e8 100644 --- a/docs/form-mutations.rst +++ b/docs/form-mutations.rst @@ -43,6 +43,8 @@ DjangoModelFormMutation model = Pet class PetMutation(DjangoModelFormMutation): + pet = Field(PetType) + class Meta: form_class = PetForm 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 030/171] 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 031/171] 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 b491878c27eb117ac72958c72333b7a9b10e6d16 Mon Sep 17 00:00:00 2001 From: Ronny Vedrilla Date: Fri, 29 Mar 2019 11:51:40 +0100 Subject: [PATCH 032/171] * Added test class for django api unittests and documentation how to use it --- docs/index.rst | 1 + docs/testing.rst | 60 ++++++++++++++++++++++++++++ graphene_django/tests/base_test.py | 64 ++++++++++++++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 docs/testing.rst create mode 100644 graphene_django/tests/base_test.py diff --git a/docs/index.rst b/docs/index.rst index 7c64ae7..9469c29 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -14,3 +14,4 @@ Contents: rest-framework form-mutations introspection + testing diff --git a/docs/testing.rst b/docs/testing.rst new file mode 100644 index 0000000..a4d5518 --- /dev/null +++ b/docs/testing.rst @@ -0,0 +1,60 @@ +Testing API calls with django +============================= + +If you want to unittest your API calls derive your test case from the class `GraphQLTestCase`. + +Usage: + +.. code:: python + + import json + + from graphene_django.tests.base_test import GraphQLTestCase + from my_project.config.schema import schema + + class MyFancyTestCase(GraphQLTestCase): + # Here you need to inject your test case's schema + GRAPHQL_SCHEMA = schema + + def test_some_query(self): + response = self.query( + ''' + query { + myModel { + id + name + } + } + ''', + op_name='myModel' + ) + + content = json.loads(response.content) + + # This validates the status code and if you get errors + self.assertResponseNoErrors(response) + + # Add some more asserts if you like + ... + + def test_some_mutation(self): + response = self.query( + ''' + mutation myMutation($input: MyMutationInput!) { + myMutation(input: $input) { + my-model { + id + name + } + } + } + ''', + op_name='myMutation', + input_data={'my_field': 'foo', 'other_field': 'bar'} + ) + + # This validates the status code and if you get errors + self.assertResponseNoErrors(response) + + # Add some more asserts if you like + ... diff --git a/graphene_django/tests/base_test.py b/graphene_django/tests/base_test.py new file mode 100644 index 0000000..8ec2fae --- /dev/null +++ b/graphene_django/tests/base_test.py @@ -0,0 +1,64 @@ +import json + +from django.http import HttpResponse +from django.test import Client +from django.test import TestCase + + +class GraphQLTestCase(TestCase): + """ + Based on: https://www.sam.today/blog/testing-graphql-with-graphene-django/ + """ + + # URL to graphql endpoint + GRAPHQL_URL = '/graphql/' + # Here you need to set your graphql schema for the tests + GRAPHQL_SCHEMA = None + + @classmethod + def setUpClass(cls): + super().setUpClass() + + if not cls.GRAPHQL_SCHEMA: + raise AttributeError('Variable GRAPHQL_SCHEMA not defined in GraphQLTestCase.') + + cls._client = Client(cls.GRAPHQL_SCHEMA) + + def query(self, query: str, op_name: str = None, input_data: dict = None): + """ + Args: + query (string) - GraphQL query to run + op_name (string) - If the query is a mutation or named query, you must + supply the op_name. For annon queries ("{ ... }"), + should be None (default). + input_data (dict) - If provided, the $input variable in GraphQL will be set + to this value + + Returns: + Response object from client + """ + body = {'query': query} + if op_name: + body['operation_name'] = op_name + if input_data: + body['variables'] = {'input': input_data} + + resp = self._client.post(self.GRAPHQL_URL, json.dumps(body), + content_type='application/json') + return resp + + def assertResponseNoErrors(self, resp: HttpResponse): + """ + Assert that the call went through correctly. 200 means the syntax is ok, if there are no `errors`, + the call was fine. + """ + content = json.loads(resp.content) + self.assertEqual(resp.status_code, 200) + self.assertNotIn('errors', list(content.keys())) + + def assertResponseHasErrors(self, resp: HttpResponse): + """ + Assert that the call was failing. Take care: Even with errors, GraphQL returns status 200! + """ + content = json.loads(resp.content) + self.assertIn('errors', list(content.keys())) From 3c11a980febe976669dda08e4ef00c8152530f8e Mon Sep 17 00:00:00 2001 From: Ronny Vedrilla Date: Fri, 29 Mar 2019 12:53:18 +0100 Subject: [PATCH 033/171] Python 2.7 syntax compat --- graphene_django/tests/base_test.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/graphene_django/tests/base_test.py b/graphene_django/tests/base_test.py index 8ec2fae..471dffe 100644 --- a/graphene_django/tests/base_test.py +++ b/graphene_django/tests/base_test.py @@ -17,14 +17,14 @@ class GraphQLTestCase(TestCase): @classmethod def setUpClass(cls): - super().setUpClass() + super(GraphQLTestCase, cls).setUpClass() if not cls.GRAPHQL_SCHEMA: raise AttributeError('Variable GRAPHQL_SCHEMA not defined in GraphQLTestCase.') cls._client = Client(cls.GRAPHQL_SCHEMA) - def query(self, query: str, op_name: str = None, input_data: dict = None): + def query(self, query, op_name=None, input_data=None): """ Args: query (string) - GraphQL query to run @@ -47,18 +47,20 @@ class GraphQLTestCase(TestCase): content_type='application/json') return resp - def assertResponseNoErrors(self, resp: HttpResponse): + def assertResponseNoErrors(self, resp): """ Assert that the call went through correctly. 200 means the syntax is ok, if there are no `errors`, the call was fine. + :resp HttpResponse: Response """ content = json.loads(resp.content) self.assertEqual(resp.status_code, 200) self.assertNotIn('errors', list(content.keys())) - def assertResponseHasErrors(self, resp: HttpResponse): + def assertResponseHasErrors(self, resp): """ Assert that the call was failing. Take care: Even with errors, GraphQL returns status 200! + :resp HttpResponse: Response """ content = json.loads(resp.content) self.assertIn('errors', list(content.keys())) From 8beadc759f1eb98174a65dd206d25f9835596827 Mon Sep 17 00:00:00 2001 From: Alexandre Kirszenberg Date: Sat, 30 Mar 2019 19:38:20 +0100 Subject: [PATCH 034/171] Correctly propagate NonNull to inner connection type --- graphene_django/fields.py | 39 +++++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/graphene_django/fields.py b/graphene_django/fields.py index 1ecce45..82c9c66 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -1,6 +1,7 @@ from functools import partial from django.db.models.query import QuerySet +from graphene import NonNull from promise import Promise @@ -45,17 +46,31 @@ class DjangoConnectionField(ConnectionField): from .types import DjangoObjectType _type = super(ConnectionField, self).type + non_null = False + if isinstance(_type, NonNull): + _type = _type.of_type + non_null = True assert issubclass( _type, DjangoObjectType ), "DjangoConnectionField only accepts DjangoObjectType types" assert _type._meta.connection, "The type {} doesn't have a connection".format( _type.__name__ ) - return _type._meta.connection + connection_type = _type._meta.connection + if non_null: + return NonNull(connection_type) + return connection_type + + @property + def connection_type(self): + type = self.type + if isinstance(type, NonNull): + return type.of_type + return type @property def node_type(self): - return self.type._meta.node + return self.connection_type._meta.node @property def model(self): @@ -103,15 +118,15 @@ class DjangoConnectionField(ConnectionField): @classmethod def connection_resolver( - cls, - resolver, - connection, - default_manager, - max_limit, - enforce_first_or_last, - root, - info, - **args + cls, + resolver, + connection, + default_manager, + max_limit, + enforce_first_or_last, + root, + info, + **args ): first = args.get("first") last = args.get("last") @@ -146,7 +161,7 @@ class DjangoConnectionField(ConnectionField): return partial( self.connection_resolver, parent_resolver, - self.type, + self.connection_type, self.get_manager(), self.max_limit, self.enforce_first_or_last, From fcc3de2a90bb81a7a02f9099029da3e4aa82b06e Mon Sep 17 00:00:00 2001 From: Gary Donovan Date: Sun, 31 Mar 2019 21:30:29 +1100 Subject: [PATCH 035/171] Allow graphql schema export to use a canonical representation (#439) When we use the `graphql_schema` management command, the output can vary from run to run depending on arbitrary factors (because there is no guarantee made about the order used to output JSON dictionary keys). This makes it difficult to compare two schema's at different points in time. We address this by including a new `canonical` flag to the command, which uses standard `json.dump` funcitonality to sort dictionary keys and force pretty-printed output. --- docs/introspection.rst | 4 +++- .../management/commands/graphql_schema.py | 4 ++-- graphene_django/settings.py | 2 +- graphene_django/tests/test_command.py | 15 ++++++++++++++- 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/docs/introspection.rst b/docs/introspection.rst index 0d30ee4..bd80f26 100644 --- a/docs/introspection.rst +++ b/docs/introspection.rst @@ -11,7 +11,7 @@ data to ``schema.json`` that is compatible with babel-relay-plugin. Usage ----- -Include ``graphene_django`` to ``INSTALLED_APPS`` in you project +Include ``graphene_django`` to ``INSTALLED_APPS`` in your project settings: .. code:: python @@ -29,6 +29,8 @@ It dumps your full introspection schema to ``schema.json`` inside your project root directory. Point ``babel-relay-plugin`` to this file and you're ready to use Relay with Graphene GraphQL implementation. +The schema file is sorted to create a reproducible canonical representation. + Advanced Usage -------------- diff --git a/graphene_django/management/commands/graphql_schema.py b/graphene_django/management/commands/graphql_schema.py index 4e526ec..d7f83da 100644 --- a/graphene_django/management/commands/graphql_schema.py +++ b/graphene_django/management/commands/graphql_schema.py @@ -39,7 +39,7 @@ class Command(CommandArguments): def save_file(self, out, schema_dict, indent): with open(out, "w") as outfile: - json.dump(schema_dict, outfile, indent=indent) + json.dump(schema_dict, outfile, indent=indent, sort_keys=True) def handle(self, *args, **options): options_schema = options.get("schema") @@ -65,7 +65,7 @@ class Command(CommandArguments): indent = options.get("indent") schema_dict = {"data": schema.introspect()} if out == '-': - self.stdout.write(json.dumps(schema_dict, indent=indent)) + self.stdout.write(json.dumps(schema_dict, indent=indent, sort_keys=True)) else: self.save_file(out, schema_dict, indent) diff --git a/graphene_django/settings.py b/graphene_django/settings.py index 7cd750a..e5fad78 100644 --- a/graphene_django/settings.py +++ b/graphene_django/settings.py @@ -28,7 +28,7 @@ except ImportError: DEFAULTS = { "SCHEMA": None, "SCHEMA_OUTPUT": "schema.json", - "SCHEMA_INDENT": None, + "SCHEMA_INDENT": 2, "MIDDLEWARE": (), # Set to True if the connection fields must have # either the first or last argument diff --git a/graphene_django/tests/test_command.py b/graphene_django/tests/test_command.py index ff6e6e1..fa78aec 100644 --- a/graphene_django/tests/test_command.py +++ b/graphene_django/tests/test_command.py @@ -1,5 +1,5 @@ from django.core import management -from mock import patch +from mock import patch, mock_open from six import StringIO @@ -8,3 +8,16 @@ def test_generate_file_on_call_graphql_schema(savefile_mock, settings): out = StringIO() management.call_command("graphql_schema", schema="", stdout=out) assert "Successfully dumped GraphQL schema to schema.json" in out.getvalue() + + +@patch('json.dump') +def test_files_are_canonical(dump_mock): + open_mock = mock_open() + with patch('graphene_django.management.commands.graphql_schema.open', open_mock): + management.call_command('graphql_schema', schema='') + + open_mock.assert_called_once() + + dump_mock.assert_called_once() + assert dump_mock.call_args[1]["sort_keys"], "json.mock() should be used to sort the output" + assert dump_mock.call_args[1]["indent"] > 0, "output should be pretty-printed by default" From 0a5020bee15b2ef04ddbd86ec903b6d87c59aa05 Mon Sep 17 00:00:00 2001 From: Jason Kraus Date: Sun, 31 Mar 2019 04:01:17 -0700 Subject: [PATCH 036/171] Get queryset (#528) * first attempt at adding get_queryset * add queryset_resolver to DjangoConnectionField and fix test failures * cleanup get_queryset API to match proposal as close as possible * pep8 fix: W293 * document get_queryset usage * add test for when get_queryset is defined on DjangoObjectType --- docs/authorization.rst | 23 +++++++++++++++ graphene_django/fields.py | 7 ++++- graphene_django/tests/test_query.py | 44 +++++++++++++++++++++++++++++ graphene_django/types.py | 7 ++++- 4 files changed, 79 insertions(+), 2 deletions(-) diff --git a/docs/authorization.rst b/docs/authorization.rst index 86ad66a..3b34326 100644 --- a/docs/authorization.rst +++ b/docs/authorization.rst @@ -96,6 +96,29 @@ schema is simple. result = schema.execute(query, context_value=request) + +Global Filtering +---------------- + +If you are using ``DjangoObjectType`` you can define a custom `get_queryset`. + +.. code:: python + + from graphene import relay + from graphene_django.types import DjangoObjectType + from .models import Post + + class PostNode(DjangoObjectType): + class Meta: + model = Post + + @classmethod + def get_queryset(cls, queryset, info): + if info.context.user.is_anonymous: + return queryset.filter(published=True) + return queryset + + Filtering ID-based Node Access ------------------------------ diff --git a/graphene_django/fields.py b/graphene_django/fields.py index 1ecce45..9b27f70 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -67,6 +67,10 @@ class DjangoConnectionField(ConnectionField): else: return self.model._default_manager + @classmethod + def resolve_queryset(cls, connection, queryset, info, args): + 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: @@ -135,7 +139,8 @@ class DjangoConnectionField(ConnectionField): args["last"] = min(last, max_limit) iterable = resolver(root, info, **args) - on_resolve = partial(cls.resolve_connection, connection, default_manager, args) + queryset = cls.resolve_queryset(connection, default_manager, info, args) + on_resolve = partial(cls.resolve_connection, connection, queryset, args) if Promise.is_thenable(iterable): return Promise.resolve(iterable).then(on_resolve) diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py index 1716034..58f46c7 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -1007,3 +1007,47 @@ def test_proxy_model_fails(): result = schema.execute(query) assert result.errors + + +def test_should_resolve_get_queryset_connectionfields(): + 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 ReporterType(DjangoObjectType): + class Meta: + model = Reporter + interfaces = (Node,) + + @classmethod + def get_queryset(cls, queryset, info): + return queryset.filter(reporter_type=2) + + class Query(graphene.ObjectType): + all_reporters = DjangoConnectionField(ReporterType) + + schema = graphene.Schema(query=Query) + query = """ + query ReporterPromiseConnectionQuery { + allReporters(first: 1) { + edges { + node { + id + } + } + } + } + """ + + expected = {"allReporters": {"edges": [{"node": {"id": "UmVwb3J0ZXJUeXBlOjI="}}]}} + + result = schema.execute(query) + assert not result.errors + assert result.data == expected diff --git a/graphene_django/types.py b/graphene_django/types.py index 4441a9a..4d55b53 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -133,9 +133,14 @@ class DjangoObjectType(ObjectType): model = root._meta.model._meta.concrete_model return model == cls._meta.model + @classmethod + def get_queryset(cls, queryset, info): + return queryset + @classmethod def get_node(cls, info, id): + queryset = cls.get_queryset(cls._meta.model.objects, info) try: - return cls._meta.model.objects.get(pk=id) + return queryset.get(pk=id) except cls._meta.model.DoesNotExist: return None From 923d8282c771244751d483553e173a15230a84b5 Mon Sep 17 00:00:00 2001 From: Patrick Arminio Date: Sun, 31 Mar 2019 12:01:43 +0100 Subject: [PATCH 037/171] Fix duplicated ErrorType declaration (#539) * Add failing test case * Fix duplicated ErrorType declaration --- graphene_django/forms/mutation.py | 2 +- graphene_django/rest_framework/mutation.py | 2 +- graphene_django/rest_framework/types.py | 5 --- graphene_django/tests/issues/__init__.py | 0 graphene_django/tests/issues/test_520.py | 44 ++++++++++++++++++++++ graphene_django/types.py | 6 +++ 6 files changed, 52 insertions(+), 7 deletions(-) create mode 100644 graphene_django/tests/issues/__init__.py create mode 100644 graphene_django/tests/issues/test_520.py diff --git a/graphene_django/forms/mutation.py b/graphene_django/forms/mutation.py index 63ea089..0851a75 100644 --- a/graphene_django/forms/mutation.py +++ b/graphene_django/forms/mutation.py @@ -14,7 +14,7 @@ from graphene.types.utils import yank_fields_from_attrs from graphene_django.registry import get_global_registry from .converter import convert_form_field -from .types import ErrorType +from ..types import ErrorType def fields_for_form(form, only_fields, exclude_fields): diff --git a/graphene_django/rest_framework/mutation.py b/graphene_django/rest_framework/mutation.py index 5e343aa..b8025f6 100644 --- a/graphene_django/rest_framework/mutation.py +++ b/graphene_django/rest_framework/mutation.py @@ -9,7 +9,7 @@ from graphene.relay.mutation import ClientIDMutation from graphene.types.objecttype import yank_fields_from_attrs from .serializer_converter import convert_serializer_field -from .types import ErrorType +from ..types import ErrorType class SerializerMutationOptions(MutationOptions): diff --git a/graphene_django/rest_framework/types.py b/graphene_django/rest_framework/types.py index 4c84c69..2472c32 100644 --- a/graphene_django/rest_framework/types.py +++ b/graphene_django/rest_framework/types.py @@ -2,11 +2,6 @@ import graphene from graphene.types.unmountedtype import UnmountedType -class ErrorType(graphene.ObjectType): - field = graphene.String(required=True) - messages = graphene.List(graphene.NonNull(graphene.String), required=True) - - class DictType(UnmountedType): key = graphene.String() value = graphene.String() diff --git a/graphene_django/tests/issues/__init__.py b/graphene_django/tests/issues/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/graphene_django/tests/issues/test_520.py b/graphene_django/tests/issues/test_520.py new file mode 100644 index 0000000..60c5b54 --- /dev/null +++ b/graphene_django/tests/issues/test_520.py @@ -0,0 +1,44 @@ +# https://github.com/graphql-python/graphene-django/issues/520 + +import datetime + +from django import forms + +import graphene + +from graphene import Field, ResolveInfo +from graphene.types.inputobjecttype import InputObjectType +from py.test import raises +from py.test import mark +from rest_framework import serializers + +from ...types import DjangoObjectType +from ...rest_framework.models import MyFakeModel +from ...rest_framework.mutation import SerializerMutation +from ...forms.mutation import DjangoFormMutation + + +class MyModelSerializer(serializers.ModelSerializer): + class Meta: + model = MyFakeModel + fields = "__all__" + + +class MyForm(forms.Form): + text = forms.CharField() + + +def test_can_use_form_and_serializer_mutations(): + class MyMutation(SerializerMutation): + class Meta: + serializer_class = MyModelSerializer + + class MyFormMutation(DjangoFormMutation): + class Meta: + form_class = MyForm + + class Mutation(graphene.ObjectType): + my_mutation = MyMutation.Field() + my_form_mutation = MyFormMutation.Field() + + graphene.Schema(mutation=Mutation) diff --git a/graphene_django/types.py b/graphene_django/types.py index 4d55b53..3f99cef 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -3,6 +3,7 @@ from collections import OrderedDict from django.db.models import Model from django.utils.functional import SimpleLazyObject +import graphene from graphene import Field from graphene.relay import Connection, Node from graphene.types.objecttype import ObjectType, ObjectTypeOptions @@ -144,3 +145,8 @@ class DjangoObjectType(ObjectType): return queryset.get(pk=id) except cls._meta.model.DoesNotExist: return None + + +class ErrorType(ObjectType): + field = graphene.String(required=True) + messages = graphene.List(graphene.NonNull(graphene.String), required=True) From 090ce6e1f1076372f5ab513edbc46276964b4e7a Mon Sep 17 00:00:00 2001 From: Edi Santoso Date: Sun, 31 Mar 2019 18:13:07 +0700 Subject: [PATCH 038/171] Fix invalid url django-filter docs (#589) --- docs/tutorial-relay.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorial-relay.rst b/docs/tutorial-relay.rst index 3627311..630898e 100644 --- a/docs/tutorial-relay.rst +++ b/docs/tutorial-relay.rst @@ -158,7 +158,7 @@ Create ``cookbook/ingredients/schema.py`` and type the following: The filtering functionality is provided by `django-filter `__. See the `usage -documentation `__ +documentation `__ for details on the format for ``filter_fields``. While optional, this tutorial makes use of this functionality so you will need to install ``django-filter`` for this tutorial to work: From 29b8ea8398217c0d1c62ad0fbd4c3e5c225e67a9 Mon Sep 17 00:00:00 2001 From: Ronny Vedrilla Date: Fri, 5 Apr 2019 14:27:53 +0200 Subject: [PATCH 039/171] Bugfix: FormMutation was always causing boolean fields to be required --- graphene_django/forms/converter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/forms/converter.py b/graphene_django/forms/converter.py index 87180b2..8916456 100644 --- a/graphene_django/forms/converter.py +++ b/graphene_django/forms/converter.py @@ -43,7 +43,7 @@ def convert_form_field_to_int(field): @convert_form_field.register(forms.BooleanField) def convert_form_field_to_boolean(field): - return Boolean(description=field.help_text, required=True) + return Boolean(description=field.help_text, required=field.required) @convert_form_field.register(forms.NullBooleanField) From 6acd917cf7076397009d0ba77901f4c1c8e190fe Mon Sep 17 00:00:00 2001 From: David Sanders Date: Mon, 15 Apr 2019 05:53:30 -0700 Subject: [PATCH 040/171] Drop old Django compatibility code --- graphene_django/utils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/graphene_django/utils.py b/graphene_django/utils.py index 560f604..532be06 100644 --- a/graphene_django/utils.py +++ b/graphene_django/utils.py @@ -25,8 +25,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' - related = getattr(attr, "rel", None) or getattr(attr, "related", None) + related = getattr(attr, "rel", None) if isinstance(related, models.ManyToOneRel): yield (name, related) elif isinstance(related, models.ManyToManyRel) and not related.symmetrical: From 2ae897187cec9119de3753bc646e806b69487188 Mon Sep 17 00:00:00 2001 From: Paul Hallett Date: Fri, 26 Apr 2019 13:14:28 +0100 Subject: [PATCH 041/171] Add Makefile and better CONTRIBUTING.md --- CONTRIBUTING.md | 32 ++++++++++++++++++++++++++++++++ Makefile | 5 +++++ README.md | 12 +----------- README.rst | 12 +----------- 4 files changed, 39 insertions(+), 22 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 Makefile diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9731c03 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,32 @@ +# Contributing + +Thanks for helping to make graphene-django great! + +We welcome all kinds of contributions: + +- Bug fixes +- Documentation improvements +- New features +- Refactoring & tidying + + +## Getting started + +If you have a specific contribution in mind, be sure to check the [issues](https://github.com/graphql-python/graphene-django/issues) and [projects](https://github.com/graphql-python/graphene-django/projects) in progress - someone could already be working on something similar and you can help out. + + +## Project setup + +After cloning this repo, ensure dependencies are installed by running: + +```sh +make dev-setup +``` + +## Running tests + +After developing, the full test suite can be evaluated by running: + +```sh +make tests +``` \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5c174ac --- /dev/null +++ b/Makefile @@ -0,0 +1,5 @@ +dev-setup: + pip install -e ".[test]" + +tests: + py.test graphene_django --cov=graphene_django -vv \ No newline at end of file diff --git a/README.md b/README.md index ef3f40c..9b8916a 100644 --- a/README.md +++ b/README.md @@ -96,17 +96,7 @@ To learn more check out the following [examples](examples/): ## Contributing -After cloning this repo, ensure dependencies are installed by running: - -```sh -pip install -e ".[test]" -``` - -After developing, the full test suite can be evaluated by running: - -```sh -py.test graphene_django --cov=graphene_django # Use -v -s for verbose mode -``` +See [CONTRIBUTING.md](contributing.md) ### Documentation diff --git a/README.rst b/README.rst index a96e60f..e884a40 100644 --- a/README.rst +++ b/README.rst @@ -105,17 +105,7 @@ To learn more check out the following `examples `__: Contributing ------------ -After cloning this repo, ensure dependencies are installed by running: - -.. code:: sh - - pip install -e ".[test]" - -After developing, the full test suite can be evaluated by running: - -.. code:: sh - - py.test graphene_django --cov=graphene_django # Use -v -s for verbose mode +See `CONTRIBUTING.md `__. Documentation ~~~~~~~~~~~~~ From bba8377a8209235fadb07848e11429c2d18aeff4 Mon Sep 17 00:00:00 2001 From: Paul Hallett Date: Fri, 26 Apr 2019 14:08:44 +0100 Subject: [PATCH 042/171] Move documentation to CONTRIBUTING.md --- CONTRIBUTING.md | 17 +++++++++++++++++ README.md | 20 +------------------- README.rst | 19 ------------------- 3 files changed, 18 insertions(+), 38 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9731c03..4a650d6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,4 +29,21 @@ After developing, the full test suite can be evaluated by running: ```sh make tests +``` + +## Documentation + +The [documentation](http://docs.graphene-python.org/projects/django/en/latest/) is generated using the excellent [Sphinx](http://www.sphinx-doc.org/) and a custom theme. + +The documentation dependencies are installed by running: + +```sh +cd docs +pip install -r requirements.txt +``` + +Then to produce a HTML version of the documentation: + +```sh +make html ``` \ No newline at end of file diff --git a/README.md b/README.md index 9b8916a..fffa1d3 100644 --- a/README.md +++ b/README.md @@ -96,22 +96,4 @@ To learn more check out the following [examples](examples/): ## Contributing -See [CONTRIBUTING.md](contributing.md) - - -### Documentation - -The [documentation](http://docs.graphene-python.org/projects/django/en/latest/) is generated using the excellent [Sphinx](http://www.sphinx-doc.org/) and a custom theme. - -The documentation dependencies are installed by running: - -```sh -cd docs -pip install -r requirements.txt -``` - -Then to produce a HTML version of the documentation: - -```sh -make html -``` +See [CONTRIBUTING.md](contributing.md) \ No newline at end of file diff --git a/README.rst b/README.rst index e884a40..2e0593d 100644 --- a/README.rst +++ b/README.rst @@ -107,25 +107,6 @@ Contributing See `CONTRIBUTING.md `__. -Documentation -~~~~~~~~~~~~~ - -The `documentation `__ is generated using the excellent -`Sphinx `__ and a custom theme. - -The documentation dependencies are installed by running: - -.. code:: sh - - cd docs - pip install -r requirements.txt - -Then to produce a HTML version of the documentation: - -.. code:: sh - - make html - .. |Graphene Logo| image:: http://graphene-python.org/favicon.png .. |Build Status| image:: https://travis-ci.org/graphql-python/graphene-django.svg?branch=master :target: https://travis-ci.org/graphql-python/graphene-django From d720b47c8da0eb4ca1e1ca8ba9cda72b56750295 Mon Sep 17 00:00:00 2001 From: Eran Kampf <205185+ekampf@users.noreply.github.com> Date: Tue, 30 Apr 2019 09:55:28 -0700 Subject: [PATCH 043/171] Test docs build --- README.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/README.rst b/README.rst index 2e0593d..8afb5ce 100644 --- a/README.rst +++ b/README.rst @@ -114,3 +114,4 @@ See `CONTRIBUTING.md `__. :target: https://badge.fury.io/py/graphene-django .. |Coverage Status| image:: https://coveralls.io/repos/graphql-python/graphene-django/badge.svg?branch=master&service=github :target: https://coveralls.io/github/graphql-python/graphene-django?branch=master + From 05c89c19fb48c26fd96a4b3d590aeb98ad11dfc0 Mon Sep 17 00:00:00 2001 From: Eran Kampf <205185+ekampf@users.noreply.github.com> Date: Tue, 30 Apr 2019 09:57:17 -0700 Subject: [PATCH 044/171] Test docs integration webhook --- README.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/README.rst b/README.rst index 8afb5ce..2e0593d 100644 --- a/README.rst +++ b/README.rst @@ -114,4 +114,3 @@ See `CONTRIBUTING.md `__. :target: https://badge.fury.io/py/graphene-django .. |Coverage Status| image:: https://coveralls.io/repos/graphql-python/graphene-django/badge.svg?branch=master&service=github :target: https://coveralls.io/github/graphql-python/graphene-django?branch=master - From b49d315a39c3a771a98ab35b6c35dbe56cb8ab3a Mon Sep 17 00:00:00 2001 From: Alexandre Kirszenberg Date: Wed, 1 May 2019 15:49:54 +0200 Subject: [PATCH 045/171] 4 spaces --- graphene_django/fields.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/graphene_django/fields.py b/graphene_django/fields.py index 82c9c66..35bd8a4 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -118,15 +118,15 @@ class DjangoConnectionField(ConnectionField): @classmethod def connection_resolver( - cls, - resolver, - connection, - default_manager, - max_limit, - enforce_first_or_last, - root, - info, - **args + cls, + resolver, + connection, + default_manager, + max_limit, + enforce_first_or_last, + root, + info, + **args ): first = args.get("first") last = args.get("last") From e6ad5887caebf818b0c61d09924c51a9f0f9b406 Mon Sep 17 00:00:00 2001 From: Paul Hallett Date: Tue, 30 Apr 2019 10:02:23 +0100 Subject: [PATCH 046/171] Introduce Black formatting, additional tests --- .travis.yml | 14 +++++++-- CONTRIBUTING.md | 13 +++++++++ Makefile | 10 +++++-- README.md | 2 +- graphene_django/compat.py | 8 +++-- graphene_django/debug/sql/types.py | 29 +++++-------------- graphene_django/debug/types.py | 5 +--- graphene_django/filter/filterset.py | 4 +-- graphene_django/forms/tests/test_mutation.py | 9 +++--- .../management/commands/graphql_schema.py | 2 +- graphene_django/tests/base_test.py | 21 ++++++++------ graphene_django/tests/test_command.py | 14 +++++---- graphene_django/tests/test_converter.py | 6 ++-- graphene_django/views.py | 3 +- setup.py | 8 ++++- 15 files changed, 86 insertions(+), 62 deletions(-) diff --git a/.travis.yml b/.travis.yml index a8375ee..07ee59f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,19 +1,21 @@ language: python -sudo: false +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 # Required for Django postgres fields testing + 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 + pip install flake8==3.7.7 fi script: - | @@ -45,10 +47,16 @@ matrix: 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 deploy: provider: pypi user: syrusakbary diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4a650d6..f9428e9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,6 +31,19 @@ After developing, the full test suite can be evaluated by running: make tests ``` +## Opening Pull Requests + +Please fork the project and open a pull request against the master branch. + +This will trigger a series of test and lint checks. + +We advise that you format and run lint locally before doing this to save time: + +```sh +make format +make lint +``` + ## Documentation The [documentation](http://docs.graphene-python.org/projects/django/en/latest/) is generated using the excellent [Sphinx](http://www.sphinx-doc.org/) and a custom theme. diff --git a/Makefile b/Makefile index 5c174ac..061ad4e 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,11 @@ dev-setup: - pip install -e ".[test]" + pip install -e ".[dev]" tests: - py.test graphene_django --cov=graphene_django -vv \ No newline at end of file + py.test graphene_django --cov=graphene_django -vv + +format: + black graphene_django + +lint: + flake8 graphene_django diff --git a/README.md b/README.md index fffa1d3..d2fe4b6 100644 --- a/README.md +++ b/README.md @@ -96,4 +96,4 @@ To learn more check out the following [examples](examples/): ## Contributing -See [CONTRIBUTING.md](contributing.md) \ No newline at end of file +See [CONTRIBUTING.md](CONTRIBUTING.md) \ No newline at end of file diff --git a/graphene_django/compat.py b/graphene_django/compat.py index 4a51de8..59fab30 100644 --- a/graphene_django/compat.py +++ b/graphene_django/compat.py @@ -5,7 +5,11 @@ class MissingType(object): try: # Postgres fields are only available in Django with psycopg2 installed # and we cannot have psycopg2 on PyPy - from django.contrib.postgres.fields import (ArrayField, HStoreField, - JSONField, RangeField) + from django.contrib.postgres.fields import ( + ArrayField, + HStoreField, + JSONField, + RangeField, + ) except ImportError: ArrayField, HStoreField, JSONField, RangeField = (MissingType,) * 4 diff --git a/graphene_django/debug/sql/types.py b/graphene_django/debug/sql/types.py index 850ced4..eeef482 100644 --- a/graphene_django/debug/sql/types.py +++ b/graphene_django/debug/sql/types.py @@ -3,9 +3,7 @@ from graphene import Boolean, Float, ObjectType, String class DjangoDebugSQL(ObjectType): class Meta: - description = ( - "Represents a single database query made to a Django managed DB." - ) + description = "Represents a single database query made to a Django managed DB." vendor = String( required=True, @@ -14,37 +12,26 @@ class DjangoDebugSQL(ObjectType): ), ) alias = String( - required=True, - description="The Django database alias (e.g. 'default').", + required=True, description="The Django database alias (e.g. 'default')." ) sql = String(description="The actual SQL sent to this database.") duration = Float( - required=True, - description="Duration of this database query in seconds.", + required=True, description="Duration of this database query in seconds." ) raw_sql = String( - required=True, - description="The raw SQL of this query, without params.", + required=True, description="The raw SQL of this query, without params." ) params = String( - required=True, - description="JSON encoded database query parameters.", - ) - start_time = Float( - required=True, - description="Start time of this database query.", - ) - stop_time = Float( - required=True, - description="Stop time of this database query.", + required=True, description="JSON encoded database query parameters." ) + start_time = Float(required=True, description="Start time of this database query.") + stop_time = Float(required=True, description="Stop time of this database query.") is_slow = Boolean( required=True, description="Whether this database query took more than 10 seconds.", ) is_select = Boolean( - required=True, - description="Whether this database query was a SELECT.", + required=True, description="Whether this database query was a SELECT." ) # Postgres diff --git a/graphene_django/debug/types.py b/graphene_django/debug/types.py index cda5725..1cd816d 100644 --- a/graphene_django/debug/types.py +++ b/graphene_django/debug/types.py @@ -7,7 +7,4 @@ class DjangoDebug(ObjectType): class Meta: description = "Debugging information for the current query." - sql = List( - DjangoDebugSQL, - description="Executed SQL queries for this API query.", - ) + sql = List(DjangoDebugSQL, description="Executed SQL queries for this API query.") diff --git a/graphene_django/filter/filterset.py b/graphene_django/filter/filterset.py index 4059083..7676ea8 100644 --- a/graphene_django/filter/filterset.py +++ b/graphene_django/filter/filterset.py @@ -45,8 +45,7 @@ class GrapheneFilterSetMixin(BaseFilterSet): FILTER_DEFAULTS = dict( itertools.chain( - FILTER_FOR_DBFIELD_DEFAULTS.items(), - GRAPHENE_FILTER_SET_OVERRIDES.items() + FILTER_FOR_DBFIELD_DEFAULTS.items(), GRAPHENE_FILTER_SET_OVERRIDES.items() ) ) @@ -59,7 +58,6 @@ if VERSION[0] < 2: from django.utils.text import capfirst class GrapheneFilterSetMixinPython2(GrapheneFilterSetMixin): - @classmethod def filter_for_reverse_field(cls, f, name): """Handles retrieving filters for reverse relationships diff --git a/graphene_django/forms/tests/test_mutation.py b/graphene_django/forms/tests/test_mutation.py index df0ffd5..543e89e 100644 --- a/graphene_django/forms/tests/test_mutation.py +++ b/graphene_django/forms/tests/test_mutation.py @@ -13,7 +13,7 @@ class MyForm(forms.Form): class PetForm(forms.ModelForm): class Meta: model = Pet - fields = '__all__' + fields = "__all__" def test_needs_form_class(): @@ -66,7 +66,7 @@ class ModelFormMutationTests(TestCase): class PetMutation(DjangoModelFormMutation): class Meta: form_class = PetForm - exclude_fields = ['id'] + exclude_fields = ["id"] self.assertEqual(PetMutation._meta.model, Pet) self.assertEqual(PetMutation._meta.return_field_name, "pet") @@ -102,7 +102,9 @@ class ModelFormMutationTests(TestCase): pet = Pet.objects.create(name="Axel", age=10) - result = PetMutation.mutate_and_get_payload(None, None, id=pet.pk, name="Mia", age=10) + result = PetMutation.mutate_and_get_payload( + None, None, id=pet.pk, name="Mia", age=10 + ) self.assertEqual(Pet.objects.count(), 1) pet.refresh_from_db() @@ -132,7 +134,6 @@ class ModelFormMutationTests(TestCase): # A pet was not created self.assertEqual(Pet.objects.count(), 0) - fields_w_error = [e.field for e in result.errors] self.assertEqual(len(result.errors), 2) self.assertIn("name", fields_w_error) diff --git a/graphene_django/management/commands/graphql_schema.py b/graphene_django/management/commands/graphql_schema.py index d7f83da..9f8689e 100644 --- a/graphene_django/management/commands/graphql_schema.py +++ b/graphene_django/management/commands/graphql_schema.py @@ -64,7 +64,7 @@ class Command(CommandArguments): indent = options.get("indent") schema_dict = {"data": schema.introspect()} - if out == '-': + if out == "-": self.stdout.write(json.dumps(schema_dict, indent=indent, sort_keys=True)) else: self.save_file(out, schema_dict, indent) diff --git a/graphene_django/tests/base_test.py b/graphene_django/tests/base_test.py index 471dffe..84e1dc5 100644 --- a/graphene_django/tests/base_test.py +++ b/graphene_django/tests/base_test.py @@ -11,7 +11,7 @@ class GraphQLTestCase(TestCase): """ # URL to graphql endpoint - GRAPHQL_URL = '/graphql/' + GRAPHQL_URL = "/graphql/" # Here you need to set your graphql schema for the tests GRAPHQL_SCHEMA = None @@ -20,7 +20,9 @@ class GraphQLTestCase(TestCase): super(GraphQLTestCase, cls).setUpClass() if not cls.GRAPHQL_SCHEMA: - raise AttributeError('Variable GRAPHQL_SCHEMA not defined in GraphQLTestCase.') + raise AttributeError( + "Variable GRAPHQL_SCHEMA not defined in GraphQLTestCase." + ) cls._client = Client(cls.GRAPHQL_SCHEMA) @@ -37,14 +39,15 @@ class GraphQLTestCase(TestCase): Returns: Response object from client """ - body = {'query': query} + body = {"query": query} if op_name: - body['operation_name'] = op_name + body["operation_name"] = op_name if input_data: - body['variables'] = {'input': input_data} + body["variables"] = {"input": input_data} - resp = self._client.post(self.GRAPHQL_URL, json.dumps(body), - content_type='application/json') + resp = self._client.post( + self.GRAPHQL_URL, json.dumps(body), content_type="application/json" + ) return resp def assertResponseNoErrors(self, resp): @@ -55,7 +58,7 @@ class GraphQLTestCase(TestCase): """ content = json.loads(resp.content) self.assertEqual(resp.status_code, 200) - self.assertNotIn('errors', list(content.keys())) + self.assertNotIn("errors", list(content.keys())) def assertResponseHasErrors(self, resp): """ @@ -63,4 +66,4 @@ class GraphQLTestCase(TestCase): :resp HttpResponse: Response """ content = json.loads(resp.content) - self.assertIn('errors', list(content.keys())) + self.assertIn("errors", list(content.keys())) diff --git a/graphene_django/tests/test_command.py b/graphene_django/tests/test_command.py index fa78aec..dbabafa 100644 --- a/graphene_django/tests/test_command.py +++ b/graphene_django/tests/test_command.py @@ -10,14 +10,18 @@ def test_generate_file_on_call_graphql_schema(savefile_mock, settings): assert "Successfully dumped GraphQL schema to schema.json" in out.getvalue() -@patch('json.dump') +@patch("json.dump") def test_files_are_canonical(dump_mock): open_mock = mock_open() - with patch('graphene_django.management.commands.graphql_schema.open', open_mock): - management.call_command('graphql_schema', schema='') + with patch("graphene_django.management.commands.graphql_schema.open", open_mock): + management.call_command("graphql_schema", schema="") open_mock.assert_called_once() dump_mock.assert_called_once() - assert dump_mock.call_args[1]["sort_keys"], "json.mock() should be used to sort the output" - assert dump_mock.call_args[1]["indent"] > 0, "output should be pretty-printed by default" + assert dump_mock.call_args[1][ + "sort_keys" + ], "json.mock() should be used to sort the output" + assert ( + dump_mock.call_args[1]["indent"] > 0 + ), "output should be pretty-printed by default" diff --git a/graphene_django/tests/test_converter.py b/graphene_django/tests/test_converter.py index eac5851..bb176b3 100644 --- a/graphene_django/tests/test_converter.py +++ b/graphene_django/tests/test_converter.py @@ -241,8 +241,7 @@ def test_should_manytoone_convert_connectionorlist(): class Meta: model = Article - graphene_field = convert_django_field(Reporter.articles.rel, - A._meta.registry) + graphene_field = convert_django_field(Reporter.articles.rel, A._meta.registry) assert isinstance(graphene_field, graphene.Dynamic) dynamic_field = graphene_field.get_type() assert isinstance(dynamic_field, graphene.Field) @@ -255,8 +254,7 @@ def test_should_onetoone_reverse_convert_model(): class Meta: model = FilmDetails - graphene_field = convert_django_field(Film.details.related, - A._meta.registry) + graphene_field = convert_django_field(Film.details.related, A._meta.registry) assert isinstance(graphene_field, graphene.Dynamic) dynamic_field = graphene_field.get_type() assert isinstance(dynamic_field, graphene.Field) diff --git a/graphene_django/views.py b/graphene_django/views.py index 9a530de..0b840f9 100644 --- a/graphene_django/views.py +++ b/graphene_django/views.py @@ -126,8 +126,7 @@ class GraphQLView(View): if show_graphiql: return self.render_graphiql( - request, - graphiql_version=self.graphiql_version, + request, graphiql_version=self.graphiql_version ) if self.batch: diff --git a/setup.py b/setup.py index 3431cd5..e622a71 100644 --- a/setup.py +++ b/setup.py @@ -24,6 +24,12 @@ tests_require = [ "pytest-django>=3.3.2", ] + rest_framework_require + +dev_requires = [ + "black==19.3b0", + "flake8==3.7.7", +] + tests_require + setup( name="graphene-django", version=version, @@ -58,7 +64,7 @@ setup( setup_requires=["pytest-runner"], tests_require=tests_require, rest_framework_require=rest_framework_require, - extras_require={"test": tests_require, "rest_framework": rest_framework_require}, + extras_require={"test": tests_require, "rest_framework": rest_framework_require, "dev": dev_requires}, include_package_data=True, zip_safe=False, platforms="any", From 6f03597a5e5d09a894ae93fc9cb97b963fcbe8a9 Mon Sep 17 00:00:00 2001 From: Paul Hallett Date: Mon, 6 May 2019 13:28:02 +0100 Subject: [PATCH 047/171] Create CODE_OF_CONDUCT.md --- CODE_OF_CONDUCT.md | 76 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 CODE_OF_CONDUCT.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..5560ba2 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,76 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity and expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at me@syrusakbary.com. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq From 31468f56874532c4628905063a891f8260198990 Mon Sep 17 00:00:00 2001 From: Paul Hallett Date: Fri, 26 Apr 2019 16:48:37 +0100 Subject: [PATCH 048/171] Rebuild documentation --- README.md | 8 +- README.rst | 12 +- docs/authorization.rst | 6 +- docs/conf.py | 89 +++++----- docs/debug.rst | 1 + docs/filtering.rst | 48 ++++- docs/form-mutations.rst | 74 -------- docs/index.rst | 23 ++- docs/installation.rst | 69 ++++++++ docs/introspection.rst | 4 +- docs/mutations.rst | 229 ++++++++++++++++++++++++ docs/queries.rst | 270 +++++++++++++++++++++++++++++ docs/rest-framework.rst | 64 ------- docs/schema.rst | 50 ++++++ docs/tutorial-plain.rst | 24 +-- docs/tutorial-relay.rst | 6 +- examples/cookbook/cookbook/urls.py | 2 +- 17 files changed, 766 insertions(+), 213 deletions(-) delete mode 100644 docs/form-mutations.rst create mode 100644 docs/installation.rst create mode 100644 docs/mutations.rst create mode 100644 docs/queries.rst delete mode 100644 docs/rest-framework.rst create mode 100644 docs/schema.rst diff --git a/README.md b/README.md index d2fe4b6..159a592 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,11 @@ Please read [UPGRADE-v2.0.md](https://github.com/graphql-python/graphene/blob/ma A [Django](https://www.djangoproject.com/) integration for [Graphene](http://graphene-python.org/). -## Installation +## Documentation + +[Visit the documentation to get started!](https://docs.graphene-python.org/projects/django/en/latest/) + +## Quickstart For installing graphene, just run this command in your shell @@ -39,7 +43,7 @@ from graphene_django.views import GraphQLView urlpatterns = [ # ... - url(r'^graphql', GraphQLView.as_view(graphiql=True)), + url(r'^graphql$', GraphQLView.as_view(graphiql=True)), ] ``` diff --git a/README.rst b/README.rst index 2e0593d..44feaee 100644 --- a/README.rst +++ b/README.rst @@ -10,8 +10,14 @@ to learn how to upgrade to Graphene ``2.0``. A `Django `__ integration for `Graphene `__. -Installation ------------- + +Documentation +------------- + +`Visit the documentation to get started! `__ + +Quickstart +---------- For installing graphene, just run this command in your shell @@ -46,7 +52,7 @@ serve the queries. urlpatterns = [ # ... - url(r'^graphql', GraphQLView.as_view(graphiql=True)), + url(r'^graphql$', GraphQLView.as_view(graphiql=True)), ] Examples diff --git a/docs/authorization.rst b/docs/authorization.rst index 3b34326..3d0bb8a 100644 --- a/docs/authorization.rst +++ b/docs/authorization.rst @@ -155,7 +155,7 @@ To restrict users from accessing the GraphQL API page the standard Django LoginR .. code:: python #views.py - + from django.contrib.auth.mixins import LoginRequiredMixin from graphene_django.views import GraphQLView @@ -171,9 +171,9 @@ For Django 1.9 and below: urlpatterns = [ # some other urls - url(r'^graphql', PrivateGraphQLView.as_view(graphiql=True, schema=schema)), + url(r'^graphql$', PrivateGraphQLView.as_view(graphiql=True, schema=schema)), ] - + For Django 2.0 and above: .. code:: python diff --git a/docs/conf.py b/docs/conf.py index 2ea2d55..a485d5b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,6 +1,6 @@ import os -on_rtd = os.environ.get('READTHEDOCS', None) == 'True' +on_rtd = os.environ.get("READTHEDOCS", None) == "True" # -*- coding: utf-8 -*- # @@ -34,46 +34,44 @@ on_rtd = os.environ.get('READTHEDOCS', None) == 'True' # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.intersphinx', - 'sphinx.ext.todo', - 'sphinx.ext.coverage', - 'sphinx.ext.viewcode', + "sphinx.ext.autodoc", + "sphinx.ext.intersphinx", + "sphinx.ext.todo", + "sphinx.ext.coverage", + "sphinx.ext.viewcode", ] if not on_rtd: - extensions += [ - 'sphinx.ext.githubpages', - ] + extensions += ["sphinx.ext.githubpages"] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] -source_suffix = '.rst' +source_suffix = ".rst" # The encoding of source files. # # source_encoding = 'utf-8-sig' # The master toctree document. -master_doc = 'index' +master_doc = "index" # General information about the project. -project = u'Graphene Django' -copyright = u'Graphene 2017' -author = u'Syrus Akbary' +project = u"Graphene Django" +copyright = u"Graphene 2017" +author = u"Syrus Akbary" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = u'1.0' +version = u"1.0" # The full version, including alpha/beta/rc tags. -release = u'1.0.dev' +release = u"1.0.dev" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -94,7 +92,7 @@ language = None # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This patterns also effect to html_static_path and html_extra_path -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # The reST default role (used for this markup: `text`) to use for all # documents. @@ -116,7 +114,7 @@ exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] # show_authors = False # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = "sphinx" # A list of ignored prefixes for module index sorting. # modindex_common_prefix = [] @@ -175,7 +173,7 @@ html_theme_path = [sphinx_graphene_theme.get_html_theme_path()] # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = ["_static"] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied @@ -255,34 +253,30 @@ html_static_path = ['_static'] # html_search_scorer = 'scorer.js' # Output file base name for HTML help builder. -htmlhelp_basename = 'Graphenedoc' +htmlhelp_basename = "Graphenedoc" # -- Options for LaTeX output --------------------------------------------- latex_elements = { - # The paper size ('letterpaper' or 'a4paper'). - # - # 'papersize': 'letterpaper', - - # The font size ('10pt', '11pt' or '12pt'). - # - # 'pointsize': '10pt', - - # Additional stuff for the LaTeX preamble. - # - # 'preamble': '', - - # Latex figure (float) alignment - # - # 'figure_align': 'htbp', + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, 'Graphene.tex', u'Graphene Documentation', - u'Syrus Akbary', 'manual'), + (master_doc, "Graphene.tex", u"Graphene Documentation", u"Syrus Akbary", "manual") ] # The name of an image file (relative to this directory) to place at the top of @@ -323,8 +317,7 @@ latex_documents = [ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - (master_doc, 'graphene_django', u'Graphene Django Documentation', - [author], 1) + (master_doc, "graphene_django", u"Graphene Django Documentation", [author], 1) ] # If true, show URL addresses after external links. @@ -338,9 +331,15 @@ man_pages = [ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (master_doc, 'Graphene-Django', u'Graphene Django Documentation', - author, 'Graphene Django', 'One line description of project.', - 'Miscellaneous'), + ( + master_doc, + "Graphene-Django", + u"Graphene Django Documentation", + author, + "Graphene Django", + "One line description of project.", + "Miscellaneous", + ) ] # Documents to append as an appendix to all manuals. @@ -414,7 +413,7 @@ epub_copyright = copyright # epub_post_files = [] # A list of files that should not be packed into the epub file. -epub_exclude_files = ['search.html'] +epub_exclude_files = ["search.html"] # The depth of the table of contents in toc.ncx. # @@ -446,4 +445,4 @@ epub_exclude_files = ['search.html'] # Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {'https://docs.python.org/': None} +intersphinx_mapping = {"https://docs.python.org/": None} diff --git a/docs/debug.rst b/docs/debug.rst index 8ef2e86..8e67c23 100644 --- a/docs/debug.rst +++ b/docs/debug.rst @@ -34,6 +34,7 @@ And in your ``settings.py``: .. code:: python GRAPHENE = { + ... 'MIDDLEWARE': [ 'graphene_django.debug.DjangoDebugMiddleware', ] diff --git a/docs/filtering.rst b/docs/filtering.rst index feafd40..d02366f 100644 --- a/docs/filtering.rst +++ b/docs/filtering.rst @@ -136,7 +136,7 @@ pre-filter animals owned by the authenticated user (set in ``context.user``). class AnimalFilter(django_filters.FilterSet): # Do case-insensitive lookups on 'name' - name = django_filters.CharFilter(lookup_type='iexact') + name = django_filters.CharFilter(lookup_type=['iexact']) class Meta: model = Animal @@ -146,3 +146,49 @@ pre-filter animals owned by the authenticated user (set in ``context.user``). def qs(self): # The query context can be found in self.request. return super(AnimalFilter, self).qs.filter(owner=self.request.user) + + +Ordering +-------- + +You can use ``OrderFilter`` to define how you want your returned results to be ordered. + +Extend the tuple of fields if you want to order by more than one field. + +.. code:: python + + from django_filters import FilterSet, OrderingFilter + + class UserFilter(FilterSet): + class Meta: + model = UserModel + + order_by = OrderingFilter( + fields=( + ('created_at', 'created_at'), + ) + ) + + class Group(DjangoObjectType): + users = DjangoFilterConnectionField(Ticket, filterset_class=UserFilter) + + class Meta: + name = 'Group' + model = GroupModel + interfaces = (relay.Node,) + + def resolve_users(self, info, **kwargs): + return UserFilter(kwargs).qs + + +with this set up, you can now order the users under group: + +.. code:: + + query { + group(id: "xxx") { + users(orderBy: "-created_at") { + xxx + } + } + } \ No newline at end of file diff --git a/docs/form-mutations.rst b/docs/form-mutations.rst deleted file mode 100644 index 85b89e8..0000000 --- a/docs/form-mutations.rst +++ /dev/null @@ -1,74 +0,0 @@ -Integration with Django forms -============================= - -Graphene-Django comes with mutation classes that will convert the fields on Django forms into inputs on a mutation. -*Note: the API is experimental and will likely change in the future.* - -DjangoFormMutation ------------------- - -.. code:: python - - from graphene_django.forms.mutation import DjangoFormMutation - - class MyForm(forms.Form): - name = forms.CharField() - - class MyMutation(DjangoFormMutation): - class Meta: - form_class = MyForm - -``MyMutation`` will automatically receive an ``input`` argument. This argument should be a ``dict`` where the key is ``name`` and the value is a string. - -DjangoModelFormMutation ------------------------ - -``DjangoModelFormMutation`` will pull the fields from a ``ModelForm``. - -.. code:: python - - from graphene_django.forms.mutation import DjangoModelFormMutation - - class Pet(models.Model): - name = models.CharField() - - class PetForm(forms.ModelForm): - class Meta: - model = Pet - fields = ('name',) - - # This will get returned when the mutation completes successfully - class PetType(DjangoObjectType): - class Meta: - model = Pet - - class PetMutation(DjangoModelFormMutation): - pet = Field(PetType) - - class Meta: - form_class = PetForm - -``PetMutation`` will grab the fields from ``PetForm`` and turn them into inputs. If the form is valid then the mutation -will lookup the ``DjangoObjectType`` for the ``Pet`` model and return that under the key ``pet``. Otherwise it will -return a list of errors. - -You can change the input name (default is ``input``) and the return field name (default is the model name lowercase). - -.. code:: python - - class PetMutation(DjangoModelFormMutation): - class Meta: - form_class = PetForm - input_field_name = 'data' - return_field_name = 'my_pet' - -Form validation ---------------- - -Form mutations will call ``is_valid()`` on your forms. - -If the form is valid then the class method ``perform_mutate(form, info)`` is called on the mutation. Override this method -to change how the form is saved or to return a different Graphene object type. - -If the form is *not* valid then a list of errors will be returned. These errors have two fields: ``field``, a string -containing the name of the invalid form field, and ``messages``, a list of strings with the validation messages. diff --git a/docs/index.rst b/docs/index.rst index 9469c29..c7820cf 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,17 +1,34 @@ Graphene-Django =============== -Contents: +Welcome to the Graphene-Django docs. + +Graphene-Django is built on top of `Graphene `__. +Graphene-Django provides some additional abstractions that make it easy to add GraphQL functionality to your Django project. + +First time? We recommend you start with the installation guide to get set up and the basic tutorial. +It is worth reading the `core graphene docs `__ to familiarize yourself with the basic utilities. + +Core tenants +------------ + +If you want to expose your data through GraphQL - read the ``Installation``, ``Schema`` and ``Queries`` section. + + +For more advanced use, check out the Relay tutorial. .. toctree:: - :maxdepth: 0 + :maxdepth: 1 + installation tutorial-plain tutorial-relay + schema + queries + mutations filtering authorization debug rest-framework - form-mutations introspection testing diff --git a/docs/installation.rst b/docs/installation.rst new file mode 100644 index 0000000..8f3e550 --- /dev/null +++ b/docs/installation.rst @@ -0,0 +1,69 @@ +Installation +============ + +Graphene-Django takes a few seconds to install and set up. + +Requirements +------------ + +Graphene-Django currently supports the following versions of Django: + +* Django 2.X + +Installation +------------ + +.. code:: bash + + pip install graphene-django + +**We strongly recommend pinning against a specific version of Graphene-Django because new versions could introduce breaking changes to your project.** + +Add ``graphene_django`` to the ``INSTALLED_APPS`` in the ``settings.py`` file of your Django project: + +.. code:: python + + INSTALLED_APPS = [ + ... + 'django.contrib.staticfiles', # Required for GraphiQL + 'graphene_django' + ] + + +We need to add a graphql URL to the ``urls.py`` of your Django project: + +.. code:: python + + from django.conf.urls import url + from graphene_django.views import GraphQLView + + urlpatterns = [ + # ... + url(r'^graphql$', GraphQLView.as_view(graphiql=True)), + ] + +(Change ``graphiql=True`` to ``graphiql=False`` if you do not want to use the GraphiQL API browser.) + +Finally, define the schema location for Graphene in the ``settings.py`` file of your Django project: + +.. code:: python + + GRAPHENE = { + 'SCHEMA': 'django_root.schema.schema' + } + +Where ``path.schema.schema`` is the location of the ``Schema`` object in your Django project. + +The most basic ``schema.py`` looks like this: + +.. code:: python + + import graphene + + class Query(graphene.ObjectType): + pass + + 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 diff --git a/docs/introspection.rst b/docs/introspection.rst index bd80f26..92e3612 100644 --- a/docs/introspection.rst +++ b/docs/introspection.rst @@ -5,8 +5,8 @@ Relay uses `Babel Relay Plugin `__ that requires you to provide your GraphQL schema data. -Graphene comes with a management command for Django to dump your schema -data to ``schema.json`` that is compatible with babel-relay-plugin. +Graphene comes with a Django management command to dump your schema +data to ``schema.json`` which is compatible with babel-relay-plugin. Usage ----- diff --git a/docs/mutations.rst b/docs/mutations.rst new file mode 100644 index 0000000..f6c6f14 --- /dev/null +++ b/docs/mutations.rst @@ -0,0 +1,229 @@ +Mutations +========= + +Introduction +------------ + +Graphene-Django makes it easy to perform mutations. + +With Graphene-Django we can take advantage of pre-existing Django features to +quickly build CRUD functionality, while still using the core `graphene mutation `__ +features to add custom mutations to a Django project. + +Simple example +-------------- + +.. code:: python + + import graphene + + from graphene_django import DjangoObjectType + + from .models import Question + + + class QuestionType(DjangoObjectType): + class Meta: + model = Question + + + class QuestionMutation(graphene.Mutation): + class Arguments: + # The input arguments for this mutation + text = graphene.String(required=True) + id = graphene.ID() + + # The class attributes define the response of the mutation + question = graphene.Field(QuestionType) + + def mutate(self, info, text, id): + question = Question.objects.get(pk=id) + question.text = text + question.save() + # Notice we return an instance of this mutation + return QuestionMutation(question=question) + + + class Mutation: + update_question = QuestionMutation.Field() + + +Django Forms +------------ + +Graphene-Django comes with mutation classes that will convert the fields on Django forms into inputs on a mutation. + +DjangoFormMutation +~~~~~~~~~~~~~~~~~~ + +.. code:: python + + from graphene_django.forms.mutation import DjangoFormMutation + + class MyForm(forms.Form): + name = forms.CharField() + + class MyMutation(DjangoFormMutation): + class Meta: + form_class = MyForm + +``MyMutation`` will automatically receive an ``input`` argument. This argument should be a ``dict`` where the key is ``name`` and the value is a string. + +DjangoModelFormMutation +~~~~~~~~~~~~~~~~~~~~~~~ + +``DjangoModelFormMutation`` will pull the fields from a ``ModelForm``. + +.. code:: python + + from graphene_django.forms.mutation import DjangoModelFormMutation + + class Pet(models.Model): + name = models.CharField() + + class PetForm(forms.ModelForm): + class Meta: + model = Pet + fields = ('name',) + + # This will get returned when the mutation completes successfully + class PetType(DjangoObjectType): + class Meta: + model = Pet + + class PetMutation(DjangoModelFormMutation): + pet = Field(PetType) + + class Meta: + form_class = PetForm + +``PetMutation`` will grab the fields from ``PetForm`` and turn them into inputs. If the form is valid then the mutation +will lookup the ``DjangoObjectType`` for the ``Pet`` model and return that under the key ``pet``. Otherwise it will +return a list of errors. + +You can change the input name (default is ``input``) and the return field name (default is the model name lowercase). + +.. code:: python + + class PetMutation(DjangoModelFormMutation): + class Meta: + form_class = PetForm + input_field_name = 'data' + return_field_name = 'my_pet' + +Form validation +~~~~~~~~~~~~~~~ + +Form mutations will call ``is_valid()`` on your forms. + +If the form is valid then the class method ``perform_mutate(form, info)`` is called on the mutation. Override this method +to change how the form is saved or to return a different Graphene object type. + +If the form is *not* valid then a list of errors will be returned. These errors have two fields: ``field``, a string +containing the name of the invalid form field, and ``messages``, a list of strings with the validation messages. + + +Django REST Framework +--------------------- + +You can re-use your Django Rest Framework serializer with Graphene Django mutations. + +You can create a Mutation based on a serializer by using the `SerializerMutation` base class: + +.. code:: python + + from graphene_django.rest_framework.mutation import SerializerMutation + + class MyAwesomeMutation(SerializerMutation): + class Meta: + serializer_class = MySerializer + + +Create/Update Operations +~~~~~~~~~~~~~~~~~~~~~~~~ + +By default ModelSerializers accept create and update operations. To +customize this use the `model_operations` attribute on the ``SerializerMutation`` class. + +The update operation looks up models by the primary key by default. You can +customize the look up with the ``lookup_field`` attribute on the ``SerializerMutation`` class. + +.. code:: python + + from graphene_django.rest_framework.mutation import SerializerMutation + from .serializers imoprt MyModelSerializer + + + class AwesomeModelMutation(SerializerMutation): + class Meta: + serializer_class = MyModelSerializer + model_operations = ['create', 'update'] + lookup_field = 'id' + +Overriding Update Queries +~~~~~~~~~~~~~~~~~~~~~~~~~ + +Use the method ``get_serializer_kwargs`` to override how updates are applied. + +.. code:: python + + from graphene_django.rest_framework.mutation import SerializerMutation + from .serializers imoprt MyModelSerializer + + + class AwesomeModelMutation(SerializerMutation): + class Meta: + serializer_class = MyModelSerializer + + @classmethod + def get_serializer_kwargs(cls, root, info, **input): + if 'id' in input: + instance = Post.objects.filter( + id=input['id'], owner=info.context.user + ).first() + if instance: + return {'instance': instance, 'data': input, 'partial': True} + + else: + raise http.Http404 + + return {'data': input, 'partial': True} + + + +Relay +----- + +You can use relay with mutations. A Relay mutation must inherit from +``ClientIDMutation`` and implement the ``mutate_and_get_payload`` method: + +.. code:: python + + import graphene import relay, DjangoObjectType + from graphql_relay import from_global_id + + from .queries import QuestionType + + + class QuestionMutation(relay.ClientIDMutation): + class Input: + text = graphene.String(required=True) + id = graphene.ID() + + question = graphene.Field(QuestionType) + + @classmethod + def mutate_and_get_payload(cls, root, info, text, id): + question = Question.objects.get(pk=from_global_id(id)) + question.text = text + question.save() + return QuestionMutation(question=question) + +Notice that the ``class Arguments`` is renamed to ``class Input`` with relay. +This is due to a deprecation of ``class Arguments`` in graphene 2.0. + +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 diff --git a/docs/queries.rst b/docs/queries.rst new file mode 100644 index 0000000..d54c908 --- /dev/null +++ b/docs/queries.rst @@ -0,0 +1,270 @@ +Queries & ObjectTypes +===================== + +Introduction +------------ + +Graphene-Django offers a host of features for performing GraphQL queries. + +Graphene-Django ships with a special ``DjangoObjectType`` that automatically transforms a Django Model +into a ``ObjectType`` for you. + + +Full example +~~~~~~~~~~~~ + +.. code:: python + + # my_app/schema.py + + import graphene + + from graphene_django.types import DjangoObjectType + from .models import Question + + + class QuestionType(DjangoObjectType): + class Meta: + model = Question + + + class Query: + questions = graphene.List(QuestionType) + question = graphene.Field(Question, question_id=graphene.String()) + + def resolve_questions(self, info, **kwargs): + # Querying a list + return Question.objects.all() + + def resolve_question(self, info, question_id): + # Querying a single question + return Question.objects.get(pk=question_id) + + +Fields +------ + +By default, ``DjangoObjectType`` will present all fields on a Model through GraphQL. +If you don't want to do this you can change this by setting either ``only_fields`` and ``exclude_fields``. + +only_fields +~~~~~~~~~~~ + +Show **only** these fields on the model: + +.. code:: python + + class QuestionType(DjangoObjectType): + class Meta: + model = Question + only_fields = ('question_text') + + +exclude_fields +~~~~~~~~~~~~~~ + +Show all fields **except** those in ``exclude_fields``: + +.. code:: python + + class QuestionType(DjangoObjectType): + class Meta: + model = Question + exclude_fields = ('question_text') + + +Customised fields +~~~~~~~~~~~~~~~~~ + +You can completely overwrite a field, or add new fields, to a ``DjangoObjectType`` using a Resolver: + +.. code:: python + + class QuestionType(DjangoObjectType): + + class Meta: + model = Question + exclude_fields = ('question_text') + + extra_field = graphene.String() + + def resolve_extra_field(self, info): + return 'hello!' + + +Related models +-------------- + +Say you have the following models: + +.. code:: python + + class Category(models.Model): + foo = models.CharField(max_length=256) + + class Question(models.Model): + category = models.ForeignKey(Category, on_delete=models.CASCADE) + + +When ``Question`` is published as a ``DjangoObjectType`` and you want to add ``Category`` as a query-able field like so: + +.. code:: python + + class QuestionType(DjangoObjectType): + class Meta: + model = Question + only_fields = ('category',) + +Then all query-able related models must be defined as DjangoObjectType subclass, +or they will fail to show if you are trying to query those relation fields. You only +need to create the most basic class for this to work: + +.. code:: python + + class CategoryType(DjangoObjectType): + class Meta: + model = Category + +Default QuerySet +----------------- + +If you are using ``DjangoObjectType`` you can define a custom `get_queryset` method. +Use this to control filtering on the ObjectType level instead of the Query object level. + +.. code:: python + + from graphene_django.types import DjangoObjectType + from .models import Question + + + class QuestionType(DjangoObjectType): + class Meta: + model = Question + + @classmethod + def get_queryset(cls, queryset, info): + if info.context.user.is_anonymous: + return queryset.filter(published=True) + return queryset + +Resolvers +--------- + +When a GraphQL query is received by the ``Schema`` object, it will map it to a "Resolver" related to it. + +This resolve method should follow this format: + +.. code:: python + + def resolve_foo(self, info, **kwargs): + +Where "foo" is the name of the field declared in the ``Query`` object. + +.. code:: python + + class Query: + foo = graphene.List(QuestionType) + + def resolve_foo(self, info, **kwargs): + id = kwargs.get('id') + return QuestionModel.objects.get(id) + +Arguments +~~~~~~~~~ + +Additionally, Resolvers will receive **any arguments declared in the field definition**. This allows you to provide input arguments in your GraphQL server and can be useful for custom queries. + +.. code:: python + + class Query: + question = graphene.Field(Question, foo=graphene.String(), bar=graphene.Int()) + + def resolve_question(self, info, foo, bar): + # If `foo` or `bar` are declared in the GraphQL query they will be here, else None. + return Question.objects.filter(foo=foo, bar=bar).first() + + +Info +~~~~ + +The ``info`` argument passed to all resolve methods holds some useful information. +For Graphene-Django, the ``info.context`` attribute is the ``HTTPRequest`` object +that would be familiar to any Django developer. This gives you the full functionality +of Django's ``HTTPRequest`` in your resolve methods, such as checking for authenticated users: + +.. code:: python + + def resolve_questions(self, info, **kwargs): + # See if a user is authenticated + if info.context.user.is_authenticated(): + return Question.objects.all() + else: + return Question.objects.none() + + +Plain ObjectTypes +----------------- + +With Graphene-Django you are not limited to just Django Models - you can use the standard +``ObjectType`` to create custom fields or to provide an abstraction between your internal +Django models and your external API. + +.. code:: python + + import graphene + from .models import Question + + + class MyQuestion(graphene.ObjectType): + text = graphene.String() + + + class Query: + question = graphene.Field(MyQuestion, question_id=graphene.String()) + + def resolve_question(self, info, question_id): + question = Question.objects.get(pk=question_id) + return MyQuestion( + text=question.question_text + ) + +For more information and more examples, please see the `core object type documentation `__. + + +Relay +----- + +`Relay `__ with Graphene-Django gives us some additional features: + +- Pagination and slicing. +- An abstract ``id`` value which contains enough info for the server to know its type and its id. + +There is one additional import and a single line of code needed to adopt this: + +Full example +~~~~~~~~~~~~ + +.. code:: python + + from graphene import relay + from graphene_django import DjangoObjectType + from .models import Question + + + class QuestionType(DjangoObjectType): + class Meta: + model = Question + interaces = (relay.Node,) + + + class QuestionConnection(relay.Connection): + class Meta: + node = QuestionType + + + class Query: + question = graphene.Field(QuestionType) + questions = relay.ConnectionField(QuestionConnection) + +See the `Relay documentation `__ on +the core graphene pages for more information on customing the Relay experience. \ No newline at end of file diff --git a/docs/rest-framework.rst b/docs/rest-framework.rst deleted file mode 100644 index ce666de..0000000 --- a/docs/rest-framework.rst +++ /dev/null @@ -1,64 +0,0 @@ -Integration with Django Rest Framework -====================================== - -You can re-use your Django Rest Framework serializer with -graphene django. - - -Mutation --------- - -You can create a Mutation based on a serializer by using the -`SerializerMutation` base class: - -.. code:: python - - from graphene_django.rest_framework.mutation import SerializerMutation - - class MyAwesomeMutation(SerializerMutation): - class Meta: - serializer_class = MySerializer - -Create/Update Operations ---------------------- - -By default ModelSerializers accept create and update operations. To -customize this use the `model_operations` attribute. The update -operation looks up models by the primary key by default. You can -customize the look up with the lookup attribute. - -.. code:: python - - from graphene_django.rest_framework.mutation import SerializerMutation - - class AwesomeModelMutation(SerializerMutation): - class Meta: - serializer_class = MyModelSerializer - model_operations = ['create', 'update'] - lookup_field = 'id' - -Overriding Update Queries -------------------------- - -Use the method `get_serializer_kwargs` to override how -updates are applied. - -.. code:: python - - from graphene_django.rest_framework.mutation import SerializerMutation - - class AwesomeModelMutation(SerializerMutation): - class Meta: - serializer_class = MyModelSerializer - - @classmethod - def get_serializer_kwargs(cls, root, info, **input): - if 'id' in input: - instance = Post.objects.filter(id=input['id'], owner=info.context.user).first() - if instance: - return {'instance': instance, 'data': input, 'partial': True} - - else: - raise http.Http404 - - return {'data': input, 'partial': True} diff --git a/docs/schema.rst b/docs/schema.rst new file mode 100644 index 0000000..9f0c283 --- /dev/null +++ b/docs/schema.rst @@ -0,0 +1,50 @@ +Schema +====== + +The ``graphene.Schema`` object describes your data model and provides a GraphQL server with an associated set of resolve methods that know how to fetch data. The most basic schema you can create looks like this: + +.. code:: python + + import graphene + + class Query(graphene.ObjectType): + pass + + class Mutation(graphene.ObjectType): + pass + + schema = graphene.Schema(query=Query, mutation=Mutation) + + +This schema doesn't do anything yet, but it is ready to accept new Query or Mutation fields. + + +Adding to the schema +-------------------- + +If you have defined a ``Query`` or ``Mutation``, you can register them with the schema: + +.. code:: python + + import graphene + + import my_app.schema.Query + import my_app.schema.Mutation + + class Query( + my_app.schema.Query, # Add your Query objects here + graphene.ObjectType + ): + pass + + class Mutation( + my_app.schema.Mutation, # Add your Mutation objects here + graphene.ObjectType + ): + pass + + schema = graphene.Schema(query=Query, mutation=Mutation) + +You can add as many mixins to the base ``Query`` and ``Mutation`` objects as you like. + +Read more about Schema on the `core graphene docs `__ \ No newline at end of file diff --git a/docs/tutorial-plain.rst b/docs/tutorial-plain.rst index a87b011..29df56e 100644 --- a/docs/tutorial-plain.rst +++ b/docs/tutorial-plain.rst @@ -1,12 +1,9 @@ -Introduction tutorial - Graphene and Django +Basic Tutorial =========================================== -Graphene has a number of additional features that are designed to make -working with Django *really simple*. - -Our primary focus here is to give a good understanding of how to connect models from Django ORM to graphene object types. - -A good idea is to check the `graphene `__ documentation first. +Graphene Django has a number of additional features that are designed to make +working with Django easy. Our primary focus in this tutorial is to give a good +understanding of how to connect models from Django ORM to graphene object types. Set up the Django project ------------------------- @@ -91,7 +88,7 @@ Don't forget to create & run migrations: python manage.py makemigrations python manage.py migrate - + Load some test data ^^^^^^^^^^^^^^^^^^^ @@ -108,7 +105,7 @@ following: $ python ./manage.py loaddata ingredients Installed 6 object(s) from 1 fixture(s) - + Alternatively you can use the Django admin interface to create some data yourself. You'll need to run the development server (see below), and create a login for yourself too (``./manage.py createsuperuser``). @@ -255,7 +252,7 @@ aforementioned GraphiQL we specify that on the parameters with ``graphiql=True`` urlpatterns = [ url(r'^admin/', admin.site.urls), - url(r'^graphql', GraphQLView.as_view(graphiql=True)), + url(r'^graphql$', GraphQLView.as_view(graphiql=True)), ] @@ -273,7 +270,7 @@ as explained above, we can do so here using: urlpatterns = [ url(r'^admin/', admin.site.urls), - url(r'^graphql', GraphQLView.as_view(graphiql=True, schema=schema)), + url(r'^graphql$', GraphQLView.as_view(graphiql=True, schema=schema)), ] @@ -487,7 +484,7 @@ Now, with the code in place, we can query for single objects. For example, lets query ``category``: -.. code:: +.. code:: query { category(id: 1) { @@ -536,3 +533,6 @@ Summary As you can see, GraphQL is very powerful but there are a lot of repetitions in our example. We can do a lot of improvements by adding layers of abstraction on top of ``graphene-django``. If you want to put things like ``django-filter`` and automatic pagination in action, you should continue with the **relay tutorial.** + +A good idea is to check the `graphene `__ +documentation but it is not essential to understand and use Graphene-Django in your project. \ No newline at end of file diff --git a/docs/tutorial-relay.rst b/docs/tutorial-relay.rst index 630898e..5f8bd64 100644 --- a/docs/tutorial-relay.rst +++ b/docs/tutorial-relay.rst @@ -1,4 +1,4 @@ -Graphene and Django Tutorial using Relay +Relay tutorial ======================================== Graphene has a number of additional features that are designed to make @@ -244,7 +244,7 @@ aforementioned GraphiQL we specify that on the params with ``graphiql=True``. urlpatterns = [ url(r'^admin/', admin.site.urls), - url(r'^graphql', GraphQLView.as_view(graphiql=True)), + url(r'^graphql$', GraphQLView.as_view(graphiql=True)), ] @@ -262,7 +262,7 @@ as explained above, we can do so here using: urlpatterns = [ url(r'^admin/', admin.site.urls), - url(r'^graphql', GraphQLView.as_view(graphiql=True, schema=schema)), + url(r'^graphql$', GraphQLView.as_view(graphiql=True, schema=schema)), ] diff --git a/examples/cookbook/cookbook/urls.py b/examples/cookbook/cookbook/urls.py index 9f8755b..4bf6003 100644 --- a/examples/cookbook/cookbook/urls.py +++ b/examples/cookbook/cookbook/urls.py @@ -6,5 +6,5 @@ from graphene_django.views import GraphQLView urlpatterns = [ url(r'^admin/', admin.site.urls), - url(r'^graphql', GraphQLView.as_view(graphiql=True)), + url(r'^graphql$', GraphQLView.as_view(graphiql=True)), ] From 15b5e6ae246b8626f9338ea89e101794fe457d38 Mon Sep 17 00:00:00 2001 From: Paul Hallett Date: Tue, 7 May 2019 19:26:19 +0100 Subject: [PATCH 049/171] Fix security issues --- examples/cookbook-plain/requirements.txt | 2 +- examples/cookbook/requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/cookbook-plain/requirements.txt b/examples/cookbook-plain/requirements.txt index 539fd67..2154fd8 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.1.2 +django==2.1.6 diff --git a/examples/cookbook/requirements.txt b/examples/cookbook/requirements.txt index b2ace1f..3fed30f1 100644 --- a/examples/cookbook/requirements.txt +++ b/examples/cookbook/requirements.txt @@ -1,5 +1,5 @@ graphene graphene-django graphql-core>=2.1rc1 -django==1.9 +django==1.11.19 django-filter>=2 From df4a07982f553cb8dbbe7e66c47fd48cdd0a3486 Mon Sep 17 00:00:00 2001 From: Paul Hallett Date: Tue, 7 May 2019 20:22:08 +0100 Subject: [PATCH 050/171] Add documentation for settings --- docs/index.rst | 2 +- docs/settings.rst | 103 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 docs/settings.rst diff --git a/docs/index.rst b/docs/index.rst index c7820cf..602f8dd 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -29,6 +29,6 @@ For more advanced use, check out the Relay tutorial. filtering authorization debug - rest-framework introspection testing + settings diff --git a/docs/settings.rst b/docs/settings.rst new file mode 100644 index 0000000..bd09886 --- /dev/null +++ b/docs/settings.rst @@ -0,0 +1,103 @@ +Settings +======== + +Graphene-Django can be customised using settings. This page explains each setting and their defaults. + +Usage +----- + +Add settings to your Django project by creating a Dictonary with name ``GRAPHENE`` in the project's ``settings.py``: + +.. code:: python + + GRAPHENE = { + ... + } + + +``SCHEMA`` +---------- + +The location of the top-level ``Schema`` class. + +Default: ``None`` + +.. code:: python + + GRAPHENE = { + 'SCHEMA': 'path.to.schema.schema', + } + + +``SCHEMA_OUTPUT`` +---------- + +The name of the file where the GraphQL schema output will go. + +Default: ``schema.json`` + +.. code:: python + + GRAPHENE = { + 'SCHEMA_OUTPUT': 'schema.json', + } + + +``SCHEMA_INDENT`` +---------- + +The indentation level of the schema output. + +Default: ``2`` + +.. code:: python + + GRAPHENE = { + 'SCHEMA_INDENT': 2, + } + + +``MIDDLEWARE`` +---------- + +A tuple of middleware that will be executed for each GraphQL query. + +See the `middleware documentation `__ for more information. + +Default: ``()`` + +.. code:: python + + GRAPHENE = { + 'MIDDLEWARE': ( + 'path.to.my.middleware.class', + ), + } + + +``RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST`` +---------- + +Enforces relay queries to have the ``first`` or ``last`` argument. + +Default: ``False`` + +.. code:: python + + GRAPHENE = { + 'RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST': False, + } + + +``RELAY_CONNECTION_MAX_LIMIT`` +---------- + +The maximum size of objects that can be requested through a relay connection. + +Default: ``100`` + +.. code:: python + + GRAPHENE = { + 'RELAY_CONNECTION_MAX_LIMIT': 100, + } \ No newline at end of file From bd53940d2322762d86d950361c61e63755972af9 Mon Sep 17 00:00:00 2001 From: Paul Hallett Date: Tue, 7 May 2019 20:23:26 +0100 Subject: [PATCH 051/171] newline --- docs/settings.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/settings.rst b/docs/settings.rst index bd09886..547e77f 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -100,4 +100,4 @@ Default: ``100`` GRAPHENE = { 'RELAY_CONNECTION_MAX_LIMIT': 100, - } \ No newline at end of file + } From 2bf7e7f66daf3f17c0ec8e599a32769eac24d849 Mon Sep 17 00:00:00 2001 From: Paul Hallett Date: Wed, 8 May 2019 22:45:28 +0100 Subject: [PATCH 052/171] Fix importing error for GraphQLTestCase --- docs/testing.rst | 2 +- graphene_django/utils/__init__.py | 19 +++++++++++++++++++ .../{tests/base_test.py => utils/testing.py} | 4 +--- graphene_django/{ => utils}/utils.py | 7 ------- 4 files changed, 21 insertions(+), 11 deletions(-) create mode 100644 graphene_django/utils/__init__.py rename graphene_django/{tests/base_test.py => utils/testing.py} (95%) rename graphene_django/{ => utils}/utils.py (96%) diff --git a/docs/testing.rst b/docs/testing.rst index a4d5518..b111642 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -9,7 +9,7 @@ Usage: import json - from graphene_django.tests.base_test import GraphQLTestCase + from graphene_django.utils.testing import GraphQLTestCase from my_project.config.schema import schema class MyFancyTestCase(GraphQLTestCase): diff --git a/graphene_django/utils/__init__.py b/graphene_django/utils/__init__.py new file mode 100644 index 0000000..f9c388d --- /dev/null +++ b/graphene_django/utils/__init__.py @@ -0,0 +1,19 @@ +from .utils import ( + DJANGO_FILTER_INSTALLED, + get_reverse_fields, + maybe_queryset, + get_model_fields, + is_valid_django_model, + import_single_dispatch, +) +from .testing import GraphQLTestCase + +__all__ = [ + "DJANGO_FILTER_INSTALLED", + "get_reverse_fields", + "maybe_queryset", + "get_model_fields", + "is_valid_django_model", + "import_single_dispatch", + "GraphQLTestCase", +] diff --git a/graphene_django/tests/base_test.py b/graphene_django/utils/testing.py similarity index 95% rename from graphene_django/tests/base_test.py rename to graphene_django/utils/testing.py index 84e1dc5..47f8d04 100644 --- a/graphene_django/tests/base_test.py +++ b/graphene_django/utils/testing.py @@ -1,8 +1,6 @@ import json -from django.http import HttpResponse -from django.test import Client -from django.test import TestCase +from django.test import TestCase, Client class GraphQLTestCase(TestCase): diff --git a/graphene_django/utils.py b/graphene_django/utils/utils.py similarity index 96% rename from graphene_django/utils.py rename to graphene_django/utils/utils.py index 532be06..02c47ee 100644 --- a/graphene_django/utils.py +++ b/graphene_django/utils/utils.py @@ -4,13 +4,6 @@ from django.db import models from django.db.models.manager import Manager -# from graphene.utils import LazyList - - -class LazyList(object): - pass - - try: import django_filters # noqa From ce9d989bcdabc5e7a4bb3fa5559de0f4604b6a74 Mon Sep 17 00:00:00 2001 From: David Beitey Date: Mon, 13 May 2019 07:01:44 +0000 Subject: [PATCH 053/171] Update install docs for Django 2.x This uses the new URL routing syntax introduced in Django 2.0 (https://docs.djangoproject.com/en/2.2/releases/2.0/#simplified-url-routing-syntax). The older `url()` syntax will deprecated at some point in future https://docs.djangoproject.com/en/2.2/ref/urls/#url --- docs/installation.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/installation.rst b/docs/installation.rst index 8f3e550..a2dc665 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -30,16 +30,16 @@ Add ``graphene_django`` to the ``INSTALLED_APPS`` in the ``settings.py`` file of ] -We need to add a graphql URL to the ``urls.py`` of your Django project: +We need to add a ``graphql`` URL to the ``urls.py`` of your Django project: .. code:: python - from django.conf.urls import url + from django.urls import path from graphene_django.views import GraphQLView urlpatterns = [ # ... - url(r'^graphql$', GraphQLView.as_view(graphiql=True)), + path("graphql", GraphQLView.as_view(graphiql=True)), ] (Change ``graphiql=True`` to ``graphiql=False`` if you do not want to use the GraphiQL API browser.) From ba64bceab09c33370d5ab8a0c8076abeced57a71 Mon Sep 17 00:00:00 2001 From: zorig Date: Wed, 15 May 2019 17:22:29 +0800 Subject: [PATCH 054/171] graphiql version upgrade --- graphene_django/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/views.py b/graphene_django/views.py index 0b840f9..72cca88 100644 --- a/graphene_django/views.py +++ b/graphene_django/views.py @@ -51,7 +51,7 @@ def instantiate_middleware(middlewares): class GraphQLView(View): - graphiql_version = "0.11.10" + graphiql_version = "0.11.11" graphiql_template = "graphene/graphiql.html" schema = None From 884c4cce0c3342cf0b27c22ec08e6ee6145f3a49 Mon Sep 17 00:00:00 2001 From: changeling Date: Wed, 15 May 2019 16:27:35 -0500 Subject: [PATCH 055/171] Correct Babel Relay Plugin docs link per Issue 358. See graphql-python#358. --- docs/introspection.rst | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/introspection.rst b/docs/introspection.rst index 92e3612..0fc6776 100644 --- a/docs/introspection.rst +++ b/docs/introspection.rst @@ -1,9 +1,7 @@ Introspection Schema ==================== -Relay uses `Babel Relay -Plugin `__ -that requires you to provide your GraphQL schema data. +Relay Modern uses `Babel Relay Plugin `__ which requires you to provide your GraphQL schema data. Graphene comes with a Django management command to dump your schema data to ``schema.json`` which is compatible with babel-relay-plugin. From 2edf7f4ec0926159347c45df3ba1ef327d465706 Mon Sep 17 00:00:00 2001 From: changeling Date: Wed, 15 May 2019 17:02:26 -0500 Subject: [PATCH 056/171] Correct examples/cookbook settings.py. See https://github.com/graphql-python/graphene-django/issues/455. --- examples/cookbook-plain/cookbook/settings.py | 6 +----- examples/cookbook/cookbook/settings.py | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/examples/cookbook-plain/cookbook/settings.py b/examples/cookbook-plain/cookbook/settings.py index d846db4..bce2bab 100644 --- a/examples/cookbook-plain/cookbook/settings.py +++ b/examples/cookbook-plain/cookbook/settings.py @@ -56,6 +56,7 @@ MIDDLEWARE = [ GRAPHENE = { 'SCHEMA': 'cookbook.schema.schema', + 'SCHEMA_INDENT': 2, 'MIDDLEWARE': ( 'graphene_django.debug.DjangoDebugMiddleware', ) @@ -130,8 +131,3 @@ USE_TZ = True # https://docs.djangoproject.com/en/1.9/howto/static-files/ STATIC_URL = '/static/' - -GRAPHENE = { - 'SCHEMA': 'cookbook.schema.schema', - 'SCHEMA_INDENT': 2, -} diff --git a/examples/cookbook/cookbook/settings.py b/examples/cookbook/cookbook/settings.py index 948292d..0b3207e 100644 --- a/examples/cookbook/cookbook/settings.py +++ b/examples/cookbook/cookbook/settings.py @@ -57,6 +57,7 @@ MIDDLEWARE_CLASSES = [ GRAPHENE = { 'SCHEMA': 'cookbook.schema.schema', + 'SCHEMA_INDENT': 2, 'MIDDLEWARE': ( 'graphene_django.debug.DjangoDebugMiddleware', ) @@ -131,8 +132,3 @@ USE_TZ = True # https://docs.djangoproject.com/en/1.9/howto/static-files/ STATIC_URL = '/static/' - -GRAPHENE = { - 'SCHEMA': 'cookbook.schema.schema', - 'SCHEMA_INDENT': 2, -} From 04fe299a6e71b15d638dbbb3a48e6c8f8dc50d3d Mon Sep 17 00:00:00 2001 From: changeling Date: Wed, 15 May 2019 19:50:55 -0500 Subject: [PATCH 057/171] Corrected docs/queries.rst. (#633) * Corrected typos in docs/queries.rst. * Add basic resolvers to Relay Full example in docs/queries.rst. Added basic resolvers to Full example in Relay section. * Remove question and question resolver. * Add query example to queries.rst. Added query example in Relay section. Minor clean-up. --- docs/queries.rst | 72 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 67 insertions(+), 5 deletions(-) diff --git a/docs/queries.rst b/docs/queries.rst index d54c908..0edd1dd 100644 --- a/docs/queries.rst +++ b/docs/queries.rst @@ -30,7 +30,7 @@ Full example class Query: questions = graphene.List(QuestionType) - question = graphene.Field(Question, question_id=graphene.String()) + question = graphene.Field(QuestionType, question_id=graphene.String()) def resolve_questions(self, info, **kwargs): # Querying a list @@ -243,6 +243,8 @@ There is one additional import and a single line of code needed to adopt this: Full example ~~~~~~~~~~~~ +See the `Relay documentation `__ on +the core graphene pages for more information on customizing the Relay experience. .. code:: python @@ -254,7 +256,7 @@ Full example class QuestionType(DjangoObjectType): class Meta: model = Question - interaces = (relay.Node,) + interfaces = (relay.Node,) class QuestionConnection(relay.Connection): @@ -263,8 +265,68 @@ Full example class Query: - question = graphene.Field(QuestionType) questions = relay.ConnectionField(QuestionConnection) -See the `Relay documentation `__ on -the core graphene pages for more information on customing the Relay experience. \ No newline at end of file + def resolve_questions(root, info, **kwargs): + return Question.objects.all() + + +You can now execute queries like: + + +.. code:: python + + { + questions (first: 2, after: "YXJyYXljb25uZWN0aW9uOjEwNQ==") { + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } + edges { + cursor + node { + id + question_text + } + } + } + } + +Which returns: + +.. code:: python + + { + "data": { + "questions": { + "pageInfo": { + "startCursor": "YXJyYXljb25uZWN0aW9uOjEwNg==", + "endCursor": "YXJyYXljb25uZWN0aW9uOjEwNw==", + "hasNextPage": true, + "hasPreviousPage": false + }, + "edges": [ + { + "cursor": "YXJyYXljb25uZWN0aW9uOjEwNg==", + "node": { + "id": "UGxhY2VUeXBlOjEwNw==", + "question_text": "How did we get here?" + } + }, + { + "cursor": "YXJyYXljb25uZWN0aW9uOjEwNw==", + "node": { + "id": "UGxhY2VUeXBlOjEwOA==", + "name": "Where are we?" + } + } + ] + } + } + } + +Note that relay implements :code:`pagination` capabilities automatically, adding a :code:`pageInfo` element, and including :code:`cursor` on nodes. These elements are included in the above example for illustration. + +To learn more about Pagination in general, take a look at `Pagination `__ on the GraphQL community site. From 49aedf171abc176bd20839dd309109c7400b9fc0 Mon Sep 17 00:00:00 2001 From: Dulmandakh Date: Mon, 20 May 2019 17:48:28 +0800 Subject: [PATCH 058/171] bump graphiql to 0.13.0, and rename __debug to _debug due to __ limitations --- docs/debug.rst | 8 +++---- examples/cookbook-plain/cookbook/schema.py | 2 +- examples/cookbook/cookbook/schema.py | 2 +- graphene_django/debug/tests/test_query.py | 28 +++++++++++----------- graphene_django/views.py | 2 +- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/docs/debug.rst b/docs/debug.rst index 8e67c23..d1cbb21 100644 --- a/docs/debug.rst +++ b/docs/debug.rst @@ -15,7 +15,7 @@ For use the Django Debug plugin in Graphene: * Add ``graphene_django.debug.DjangoDebugMiddleware`` into ``MIDDLEWARE`` in the ``GRAPHENE`` settings. -* Add the ``debug`` field into the schema root ``Query`` with the value ``graphene.Field(DjangoDebug, name='__debug')``. +* Add the ``debug`` field into the schema root ``Query`` with the value ``graphene.Field(DjangoDebug, name='_debug')``. .. code:: python @@ -24,7 +24,7 @@ For use the Django Debug plugin in Graphene: class Query(graphene.ObjectType): # ... - debug = graphene.Field(DjangoDebug, name='__debug') + debug = graphene.Field(DjangoDebug, name='_debug') schema = graphene.Schema(query=Query) @@ -59,11 +59,11 @@ the GraphQL request, like: } } # Here is the debug field that will output the SQL queries - __debug { + _debug { sql { rawSql } } } -Note that the ``__debug`` field must be the last field in your query. +Note that the ``_debug`` field must be the last field in your query. diff --git a/examples/cookbook-plain/cookbook/schema.py b/examples/cookbook-plain/cookbook/schema.py index f8606a7..f91d62c 100644 --- a/examples/cookbook-plain/cookbook/schema.py +++ b/examples/cookbook-plain/cookbook/schema.py @@ -8,7 +8,7 @@ from graphene_django.debug import DjangoDebug class Query(cookbook.ingredients.schema.Query, cookbook.recipes.schema.Query, graphene.ObjectType): - debug = graphene.Field(DjangoDebug, name='__debug') + debug = graphene.Field(DjangoDebug, name='_debug') schema = graphene.Schema(query=Query) diff --git a/examples/cookbook/cookbook/schema.py b/examples/cookbook/cookbook/schema.py index f8606a7..f91d62c 100644 --- a/examples/cookbook/cookbook/schema.py +++ b/examples/cookbook/cookbook/schema.py @@ -8,7 +8,7 @@ from graphene_django.debug import DjangoDebug class Query(cookbook.ingredients.schema.Query, cookbook.recipes.schema.Query, graphene.ObjectType): - debug = graphene.Field(DjangoDebug, name='__debug') + debug = graphene.Field(DjangoDebug, name='_debug') schema = graphene.Schema(query=Query) diff --git a/graphene_django/debug/tests/test_query.py b/graphene_django/debug/tests/test_query.py index f2ef096..592899b 100644 --- a/graphene_django/debug/tests/test_query.py +++ b/graphene_django/debug/tests/test_query.py @@ -31,7 +31,7 @@ def test_should_query_field(): class Query(graphene.ObjectType): reporter = graphene.Field(ReporterType) - debug = graphene.Field(DjangoDebug, name="__debug") + debug = graphene.Field(DjangoDebug, name="_debug") def resolve_reporter(self, info, **args): return Reporter.objects.first() @@ -41,7 +41,7 @@ def test_should_query_field(): reporter { lastName } - __debug { + _debug { sql { rawSql } @@ -50,7 +50,7 @@ def test_should_query_field(): """ expected = { "reporter": {"lastName": "ABA"}, - "__debug": { + "_debug": { "sql": [{"rawSql": str(Reporter.objects.order_by("pk")[:1].query)}] }, } @@ -75,7 +75,7 @@ def test_should_query_list(): class Query(graphene.ObjectType): all_reporters = graphene.List(ReporterType) - debug = graphene.Field(DjangoDebug, name="__debug") + debug = graphene.Field(DjangoDebug, name="_debug") def resolve_all_reporters(self, info, **args): return Reporter.objects.all() @@ -85,7 +85,7 @@ def test_should_query_list(): allReporters { lastName } - __debug { + _debug { sql { rawSql } @@ -94,7 +94,7 @@ def test_should_query_list(): """ expected = { "allReporters": [{"lastName": "ABA"}, {"lastName": "Griffin"}], - "__debug": {"sql": [{"rawSql": str(Reporter.objects.all().query)}]}, + "_debug": {"sql": [{"rawSql": str(Reporter.objects.all().query)}]}, } schema = graphene.Schema(query=Query) result = schema.execute( @@ -117,7 +117,7 @@ def test_should_query_connection(): class Query(graphene.ObjectType): all_reporters = DjangoConnectionField(ReporterType) - debug = graphene.Field(DjangoDebug, name="__debug") + debug = graphene.Field(DjangoDebug, name="_debug") def resolve_all_reporters(self, info, **args): return Reporter.objects.all() @@ -131,7 +131,7 @@ def test_should_query_connection(): } } } - __debug { + _debug { sql { rawSql } @@ -145,9 +145,9 @@ def test_should_query_connection(): ) assert not result.errors assert result.data["allReporters"] == expected["allReporters"] - assert "COUNT" in result.data["__debug"]["sql"][0]["rawSql"] + assert "COUNT" in result.data["_debug"]["sql"][0]["rawSql"] query = str(Reporter.objects.all()[:1].query) - assert result.data["__debug"]["sql"][1]["rawSql"] == query + assert result.data["_debug"]["sql"][1]["rawSql"] == query def test_should_query_connectionfilter(): @@ -166,7 +166,7 @@ def test_should_query_connectionfilter(): class Query(graphene.ObjectType): all_reporters = DjangoFilterConnectionField(ReporterType, fields=["last_name"]) s = graphene.String(resolver=lambda *_: "S") - debug = graphene.Field(DjangoDebug, name="__debug") + debug = graphene.Field(DjangoDebug, name="_debug") def resolve_all_reporters(self, info, **args): return Reporter.objects.all() @@ -180,7 +180,7 @@ def test_should_query_connectionfilter(): } } } - __debug { + _debug { sql { rawSql } @@ -194,6 +194,6 @@ def test_should_query_connectionfilter(): ) assert not result.errors assert result.data["allReporters"] == expected["allReporters"] - assert "COUNT" in result.data["__debug"]["sql"][0]["rawSql"] + assert "COUNT" in result.data["_debug"]["sql"][0]["rawSql"] query = str(Reporter.objects.all()[:1].query) - assert result.data["__debug"]["sql"][1]["rawSql"] == query + assert result.data["_debug"]["sql"][1]["rawSql"] == query diff --git a/graphene_django/views.py b/graphene_django/views.py index 72cca88..c9ac770 100644 --- a/graphene_django/views.py +++ b/graphene_django/views.py @@ -51,7 +51,7 @@ def instantiate_middleware(middlewares): class GraphQLView(View): - graphiql_version = "0.11.11" + graphiql_version = "0.13.0" graphiql_template = "graphene/graphiql.html" schema = None From 7690c2c0025f1537ee9c9c2971d3c9b1546b9ba6 Mon Sep 17 00:00:00 2001 From: Dulmandakh Date: Mon, 20 May 2019 19:41:25 +0800 Subject: [PATCH 059/171] bump react to 16.8.6 --- graphene_django/templates/graphene/graphiql.html | 6 ++---- graphene_django/views.py | 5 ++++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/graphene_django/templates/graphene/graphiql.html b/graphene_django/templates/graphene/graphiql.html index c0c9af1..d0fb5a8 100644 --- a/graphene_django/templates/graphene/graphiql.html +++ b/graphene_django/templates/graphene/graphiql.html @@ -23,11 +23,9 @@ add "&raw" to the end of the URL within a browser. - - diff --git a/graphene_django/views.py b/graphene_django/views.py index 0b840f9..2fc8c88 100644 --- a/graphene_django/views.py +++ b/graphene_django/views.py @@ -53,6 +53,7 @@ def instantiate_middleware(middlewares): class GraphQLView(View): graphiql_version = "0.11.10" graphiql_template = "graphene/graphiql.html" + react_version = "16.8.6" schema = None graphiql = False @@ -126,7 +127,9 @@ class GraphQLView(View): if show_graphiql: return self.render_graphiql( - request, graphiql_version=self.graphiql_version + request, + graphiql_version=self.graphiql_version, + react_version=self.react_version, ) if self.batch: From cb9eed6765f0ab706fdc1e6884d1c24c8123965b Mon Sep 17 00:00:00 2001 From: Anthony Monthe Date: Mon, 7 May 2018 00:22:34 +0100 Subject: [PATCH 060/171] 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 061/171] 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 062/171] 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 063/171] 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 064/171] 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 065/171] 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 066/171] 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 067/171] 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 068/171] 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 069/171] 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 070/171] 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 071/171] 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) From 44e9b0d0c584fde3028acdf132b4e70de7e9e7f1 Mon Sep 17 00:00:00 2001 From: Mel van Londen Date: Mon, 10 Jun 2019 11:08:41 -0700 Subject: [PATCH 072/171] Add stale bot (#661) --- .github/stale.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .github/stale.yml diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 0000000..dc90e5a --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,17 @@ +# Number of days of inactivity before an issue becomes stale +daysUntilStale: 60 +# Number of days of inactivity before a stale issue is closed +daysUntilClose: 7 +# Issues with these labels will never be considered stale +exemptLabels: + - pinned + - security +# 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 +markComment: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. Thank you + for your contributions. +# Comment to post when closing a stale issue. Set to `false` to disable +closeComment: false From 775d2e35233f14123cea127dbd7b79d53e8e4420 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Mon, 10 Jun 2019 20:54:30 -0700 Subject: [PATCH 073/171] Update travis and tox (#667) * Update travis and tox * Use xenial distribution * Don't install coveralls twice * Add black and flake8 tox commands * Remove Python 3.5 test for Django master * Fix indent * Ignore migrations * Remove black for now * Run black formatting (#668) * Run black format * Update makefile * Add black to travis build --- .travis.yml | 80 +++++++-------- Makefile | 4 +- .../cookbook/ingredients/admin.py | 4 +- .../cookbook/ingredients/apps.py | 6 +- .../cookbook/ingredients/models.py | 7 +- .../cookbook/ingredients/schema.py | 12 +-- .../cookbook/ingredients/tests.py | 1 - .../cookbook/ingredients/views.py | 1 - .../cookbook-plain/cookbook/recipes/apps.py | 6 +- .../cookbook-plain/cookbook/recipes/models.py | 22 +++-- .../cookbook-plain/cookbook/recipes/schema.py | 9 +- .../cookbook-plain/cookbook/recipes/tests.py | 1 - .../cookbook-plain/cookbook/recipes/views.py | 1 - examples/cookbook-plain/cookbook/schema.py | 10 +- examples/cookbook-plain/cookbook/settings.py | 95 ++++++++---------- examples/cookbook-plain/cookbook/urls.py | 4 +- .../cookbook/cookbook/ingredients/admin.py | 4 +- .../cookbook/cookbook/ingredients/apps.py | 6 +- .../cookbook/cookbook/ingredients/models.py | 2 +- .../cookbook/cookbook/ingredients/schema.py | 16 ++- .../cookbook/cookbook/ingredients/tests.py | 1 - .../cookbook/cookbook/ingredients/views.py | 1 - examples/cookbook/cookbook/recipes/apps.py | 6 +- examples/cookbook/cookbook/recipes/models.py | 19 ++-- examples/cookbook/cookbook/recipes/schema.py | 15 ++- examples/cookbook/cookbook/recipes/tests.py | 1 - examples/cookbook/cookbook/recipes/views.py | 1 - examples/cookbook/cookbook/schema.py | 10 +- examples/cookbook/cookbook/settings.py | 97 +++++++++---------- examples/cookbook/cookbook/urls.py | 4 +- examples/starwars/data.py | 73 +++----------- examples/starwars/models.py | 10 +- examples/starwars/schema.py | 17 ++-- examples/starwars/tests/test_connections.py | 35 ++----- examples/starwars/tests/test_mutation.py | 58 +++-------- .../tests/test_objectidentification.py | 51 +++------- graphene_django/converter.py | 6 +- graphene_django/filter/fields.py | 5 +- graphene_django/filter/tests/test_fields.py | 1 + .../rest_framework/tests/test_mutation.py | 8 +- graphene_django/tests/test_query.py | 4 +- graphene_django/types.py | 10 +- tox.ini | 46 +++++---- 43 files changed, 331 insertions(+), 439 deletions(-) diff --git a/.travis.yml b/.travis.yml index 5c4725f..871d4e3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,58 +1,58 @@ language: python -sudo: required +cache: pip dist: xenial -python: - - 2.7 - - 3.4 - - 3.5 - - 3.6 - - 3.7 - -env: - matrix: - - 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 + - pip install tox tox-travis -after_success: - - tox -e $TOX_ENV -- pip install coveralls - - tox -e $TOX_ENV -- coveralls $COVERALLS_OPTION +script: + - tox + +after_success: + - pip install coveralls + - coveralls matrix: fast_finish: true include: - - 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 + env: DJANGO=1.11 + - python: 3.5 + env: DJANGO=1.11 + - python: 3.5 + env: DJANGO=2.0 + - python: 3.5 + env: DJANGO=2.1 + - python: 3.5 + env: DJANGO=2.2 + + - python: 3.6 + env: DJANGO=1.11 + - python: 3.6 + env: DJANGO=2.0 + - python: 3.6 + env: DJANGO=2.1 + - python: 3.6 + env: DJANGO=2.2 + - python: 3.6 env: DJANGO=master - - python: 3.7 - env: DJANGO=1.10 + - python: 3.7 env: DJANGO=1.11 - allow_failures: - python: 3.7 + env: DJANGO=2.0 + - python: 3.7 + env: DJANGO=2.1 + - python: 3.7 + env: DJANGO=2.2 + - python: 3.7 + env: DJANGO=master + + - python: 3.7 + env: TOXENV=black,flake8 + + allow_failures: - env: DJANGO=master deploy: diff --git a/Makefile b/Makefile index 061ad4e..70badcb 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ tests: py.test graphene_django --cov=graphene_django -vv format: - black graphene_django + black --exclude "/migrations/" graphene_django examples lint: - flake8 graphene_django + flake8 graphene_django examples diff --git a/examples/cookbook-plain/cookbook/ingredients/admin.py b/examples/cookbook-plain/cookbook/ingredients/admin.py index b57cbc3..042682f 100644 --- a/examples/cookbook-plain/cookbook/ingredients/admin.py +++ b/examples/cookbook-plain/cookbook/ingredients/admin.py @@ -5,8 +5,8 @@ from cookbook.ingredients.models import Category, Ingredient @admin.register(Ingredient) class IngredientAdmin(admin.ModelAdmin): - list_display = ('id', 'name', 'category') - list_editable = ('name', 'category') + list_display = ("id", "name", "category") + list_editable = ("name", "category") admin.site.register(Category) diff --git a/examples/cookbook-plain/cookbook/ingredients/apps.py b/examples/cookbook-plain/cookbook/ingredients/apps.py index 21b4b08..3ad0143 100644 --- a/examples/cookbook-plain/cookbook/ingredients/apps.py +++ b/examples/cookbook-plain/cookbook/ingredients/apps.py @@ -2,6 +2,6 @@ from django.apps import AppConfig class IngredientsConfig(AppConfig): - name = 'cookbook.ingredients' - label = 'ingredients' - verbose_name = 'Ingredients' + name = "cookbook.ingredients" + label = "ingredients" + verbose_name = "Ingredients" diff --git a/examples/cookbook-plain/cookbook/ingredients/models.py b/examples/cookbook-plain/cookbook/ingredients/models.py index 5836949..5d88785 100644 --- a/examples/cookbook-plain/cookbook/ingredients/models.py +++ b/examples/cookbook-plain/cookbook/ingredients/models.py @@ -3,7 +3,8 @@ from django.db import models class Category(models.Model): class Meta: - verbose_name_plural = 'Categories' + verbose_name_plural = "Categories" + name = models.CharField(max_length=100) def __str__(self): @@ -13,7 +14,9 @@ class Category(models.Model): class Ingredient(models.Model): name = models.CharField(max_length=100) notes = models.TextField(null=True, blank=True) - category = models.ForeignKey(Category, related_name='ingredients', on_delete=models.CASCADE) + category = models.ForeignKey( + Category, related_name="ingredients", on_delete=models.CASCADE + ) def __str__(self): return self.name diff --git a/examples/cookbook-plain/cookbook/ingredients/schema.py b/examples/cookbook-plain/cookbook/ingredients/schema.py index e7ef688..1a54c4b 100644 --- a/examples/cookbook-plain/cookbook/ingredients/schema.py +++ b/examples/cookbook-plain/cookbook/ingredients/schema.py @@ -15,14 +15,12 @@ class IngredientType(DjangoObjectType): class Query(object): - category = graphene.Field(CategoryType, - id=graphene.Int(), - name=graphene.String()) + 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()) + ingredient = graphene.Field( + IngredientType, id=graphene.Int(), name=graphene.String() + ) all_ingredients = graphene.List(IngredientType) def resolve_all_categories(self, context): @@ -30,7 +28,7 @@ class Query(object): def resolve_all_ingredients(self, context): # We can easily optimize query count in the resolve method - return Ingredient.objects.select_related('category').all() + return Ingredient.objects.select_related("category").all() def resolve_category(self, context, id=None, name=None): if id is not None: diff --git a/examples/cookbook-plain/cookbook/ingredients/tests.py b/examples/cookbook-plain/cookbook/ingredients/tests.py index 4929020..a39b155 100644 --- a/examples/cookbook-plain/cookbook/ingredients/tests.py +++ b/examples/cookbook-plain/cookbook/ingredients/tests.py @@ -1,2 +1 @@ - # Create your tests here. diff --git a/examples/cookbook-plain/cookbook/ingredients/views.py b/examples/cookbook-plain/cookbook/ingredients/views.py index b8e4ee0..60f00ef 100644 --- a/examples/cookbook-plain/cookbook/ingredients/views.py +++ b/examples/cookbook-plain/cookbook/ingredients/views.py @@ -1,2 +1 @@ - # Create your views here. diff --git a/examples/cookbook-plain/cookbook/recipes/apps.py b/examples/cookbook-plain/cookbook/recipes/apps.py index 1f24f13..f1e4dde 100644 --- a/examples/cookbook-plain/cookbook/recipes/apps.py +++ b/examples/cookbook-plain/cookbook/recipes/apps.py @@ -2,6 +2,6 @@ from django.apps import AppConfig class RecipesConfig(AppConfig): - name = 'cookbook.recipes' - label = 'recipes' - verbose_name = 'Recipes' + name = "cookbook.recipes" + label = "recipes" + verbose_name = "Recipes" diff --git a/examples/cookbook-plain/cookbook/recipes/models.py b/examples/cookbook-plain/cookbook/recipes/models.py index 382b88e..f6e955e 100644 --- a/examples/cookbook-plain/cookbook/recipes/models.py +++ b/examples/cookbook-plain/cookbook/recipes/models.py @@ -6,17 +6,23 @@ from ..ingredients.models import Ingredient class Recipe(models.Model): title = models.CharField(max_length=100) instructions = models.TextField() + def __str__(self): return self.title class RecipeIngredient(models.Model): - recipe = models.ForeignKey(Recipe, related_name='amounts', on_delete=models.CASCADE) - ingredient = models.ForeignKey(Ingredient, related_name='used_by', on_delete=models.CASCADE) + recipe = models.ForeignKey(Recipe, related_name="amounts", on_delete=models.CASCADE) + ingredient = models.ForeignKey( + Ingredient, related_name="used_by", on_delete=models.CASCADE + ) amount = models.FloatField() - unit = models.CharField(max_length=20, choices=( - ('unit', 'Units'), - ('kg', 'Kilograms'), - ('l', 'Litres'), - ('st', 'Shots'), - )) + unit = models.CharField( + max_length=20, + choices=( + ("unit", "Units"), + ("kg", "Kilograms"), + ("l", "Litres"), + ("st", "Shots"), + ), + ) diff --git a/examples/cookbook-plain/cookbook/recipes/schema.py b/examples/cookbook-plain/cookbook/recipes/schema.py index 74692f8..b029570 100644 --- a/examples/cookbook-plain/cookbook/recipes/schema.py +++ b/examples/cookbook-plain/cookbook/recipes/schema.py @@ -15,13 +15,10 @@ class RecipeIngredientType(DjangoObjectType): class Query(object): - recipe = graphene.Field(RecipeType, - id=graphene.Int(), - title=graphene.String()) + recipe = graphene.Field(RecipeType, id=graphene.Int(), title=graphene.String()) all_recipes = graphene.List(RecipeType) - recipeingredient = graphene.Field(RecipeIngredientType, - id=graphene.Int()) + recipeingredient = graphene.Field(RecipeIngredientType, id=graphene.Int()) all_recipeingredients = graphene.List(RecipeIngredientType) def resolve_recipe(self, context, id=None, title=None): @@ -43,5 +40,5 @@ class Query(object): return Recipe.objects.all() def resolve_all_recipeingredients(self, context): - related = ['recipe', 'ingredient'] + related = ["recipe", "ingredient"] return RecipeIngredient.objects.select_related(*related).all() diff --git a/examples/cookbook-plain/cookbook/recipes/tests.py b/examples/cookbook-plain/cookbook/recipes/tests.py index 4929020..a39b155 100644 --- a/examples/cookbook-plain/cookbook/recipes/tests.py +++ b/examples/cookbook-plain/cookbook/recipes/tests.py @@ -1,2 +1 @@ - # Create your tests here. diff --git a/examples/cookbook-plain/cookbook/recipes/views.py b/examples/cookbook-plain/cookbook/recipes/views.py index b8e4ee0..60f00ef 100644 --- a/examples/cookbook-plain/cookbook/recipes/views.py +++ b/examples/cookbook-plain/cookbook/recipes/views.py @@ -1,2 +1 @@ - # Create your views here. diff --git a/examples/cookbook-plain/cookbook/schema.py b/examples/cookbook-plain/cookbook/schema.py index f91d62c..bde9372 100644 --- a/examples/cookbook-plain/cookbook/schema.py +++ b/examples/cookbook-plain/cookbook/schema.py @@ -5,10 +5,12 @@ import graphene from graphene_django.debug import DjangoDebug -class Query(cookbook.ingredients.schema.Query, - cookbook.recipes.schema.Query, - graphene.ObjectType): - debug = graphene.Field(DjangoDebug, name='_debug') +class Query( + cookbook.ingredients.schema.Query, + cookbook.recipes.schema.Query, + graphene.ObjectType, +): + debug = graphene.Field(DjangoDebug, name="_debug") schema = graphene.Schema(query=Query) diff --git a/examples/cookbook-plain/cookbook/settings.py b/examples/cookbook-plain/cookbook/settings.py index bce2bab..7eb9d56 100644 --- a/examples/cookbook-plain/cookbook/settings.py +++ b/examples/cookbook-plain/cookbook/settings.py @@ -21,7 +21,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # See https://docs.djangoproject.com/en/1.9/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = '_$=$%eqxk$8ss4n7mtgarw^5$8^d5+c83!vwatr@i_81myb=e4' +SECRET_KEY = "_$=$%eqxk$8ss4n7mtgarw^5$8^d5+c83!vwatr@i_81myb=e4" # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True @@ -32,64 +32,61 @@ ALLOWED_HOSTS = [] # Application definition INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'graphene_django', - - 'cookbook.ingredients.apps.IngredientsConfig', - 'cookbook.recipes.apps.RecipesConfig', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "graphene_django", + "cookbook.ingredients.apps.IngredientsConfig", + "cookbook.recipes.apps.RecipesConfig", ] MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", ] GRAPHENE = { - 'SCHEMA': 'cookbook.schema.schema', - 'SCHEMA_INDENT': 2, - 'MIDDLEWARE': ( - 'graphene_django.debug.DjangoDebugMiddleware', - ) + "SCHEMA": "cookbook.schema.schema", + "SCHEMA_INDENT": 2, + "MIDDLEWARE": ("graphene_django.debug.DjangoDebugMiddleware",), } -ROOT_URLCONF = 'cookbook.urls' +ROOT_URLCONF = "cookbook.urls" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - ], + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ] }, - }, + } ] -WSGI_APPLICATION = 'cookbook.wsgi.application' +WSGI_APPLICATION = "cookbook.wsgi.application" # Database # https://docs.djangoproject.com/en/1.9/ref/settings/#databases DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": os.path.join(BASE_DIR, "db.sqlite3"), } } @@ -99,26 +96,20 @@ DATABASES = { AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" }, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, ] # Internationalization # https://docs.djangoproject.com/en/1.9/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" USE_I18N = True @@ -130,4 +121,4 @@ USE_TZ = True # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/1.9/howto/static-files/ -STATIC_URL = '/static/' +STATIC_URL = "/static/" diff --git a/examples/cookbook-plain/cookbook/urls.py b/examples/cookbook-plain/cookbook/urls.py index 4f87da0..a64a875 100644 --- a/examples/cookbook-plain/cookbook/urls.py +++ b/examples/cookbook-plain/cookbook/urls.py @@ -5,6 +5,6 @@ from graphene_django.views import GraphQLView urlpatterns = [ - path('admin/', admin.site.urls), - path('graphql/', GraphQLView.as_view(graphiql=True)), + path("admin/", admin.site.urls), + path("graphql/", GraphQLView.as_view(graphiql=True)), ] diff --git a/examples/cookbook/cookbook/ingredients/admin.py b/examples/cookbook/cookbook/ingredients/admin.py index b57cbc3..042682f 100644 --- a/examples/cookbook/cookbook/ingredients/admin.py +++ b/examples/cookbook/cookbook/ingredients/admin.py @@ -5,8 +5,8 @@ from cookbook.ingredients.models import Category, Ingredient @admin.register(Ingredient) class IngredientAdmin(admin.ModelAdmin): - list_display = ('id', 'name', 'category') - list_editable = ('name', 'category') + list_display = ("id", "name", "category") + list_editable = ("name", "category") admin.site.register(Category) diff --git a/examples/cookbook/cookbook/ingredients/apps.py b/examples/cookbook/cookbook/ingredients/apps.py index 21b4b08..3ad0143 100644 --- a/examples/cookbook/cookbook/ingredients/apps.py +++ b/examples/cookbook/cookbook/ingredients/apps.py @@ -2,6 +2,6 @@ from django.apps import AppConfig class IngredientsConfig(AppConfig): - name = 'cookbook.ingredients' - label = 'ingredients' - verbose_name = 'Ingredients' + name = "cookbook.ingredients" + label = "ingredients" + verbose_name = "Ingredients" diff --git a/examples/cookbook/cookbook/ingredients/models.py b/examples/cookbook/cookbook/ingredients/models.py index 2f0eba3..6426dab 100644 --- a/examples/cookbook/cookbook/ingredients/models.py +++ b/examples/cookbook/cookbook/ingredients/models.py @@ -11,7 +11,7 @@ class Category(models.Model): class Ingredient(models.Model): name = models.CharField(max_length=100) notes = models.TextField(null=True, blank=True) - category = models.ForeignKey(Category, related_name='ingredients') + category = models.ForeignKey(Category, related_name="ingredients") def __str__(self): return self.name diff --git a/examples/cookbook/cookbook/ingredients/schema.py b/examples/cookbook/cookbook/ingredients/schema.py index 5ad92e8..5e5da80 100644 --- a/examples/cookbook/cookbook/ingredients/schema.py +++ b/examples/cookbook/cookbook/ingredients/schema.py @@ -7,24 +7,22 @@ from graphene_django.types import DjangoObjectType # Graphene will automatically map the Category model's fields onto the CategoryNode. # This is configured in the CategoryNode's Meta class (as you can see below) class CategoryNode(DjangoObjectType): - class Meta: model = Category - interfaces = (Node, ) - filter_fields = ['name', 'ingredients'] + interfaces = (Node,) + filter_fields = ["name", "ingredients"] class IngredientNode(DjangoObjectType): - class Meta: model = Ingredient # Allow for some more advanced filtering here - interfaces = (Node, ) + interfaces = (Node,) filter_fields = { - 'name': ['exact', 'icontains', 'istartswith'], - 'notes': ['exact', 'icontains'], - 'category': ['exact'], - 'category__name': ['exact'], + "name": ["exact", "icontains", "istartswith"], + "notes": ["exact", "icontains"], + "category": ["exact"], + "category__name": ["exact"], } diff --git a/examples/cookbook/cookbook/ingredients/tests.py b/examples/cookbook/cookbook/ingredients/tests.py index 4929020..a39b155 100644 --- a/examples/cookbook/cookbook/ingredients/tests.py +++ b/examples/cookbook/cookbook/ingredients/tests.py @@ -1,2 +1 @@ - # Create your tests here. diff --git a/examples/cookbook/cookbook/ingredients/views.py b/examples/cookbook/cookbook/ingredients/views.py index b8e4ee0..60f00ef 100644 --- a/examples/cookbook/cookbook/ingredients/views.py +++ b/examples/cookbook/cookbook/ingredients/views.py @@ -1,2 +1 @@ - # Create your views here. diff --git a/examples/cookbook/cookbook/recipes/apps.py b/examples/cookbook/cookbook/recipes/apps.py index 1f24f13..f1e4dde 100644 --- a/examples/cookbook/cookbook/recipes/apps.py +++ b/examples/cookbook/cookbook/recipes/apps.py @@ -2,6 +2,6 @@ from django.apps import AppConfig class RecipesConfig(AppConfig): - name = 'cookbook.recipes' - label = 'recipes' - verbose_name = 'Recipes' + name = "cookbook.recipes" + label = "recipes" + verbose_name = "Recipes" diff --git a/examples/cookbook/cookbook/recipes/models.py b/examples/cookbook/cookbook/recipes/models.py index ca12fac..b98664c 100644 --- a/examples/cookbook/cookbook/recipes/models.py +++ b/examples/cookbook/cookbook/recipes/models.py @@ -10,12 +10,15 @@ class Recipe(models.Model): class RecipeIngredient(models.Model): - recipe = models.ForeignKey(Recipe, related_name='amounts') - ingredient = models.ForeignKey(Ingredient, related_name='used_by') + recipe = models.ForeignKey(Recipe, related_name="amounts") + ingredient = models.ForeignKey(Ingredient, related_name="used_by") amount = models.FloatField() - unit = models.CharField(max_length=20, choices=( - ('unit', 'Units'), - ('kg', 'Kilograms'), - ('l', 'Litres'), - ('st', 'Shots'), - )) + unit = models.CharField( + max_length=20, + choices=( + ("unit", "Units"), + ("kg", "Kilograms"), + ("l", "Litres"), + ("st", "Shots"), + ), + ) diff --git a/examples/cookbook/cookbook/recipes/schema.py b/examples/cookbook/cookbook/recipes/schema.py index 8018322..fbbedd8 100644 --- a/examples/cookbook/cookbook/recipes/schema.py +++ b/examples/cookbook/cookbook/recipes/schema.py @@ -3,24 +3,23 @@ from graphene import Node from graphene_django.filter import DjangoFilterConnectionField from graphene_django.types import DjangoObjectType -class RecipeNode(DjangoObjectType): +class RecipeNode(DjangoObjectType): class Meta: model = Recipe - interfaces = (Node, ) - filter_fields = ['title','amounts'] + interfaces = (Node,) + filter_fields = ["title", "amounts"] class RecipeIngredientNode(DjangoObjectType): - class Meta: model = RecipeIngredient # Allow for some more advanced filtering here - interfaces = (Node, ) + interfaces = (Node,) filter_fields = { - 'ingredient__name': ['exact', 'icontains', 'istartswith'], - 'recipe': ['exact'], - 'recipe__title': ['icontains'], + "ingredient__name": ["exact", "icontains", "istartswith"], + "recipe": ["exact"], + "recipe__title": ["icontains"], } diff --git a/examples/cookbook/cookbook/recipes/tests.py b/examples/cookbook/cookbook/recipes/tests.py index 4929020..a39b155 100644 --- a/examples/cookbook/cookbook/recipes/tests.py +++ b/examples/cookbook/cookbook/recipes/tests.py @@ -1,2 +1 @@ - # Create your tests here. diff --git a/examples/cookbook/cookbook/recipes/views.py b/examples/cookbook/cookbook/recipes/views.py index b8e4ee0..60f00ef 100644 --- a/examples/cookbook/cookbook/recipes/views.py +++ b/examples/cookbook/cookbook/recipes/views.py @@ -1,2 +1 @@ - # Create your views here. diff --git a/examples/cookbook/cookbook/schema.py b/examples/cookbook/cookbook/schema.py index f91d62c..bde9372 100644 --- a/examples/cookbook/cookbook/schema.py +++ b/examples/cookbook/cookbook/schema.py @@ -5,10 +5,12 @@ import graphene from graphene_django.debug import DjangoDebug -class Query(cookbook.ingredients.schema.Query, - cookbook.recipes.schema.Query, - graphene.ObjectType): - debug = graphene.Field(DjangoDebug, name='_debug') +class Query( + cookbook.ingredients.schema.Query, + cookbook.recipes.schema.Query, + graphene.ObjectType, +): + debug = graphene.Field(DjangoDebug, name="_debug") schema = graphene.Schema(query=Query) diff --git a/examples/cookbook/cookbook/settings.py b/examples/cookbook/cookbook/settings.py index 0b3207e..ed41a65 100644 --- a/examples/cookbook/cookbook/settings.py +++ b/examples/cookbook/cookbook/settings.py @@ -21,7 +21,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # See https://docs.djangoproject.com/en/1.9/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = '_$=$%eqxk$8ss4n7mtgarw^5$8^d5+c83!vwatr@i_81myb=e4' +SECRET_KEY = "_$=$%eqxk$8ss4n7mtgarw^5$8^d5+c83!vwatr@i_81myb=e4" # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True @@ -32,65 +32,62 @@ ALLOWED_HOSTS = [] # Application definition INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'graphene_django', - - 'cookbook.ingredients.apps.IngredientsConfig', - 'cookbook.recipes.apps.RecipesConfig', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "graphene_django", + "cookbook.ingredients.apps.IngredientsConfig", + "cookbook.recipes.apps.RecipesConfig", ] MIDDLEWARE_CLASSES = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.auth.middleware.SessionAuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", ] GRAPHENE = { - 'SCHEMA': 'cookbook.schema.schema', - 'SCHEMA_INDENT': 2, - 'MIDDLEWARE': ( - 'graphene_django.debug.DjangoDebugMiddleware', - ) + "SCHEMA": "cookbook.schema.schema", + "SCHEMA_INDENT": 2, + "MIDDLEWARE": ("graphene_django.debug.DjangoDebugMiddleware",), } -ROOT_URLCONF = 'cookbook.urls' +ROOT_URLCONF = "cookbook.urls" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - ], + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ] }, - }, + } ] -WSGI_APPLICATION = 'cookbook.wsgi.application' +WSGI_APPLICATION = "cookbook.wsgi.application" # Database # https://docs.djangoproject.com/en/1.9/ref/settings/#databases DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": os.path.join(BASE_DIR, "db.sqlite3"), } } @@ -100,26 +97,20 @@ DATABASES = { AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" }, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, ] # Internationalization # https://docs.djangoproject.com/en/1.9/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" USE_I18N = True @@ -131,4 +122,4 @@ USE_TZ = True # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/1.9/howto/static-files/ -STATIC_URL = '/static/' +STATIC_URL = "/static/" diff --git a/examples/cookbook/cookbook/urls.py b/examples/cookbook/cookbook/urls.py index 4bf6003..6f8a302 100644 --- a/examples/cookbook/cookbook/urls.py +++ b/examples/cookbook/cookbook/urls.py @@ -5,6 +5,6 @@ from graphene_django.views import GraphQLView urlpatterns = [ - url(r'^admin/', admin.site.urls), - url(r'^graphql$', GraphQLView.as_view(graphiql=True)), + url(r"^admin/", admin.site.urls), + url(r"^graphql$", GraphQLView.as_view(graphiql=True)), ] diff --git a/examples/starwars/data.py b/examples/starwars/data.py index 9b52006..6bdbf57 100644 --- a/examples/starwars/data.py +++ b/examples/starwars/data.py @@ -2,97 +2,50 @@ from .models import Character, Faction, Ship def initialize(): - human = Character( - name='Human' - ) + human = Character(name="Human") human.save() - droid = Character( - name='Droid' - ) + droid = Character(name="Droid") droid.save() - rebels = Faction( - id='1', - name='Alliance to Restore the Republic', - hero=human - ) + rebels = Faction(id="1", name="Alliance to Restore the Republic", hero=human) rebels.save() - empire = Faction( - id='2', - name='Galactic Empire', - hero=droid - ) + empire = Faction(id="2", name="Galactic Empire", hero=droid) empire.save() - xwing = Ship( - id='1', - name='X-Wing', - faction=rebels, - ) + xwing = Ship(id="1", name="X-Wing", faction=rebels) xwing.save() human.ship = xwing human.save() - ywing = Ship( - id='2', - name='Y-Wing', - faction=rebels, - ) + ywing = Ship(id="2", name="Y-Wing", faction=rebels) ywing.save() - awing = Ship( - id='3', - name='A-Wing', - faction=rebels, - ) + awing = Ship(id="3", name="A-Wing", faction=rebels) awing.save() # Yeah, technically it's Corellian. But it flew in the service of the rebels, # so for the purposes of this demo it's a rebel ship. - falcon = Ship( - id='4', - name='Millenium Falcon', - faction=rebels, - ) + falcon = Ship(id="4", name="Millenium Falcon", faction=rebels) falcon.save() - homeOne = Ship( - id='5', - name='Home One', - faction=rebels, - ) + homeOne = Ship(id="5", name="Home One", faction=rebels) homeOne.save() - tieFighter = Ship( - id='6', - name='TIE Fighter', - faction=empire, - ) + tieFighter = Ship(id="6", name="TIE Fighter", faction=empire) tieFighter.save() - tieInterceptor = Ship( - id='7', - name='TIE Interceptor', - faction=empire, - ) + tieInterceptor = Ship(id="7", name="TIE Interceptor", faction=empire) tieInterceptor.save() - executor = Ship( - id='8', - name='Executor', - faction=empire, - ) + executor = Ship(id="8", name="Executor", faction=empire) executor.save() def create_ship(ship_name, faction_id): - new_ship = Ship( - name=ship_name, - faction_id=faction_id - ) + new_ship = Ship(name=ship_name, faction_id=faction_id) new_ship.save() return new_ship diff --git a/examples/starwars/models.py b/examples/starwars/models.py index 45741da..03e06a2 100644 --- a/examples/starwars/models.py +++ b/examples/starwars/models.py @@ -5,7 +5,13 @@ from django.db import models class Character(models.Model): name = models.CharField(max_length=50) - ship = models.ForeignKey('Ship', on_delete=models.CASCADE, blank=True, null=True, related_name='characters') + ship = models.ForeignKey( + "Ship", + on_delete=models.CASCADE, + blank=True, + null=True, + related_name="characters", + ) def __str__(self): return self.name @@ -21,7 +27,7 @@ class Faction(models.Model): class Ship(models.Model): name = models.CharField(max_length=50) - faction = models.ForeignKey(Faction, on_delete=models.CASCADE, related_name='ships') + faction = models.ForeignKey(Faction, on_delete=models.CASCADE, related_name="ships") def __str__(self): return self.name diff --git a/examples/starwars/schema.py b/examples/starwars/schema.py index 492918e..fb22840 100644 --- a/examples/starwars/schema.py +++ b/examples/starwars/schema.py @@ -2,18 +2,16 @@ import graphene from graphene import Schema, relay, resolve_only_args from graphene_django import DjangoConnectionField, DjangoObjectType -from .data import (create_ship, get_empire, get_faction, get_rebels, get_ship, - get_ships) +from .data import create_ship, get_empire, get_faction, get_rebels, get_ship, get_ships from .models import Character as CharacterModel from .models import Faction as FactionModel from .models import Ship as ShipModel class Ship(DjangoObjectType): - class Meta: model = ShipModel - interfaces = (relay.Node, ) + interfaces = (relay.Node,) @classmethod def get_node(cls, info, id): @@ -22,16 +20,14 @@ class Ship(DjangoObjectType): class Character(DjangoObjectType): - class Meta: model = CharacterModel class Faction(DjangoObjectType): - class Meta: model = FactionModel - interfaces = (relay.Node, ) + interfaces = (relay.Node,) @classmethod def get_node(cls, info, id): @@ -39,7 +35,6 @@ class Faction(DjangoObjectType): class IntroduceShip(relay.ClientIDMutation): - class Input: ship_name = graphene.String(required=True) faction_id = graphene.String(required=True) @@ -48,7 +43,9 @@ class IntroduceShip(relay.ClientIDMutation): faction = graphene.Field(Faction) @classmethod - def mutate_and_get_payload(cls, root, info, ship_name, faction_id, client_mutation_id=None): + def mutate_and_get_payload( + cls, root, info, ship_name, faction_id, client_mutation_id=None + ): ship = create_ship(ship_name, faction_id) faction = get_faction(faction_id) return IntroduceShip(ship=ship, faction=faction) @@ -58,7 +55,7 @@ class Query(graphene.ObjectType): rebels = graphene.Field(Faction) empire = graphene.Field(Faction) node = relay.Node.Field() - ships = DjangoConnectionField(Ship, description='All the ships.') + ships = DjangoConnectionField(Ship, description="All the ships.") @resolve_only_args def resolve_ships(self): diff --git a/examples/starwars/tests/test_connections.py b/examples/starwars/tests/test_connections.py index d266df3..425dce5 100644 --- a/examples/starwars/tests/test_connections.py +++ b/examples/starwars/tests/test_connections.py @@ -8,7 +8,7 @@ pytestmark = pytest.mark.django_db def test_correct_fetch_first_ship_rebels(): initialize() - query = ''' + query = """ query RebelsShipsQuery { rebels { name, @@ -24,22 +24,12 @@ def test_correct_fetch_first_ship_rebels(): } } } - ''' + """ expected = { - 'rebels': { - 'name': 'Alliance to Restore the Republic', - 'hero': { - 'name': 'Human' - }, - 'ships': { - 'edges': [ - { - 'node': { - 'name': 'X-Wing' - } - } - ] - } + "rebels": { + "name": "Alliance to Restore the Republic", + "hero": {"name": "Human"}, + "ships": {"edges": [{"node": {"name": "X-Wing"}}]}, } } result = schema.execute(query) @@ -49,7 +39,7 @@ def test_correct_fetch_first_ship_rebels(): def test_correct_list_characters(): initialize() - query = ''' + query = """ query RebelsShipsQuery { node(id: "U2hpcDox") { ... on Ship { @@ -60,15 +50,8 @@ def test_correct_list_characters(): } } } - ''' - expected = { - 'node': { - 'name': 'X-Wing', - 'characters': [{ - 'name': 'Human' - }], - } - } + """ + expected = {"node": {"name": "X-Wing", "characters": [{"name": "Human"}]}} result = schema.execute(query) assert not result.errors assert result.data == expected diff --git a/examples/starwars/tests/test_mutation.py b/examples/starwars/tests/test_mutation.py index aa312ff..e24bf8a 100644 --- a/examples/starwars/tests/test_mutation.py +++ b/examples/starwars/tests/test_mutation.py @@ -9,7 +9,7 @@ pytestmark = pytest.mark.django_db def test_mutations(): initialize() - query = ''' + query = """ mutation MyMutation { introduceShip(input:{clientMutationId:"abc", shipName: "Peter", factionId: "1"}) { ship { @@ -29,49 +29,23 @@ def test_mutations(): } } } - ''' + """ expected = { - 'introduceShip': { - 'ship': { - 'id': 'U2hpcDo5', - 'name': 'Peter' - }, - 'faction': { - 'name': 'Alliance to Restore the Republic', - 'ships': { - 'edges': [{ - 'node': { - 'id': 'U2hpcDox', - 'name': 'X-Wing' - } - }, { - 'node': { - 'id': 'U2hpcDoy', - 'name': 'Y-Wing' - } - }, { - 'node': { - 'id': 'U2hpcDoz', - 'name': 'A-Wing' - } - }, { - 'node': { - 'id': 'U2hpcDo0', - 'name': 'Millenium Falcon' - } - }, { - 'node': { - 'id': 'U2hpcDo1', - 'name': 'Home One' - } - }, { - 'node': { - 'id': 'U2hpcDo5', - 'name': 'Peter' - } - }] + "introduceShip": { + "ship": {"id": "U2hpcDo5", "name": "Peter"}, + "faction": { + "name": "Alliance to Restore the Republic", + "ships": { + "edges": [ + {"node": {"id": "U2hpcDox", "name": "X-Wing"}}, + {"node": {"id": "U2hpcDoy", "name": "Y-Wing"}}, + {"node": {"id": "U2hpcDoz", "name": "A-Wing"}}, + {"node": {"id": "U2hpcDo0", "name": "Millenium Falcon"}}, + {"node": {"id": "U2hpcDo1", "name": "Home One"}}, + {"node": {"id": "U2hpcDo5", "name": "Peter"}}, + ] }, - } + }, } } result = schema.execute(query) diff --git a/examples/starwars/tests/test_objectidentification.py b/examples/starwars/tests/test_objectidentification.py index fad1958..6e04a7b 100644 --- a/examples/starwars/tests/test_objectidentification.py +++ b/examples/starwars/tests/test_objectidentification.py @@ -8,19 +8,16 @@ pytestmark = pytest.mark.django_db def test_correctly_fetches_id_name_rebels(): initialize() - query = ''' + query = """ query RebelsQuery { rebels { id name } } - ''' + """ expected = { - 'rebels': { - 'id': 'RmFjdGlvbjox', - 'name': 'Alliance to Restore the Republic' - } + "rebels": {"id": "RmFjdGlvbjox", "name": "Alliance to Restore the Republic"} } result = schema.execute(query) assert not result.errors @@ -29,7 +26,7 @@ def test_correctly_fetches_id_name_rebels(): def test_correctly_refetches_rebels(): initialize() - query = ''' + query = """ query RebelsRefetchQuery { node(id: "RmFjdGlvbjox") { id @@ -38,12 +35,9 @@ def test_correctly_refetches_rebels(): } } } - ''' + """ expected = { - 'node': { - 'id': 'RmFjdGlvbjox', - 'name': 'Alliance to Restore the Republic' - } + "node": {"id": "RmFjdGlvbjox", "name": "Alliance to Restore the Republic"} } result = schema.execute(query) assert not result.errors @@ -52,20 +46,15 @@ def test_correctly_refetches_rebels(): def test_correctly_fetches_id_name_empire(): initialize() - query = ''' + query = """ query EmpireQuery { empire { id name } } - ''' - expected = { - 'empire': { - 'id': 'RmFjdGlvbjoy', - 'name': 'Galactic Empire' - } - } + """ + expected = {"empire": {"id": "RmFjdGlvbjoy", "name": "Galactic Empire"}} result = schema.execute(query) assert not result.errors assert result.data == expected @@ -73,7 +62,7 @@ def test_correctly_fetches_id_name_empire(): def test_correctly_refetches_empire(): initialize() - query = ''' + query = """ query EmpireRefetchQuery { node(id: "RmFjdGlvbjoy") { id @@ -82,13 +71,8 @@ def test_correctly_refetches_empire(): } } } - ''' - expected = { - 'node': { - 'id': 'RmFjdGlvbjoy', - 'name': 'Galactic Empire' - } - } + """ + expected = {"node": {"id": "RmFjdGlvbjoy", "name": "Galactic Empire"}} result = schema.execute(query) assert not result.errors assert result.data == expected @@ -96,7 +80,7 @@ def test_correctly_refetches_empire(): def test_correctly_refetches_xwing(): initialize() - query = ''' + query = """ query XWingRefetchQuery { node(id: "U2hpcDox") { id @@ -105,13 +89,8 @@ def test_correctly_refetches_xwing(): } } } - ''' - expected = { - 'node': { - 'id': 'U2hpcDox', - 'name': 'X-Wing' - } - } + """ + expected = {"node": {"id": "U2hpcDox", "name": "X-Wing"}} result = schema.execute(query) assert not result.errors assert result.data == expected diff --git a/graphene_django/converter.py b/graphene_django/converter.py index 158355a..1bb16f4 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -177,7 +177,11 @@ 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 + 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 diff --git a/graphene_django/filter/fields.py b/graphene_django/filter/fields.py index 7c85e9a..62f4b1a 100644 --- a/graphene_django/filter/fields.py +++ b/graphene_django/filter/fields.py @@ -41,10 +41,9 @@ class DjangoFilterConnectionField(DjangoConnectionField): meta.update(self._extra_filter_meta) filterset_class = self._provided_filterset_class or ( - self.node_type._meta.filterset_class) - self._filterset_class = get_filterset_class( - filterset_class, **meta + self.node_type._meta.filterset_class ) + self._filterset_class = get_filterset_class(filterset_class, **meta) return self._filterset_class diff --git a/graphene_django/filter/tests/test_fields.py b/graphene_django/filter/tests/test_fields.py index eb6581b..4d8d597 100644 --- a/graphene_django/filter/tests/test_fields.py +++ b/graphene_django/filter/tests/test_fields.py @@ -229,6 +229,7 @@ def test_filter_filterset_information_on_meta_related(): def test_filter_filterset_class_filter_fields_exception(): with pytest.raises(Exception): + class ReporterFilter(FilterSet): class Meta: model = Reporter diff --git a/graphene_django/rest_framework/tests/test_mutation.py b/graphene_django/rest_framework/tests/test_mutation.py index a0c861d..9621ee3 100644 --- a/graphene_django/rest_framework/tests/test_mutation.py +++ b/graphene_django/rest_framework/tests/test_mutation.py @@ -104,7 +104,9 @@ def test_write_only_field(): ) assert hasattr(result, "cool_name") - assert not hasattr(result, "password"), "'password' is write_only field and shouldn't be visible" + assert not hasattr( + result, "password" + ), "'password' is write_only field and shouldn't be visible" @mark.django_db @@ -124,7 +126,9 @@ def test_write_only_field_using_extra_kwargs(): ) assert hasattr(result, "cool_name") - assert not hasattr(result, "password"), "'password' is write_only field and shouldn't be visible" + assert not hasattr( + result, "password" + ), "'password' is write_only field and shouldn't be visible" def test_nested_model(): diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py index 9ef217e..484a225 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -1015,13 +1015,13 @@ def test_proxy_model_support(): "edges": [ {"node": {"id": to_global_id("CNNReporterType", cnn_reporter.id)}} ] - } + }, } result = schema.execute(query) assert not result.errors assert result.data == expected - + def test_should_resolve_get_queryset_connectionfields(): reporter_1 = Reporter.objects.create( diff --git a/graphene_django/types.py b/graphene_django/types.py index ded8a15..a1e17b3 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -82,10 +82,12 @@ class DjangoObjectType(ObjectType): 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" - )) + 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 diff --git a/tox.ini b/tox.ini index 8e21c74..58f283a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,31 +1,39 @@ [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 +envlist = + py{27,35,36,37}-django{111,20,21,22,master}, + black,flake8 + +[travis:env] +DJANGO = + 1.11: django111 + 2.0: django20 + 2.1: django21 + 2.2: django22 + master: djangomaster [testenv] passenv = * usedevelop = True -setenv = +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 + django111: Django>=1.11,<2.0 + django20: Django>=2.0,<2.1 + django21: Django>=2.1,<2.2 + django22: Django>=2.2,<3.0 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 +[testenv:black] +basepython = python3.7 +deps = black +commands = + black --exclude "/migrations/" graphene_django examples --check + +[testenv:flake8] +basepython = python3.7 +deps = flake8 +commands = + flake8 graphene_django examples From 6e8dce95ae184cc3f8c3c9202ba92f8fd92c09d1 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Fri, 14 Jun 2019 12:33:37 +0100 Subject: [PATCH 074/171] Update doc setup (#673) * Expose doc commands in root makefile and add autobuild * Fix some errors * Alias some commands and add PHONY --- Makefile | 18 ++++++++++++++++++ docs/Makefile | 8 ++++++++ docs/_static/.gitkeep | 0 docs/authorization.rst | 3 ++- docs/requirements.txt | 3 ++- docs/settings.rst | 10 +++++----- 6 files changed, 35 insertions(+), 7 deletions(-) create mode 100644 docs/_static/.gitkeep diff --git a/Makefile b/Makefile index 70badcb..39a0f31 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,29 @@ +.PHONY: dev-setup ## Install development dependencies dev-setup: pip install -e ".[dev]" +.PHONY: install-dev +install-dev: dev-setup # Alias install-dev -> dev-setup + +.PHONY: tests tests: py.test graphene_django --cov=graphene_django -vv +.PHONY: test +test: tests # Alias test -> tests + +.PHONY: format format: black --exclude "/migrations/" graphene_django examples +.PHONY: lint lint: flake8 graphene_django examples + +.PHONY: docs ## Generate docs +docs: dev-setup + cd docs && make install && make html + +.PHONY: docs-live ## Generate docs with live reloading +docs-live: dev-setup + cd docs && make install && make livehtml diff --git a/docs/Makefile b/docs/Makefile index 7da67c3..4ae2962 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -48,12 +48,20 @@ help: clean: rm -rf $(BUILDDIR)/* +.PHONY: install ## to install all documentation related requirements +install: + pip install -r requirements.txt + .PHONY: html html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." +.PHONY: livehtml ## to build and serve live-reloading documentation +livehtml: + sphinx-autobuild -b html --watch ../graphene_django $(ALLSPHINXOPTS) $(BUILDDIR)/html + .PHONY: dirhtml dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml diff --git a/docs/_static/.gitkeep b/docs/_static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/authorization.rst b/docs/authorization.rst index 3d0bb8a..2c38fa4 100644 --- a/docs/authorization.rst +++ b/docs/authorization.rst @@ -154,7 +154,8 @@ Adding Login Required To restrict users from accessing the GraphQL API page the standard Django LoginRequiredMixin_ can be used to create your own standard Django Class Based View, which includes the ``LoginRequiredMixin`` and subclasses the ``GraphQLView``.: .. code:: python - #views.py + + # views.py from django.contrib.auth.mixins import LoginRequiredMixin from graphene_django.views import GraphQLView diff --git a/docs/requirements.txt b/docs/requirements.txt index 220b7cf..7c89926 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,3 +1,4 @@ -sphinx +Sphinx==1.5.3 +sphinx-autobuild==0.7.1 # Docs template http://graphene-python.org/sphinx_graphene_theme.zip diff --git a/docs/settings.rst b/docs/settings.rst index 547e77f..4d37a99 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -30,7 +30,7 @@ Default: ``None`` ``SCHEMA_OUTPUT`` ----------- +----------------- The name of the file where the GraphQL schema output will go. @@ -44,7 +44,7 @@ Default: ``schema.json`` ``SCHEMA_INDENT`` ----------- +----------------- The indentation level of the schema output. @@ -58,7 +58,7 @@ Default: ``2`` ``MIDDLEWARE`` ----------- +-------------- A tuple of middleware that will be executed for each GraphQL query. @@ -76,7 +76,7 @@ Default: ``()`` ``RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST`` ----------- +------------------------------------------ Enforces relay queries to have the ``first`` or ``last`` argument. @@ -90,7 +90,7 @@ Default: ``False`` ``RELAY_CONNECTION_MAX_LIMIT`` ----------- +------------------------------ The maximum size of objects that can be requested through a relay connection. From 6169346776854c055f6349509e4e02d64b00863e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" Date: Mon, 17 Jun 2019 17:08:51 +0100 Subject: [PATCH 075/171] Bump django from 1.11.20 to 1.11.21 in /examples/cookbook (#670) Bumps [django](https://github.com/django/django) from 1.11.20 to 1.11.21. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/1.11.20...1.11.21) 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 fe0527a..9d13a82 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.20 +django==1.11.21 django-filter>=2 From 894b1053a2bb40e7f52601f70d65e3ebd7c51fe5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" Date: Mon, 17 Jun 2019 18:48:15 +0100 Subject: [PATCH 076/171] Bump django from 2.1.6 to 2.1.9 in /examples/cookbook-plain (#669) Bumps [django](https://github.com/django/django) from 2.1.6 to 2.1.9. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/2.1.6...2.1.9) 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 2154fd8..ea1f4ba 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.1.6 +django==2.1.9 From 612ba5a4eaea0336a5dffcba3dbe7909b9d94646 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Mon, 17 Jun 2019 18:48:29 +0100 Subject: [PATCH 077/171] Add `convert_choices_to_enum` option on DjangoObjectType Meta class (#674) * Add convert_choices_to_enum meta option * Add tests * Run black * Update documentation * Add link to Django choices documentation * Add test and documentation note That setting to an empty list is the same as setting the value as False * Fix Django warning in tests * rst is not markdown --- docs/queries.rst | 65 ++++++++++++++ graphene_django/converter.py | 6 +- graphene_django/tests/test_converter.py | 17 ++++ graphene_django/tests/test_types.py | 115 +++++++++++++++++++++++- graphene_django/types.py | 23 ++++- 5 files changed, 220 insertions(+), 6 deletions(-) diff --git a/docs/queries.rst b/docs/queries.rst index 0edd1dd..7aff572 100644 --- a/docs/queries.rst +++ b/docs/queries.rst @@ -92,6 +92,71 @@ You can completely overwrite a field, or add new fields, to a ``DjangoObjectType return 'hello!' +Choices to Enum conversion +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +By default Graphene-Django will convert any Django fields that have `choices`_ +defined into a GraphQL enum type. + +.. _choices: https://docs.djangoproject.com/en/2.2/ref/models/fields/#choices + +For example the following ``Model`` and ``DjangoObjectType``: + +.. code:: python + + class PetModel(models.Model): + kind = models.CharField(max_length=100, choices=(('cat', 'Cat'), ('dog', 'Dog'))) + + class Pet(DjangoObjectType): + class Meta: + model = PetModel + +Results in the following GraphQL schema definition: + +.. code:: + + type Pet { + id: ID! + kind: PetModelKind! + } + + enum PetModelKind { + CAT + DOG + } + +You can disable this automatic conversion by setting +``convert_choices_to_enum`` attribute to ``False`` on the ``DjangoObjectType`` +``Meta`` class. + +.. code:: python + + class Pet(DjangoObjectType): + class Meta: + model = PetModel + convert_choices_to_enum = False + +.. code:: + + type Pet { + id: ID! + kind: String! + } + +You can also set ``convert_choices_to_enum`` to a list of fields that should be +automatically converted into enums: + +.. code:: python + + class Pet(DjangoObjectType): + class Meta: + model = PetModel + convert_choices_to_enum = ['kind'] + +**Note:** Setting ``convert_choices_to_enum = []`` is the same as setting it to +``False``. + + Related models -------------- diff --git a/graphene_django/converter.py b/graphene_django/converter.py index 1bb16f4..4d0b45f 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -52,13 +52,15 @@ def get_choices(choices): yield name, value, description -def convert_django_field_with_choices(field, registry=None): +def convert_django_field_with_choices( + field, registry=None, convert_choices_to_enum=True +): if registry is not None: converted = registry.get_converted_field(field) if converted: return converted choices = getattr(field, "choices", None) - if choices: + if choices and convert_choices_to_enum: meta = field.model._meta name = to_camel_case("{}_{}".format(meta.object_name, field.name)) choices = list(get_choices(choices)) diff --git a/graphene_django/tests/test_converter.py b/graphene_django/tests/test_converter.py index bb176b3..5542c90 100644 --- a/graphene_django/tests/test_converter.py +++ b/graphene_django/tests/test_converter.py @@ -196,6 +196,23 @@ def test_field_with_choices_collision(): convert_django_field_with_choices(field) +def test_field_with_choices_convert_enum_false(): + field = models.CharField( + help_text="Language", choices=(("es", "Spanish"), ("en", "English")) + ) + + class TranslatedModel(models.Model): + language = field + + class Meta: + app_label = "test" + + graphene_type = convert_django_field_with_choices( + field, convert_choices_to_enum=False + ) + assert isinstance(graphene_type, graphene.String) + + def test_should_float_convert_float(): assert_conversion(models.FloatField, graphene.Float) diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py index 8a8643b..c1ac6c2 100644 --- a/graphene_django/tests/test_types.py +++ b/graphene_django/tests/test_types.py @@ -1,6 +1,11 @@ +from collections import OrderedDict, defaultdict +from textwrap import dedent + +import pytest +from django.db import models from mock import patch -from graphene import Interface, ObjectType, Schema, Connection, String +from graphene import Connection, Field, Interface, ObjectType, Schema, String from graphene.relay import Node from .. import registry @@ -224,3 +229,111 @@ def test_django_objecttype_exclude_fields(): fields = list(Reporter._meta.fields.keys()) assert "email" not in fields + + +class TestDjangoObjectType: + @pytest.fixture + def PetModel(self): + class PetModel(models.Model): + kind = models.CharField(choices=(("cat", "Cat"), ("dog", "Dog"))) + cuteness = models.IntegerField( + choices=((1, "Kind of cute"), (2, "Pretty cute"), (3, "OMG SO CUTE!!!")) + ) + + yield PetModel + + # Clear Django model cache so we don't get warnings when creating the + # model multiple times + PetModel._meta.apps.all_models = defaultdict(OrderedDict) + + def test_django_objecttype_convert_choices_enum_false(self, PetModel): + class Pet(DjangoObjectType): + class Meta: + model = PetModel + convert_choices_to_enum = False + + class Query(ObjectType): + pet = Field(Pet) + + schema = Schema(query=Query) + + assert str(schema) == dedent( + """\ + schema { + query: Query + } + + type Pet { + id: ID! + kind: String! + cuteness: Int! + } + + type Query { + pet: Pet + } + """ + ) + + def test_django_objecttype_convert_choices_enum_list(self, PetModel): + class Pet(DjangoObjectType): + class Meta: + model = PetModel + convert_choices_to_enum = ["kind"] + + class Query(ObjectType): + pet = Field(Pet) + + schema = Schema(query=Query) + + assert str(schema) == dedent( + """\ + schema { + query: Query + } + + type Pet { + id: ID! + kind: PetModelKind! + cuteness: Int! + } + + enum PetModelKind { + CAT + DOG + } + + type Query { + pet: Pet + } + """ + ) + + def test_django_objecttype_convert_choices_enum_empty_list(self, PetModel): + class Pet(DjangoObjectType): + class Meta: + model = PetModel + convert_choices_to_enum = [] + + class Query(ObjectType): + pet = Field(Pet) + + schema = Schema(query=Query) + + assert str(schema) == dedent( + """\ + schema { + query: Query + } + + type Pet { + id: ID! + kind: String! + cuteness: Int! + } + + type Query { + pet: Pet + } + """ + ) diff --git a/graphene_django/types.py b/graphene_django/types.py index a1e17b3..005300d 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -18,7 +18,9 @@ if six.PY3: from typing import Type -def construct_fields(model, registry, only_fields, exclude_fields): +def construct_fields( + model, registry, only_fields, exclude_fields, convert_choices_to_enum +): _model_fields = get_model_fields(model) fields = OrderedDict() @@ -33,7 +35,18 @@ def construct_fields(model, registry, only_fields, exclude_fields): # in there. Or when we exclude this field in exclude_fields. # Or when there is no back reference. continue - converted = convert_django_field_with_choices(field, registry) + + _convert_choices_to_enum = convert_choices_to_enum + if not isinstance(_convert_choices_to_enum, bool): + # then `convert_choices_to_enum` is a list of field names to convert + if name in _convert_choices_to_enum: + _convert_choices_to_enum = True + else: + _convert_choices_to_enum = False + + converted = convert_django_field_with_choices( + field, registry, convert_choices_to_enum=_convert_choices_to_enum + ) fields[name] = converted return fields @@ -63,6 +76,7 @@ class DjangoObjectType(ObjectType): connection_class=None, use_connection=None, interfaces=(), + convert_choices_to_enum=True, _meta=None, **options ): @@ -90,7 +104,10 @@ class DjangoObjectType(ObjectType): ) django_fields = yank_fields_from_attrs( - construct_fields(model, registry, only_fields, exclude_fields), _as=Field + construct_fields( + model, registry, only_fields, exclude_fields, convert_choices_to_enum + ), + _as=Field, ) if use_connection is None and interfaces: From 91c1278d1a25e35c08c47d24e6ac39ecc0ab78e2 Mon Sep 17 00:00:00 2001 From: Semyon Pupkov Date: Wed, 19 Jun 2019 15:59:19 +0500 Subject: [PATCH 078/171] Make cookbook example working on django 2 (#680) --- examples/cookbook/cookbook/ingredients/models.py | 4 +++- examples/cookbook/cookbook/recipes/models.py | 6 ++++-- examples/cookbook/cookbook/settings.py | 3 +-- examples/cookbook/requirements.txt | 2 +- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/examples/cookbook/cookbook/ingredients/models.py b/examples/cookbook/cookbook/ingredients/models.py index 6426dab..1e97226 100644 --- a/examples/cookbook/cookbook/ingredients/models.py +++ b/examples/cookbook/cookbook/ingredients/models.py @@ -11,7 +11,9 @@ class Category(models.Model): class Ingredient(models.Model): name = models.CharField(max_length=100) notes = models.TextField(null=True, blank=True) - category = models.ForeignKey(Category, related_name="ingredients") + category = models.ForeignKey( + Category, related_name="ingredients", on_delete=models.CASCADE + ) def __str__(self): return self.name diff --git a/examples/cookbook/cookbook/recipes/models.py b/examples/cookbook/cookbook/recipes/models.py index b98664c..0bfb434 100644 --- a/examples/cookbook/cookbook/recipes/models.py +++ b/examples/cookbook/cookbook/recipes/models.py @@ -10,8 +10,10 @@ class Recipe(models.Model): class RecipeIngredient(models.Model): - recipe = models.ForeignKey(Recipe, related_name="amounts") - ingredient = models.ForeignKey(Ingredient, related_name="used_by") + recipe = models.ForeignKey(Recipe, related_name="amounts", on_delete=models.CASCADE) + ingredient = models.ForeignKey( + Ingredient, related_name="used_by", on_delete=models.CASCADE + ) amount = models.FloatField() unit = models.CharField( max_length=20, diff --git a/examples/cookbook/cookbook/settings.py b/examples/cookbook/cookbook/settings.py index ed41a65..7eb9d56 100644 --- a/examples/cookbook/cookbook/settings.py +++ b/examples/cookbook/cookbook/settings.py @@ -43,13 +43,12 @@ INSTALLED_APPS = [ "cookbook.recipes.apps.RecipesConfig", ] -MIDDLEWARE_CLASSES = [ +MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", - "django.contrib.auth.middleware.SessionAuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", ] diff --git a/examples/cookbook/requirements.txt b/examples/cookbook/requirements.txt index 9d13a82..ccece5c 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.21 +django==2.2.2 django-filter>=2 From 692540cc782e52364f01c14523bcd551dff6cd3e Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Mon, 24 Jun 2019 18:55:44 +0100 Subject: [PATCH 079/171] Update flake8 (#688) * Include setup.py in black formatting * Add new flake8 plugins and update errors to look for * Fix duplicate test name * Don't use mutable data structure * Install all dev dependencies for flake8 and black tox envs --- Makefile | 2 +- graphene_django/filter/tests/test_fields.py | 4 ++- graphene_django/rest_framework/mutation.py | 2 +- setup.cfg | 27 ++++++++++++++++++++- setup.py | 8 +++++- tox.ini | 6 ++--- 6 files changed, 41 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index 39a0f31..b850ae8 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ test: tests # Alias test -> tests .PHONY: format format: - black --exclude "/migrations/" graphene_django examples + black --exclude "/migrations/" graphene_django examples setup.py .PHONY: lint lint: diff --git a/graphene_django/filter/tests/test_fields.py b/graphene_django/filter/tests/test_fields.py index 4d8d597..b9bc599 100644 --- a/graphene_django/filter/tests/test_fields.py +++ b/graphene_django/filter/tests/test_fields.py @@ -321,12 +321,14 @@ def test_filter_filterset_related_results(): pub_date=datetime.now(), pub_date_time=datetime.now(), reporter=r1, + editor=r1, ) Article.objects.create( headline="a2", pub_date=datetime.now(), pub_date_time=datetime.now(), reporter=r2, + editor=r2, ) query = """ @@ -450,7 +452,7 @@ def test_global_id_multiple_field_explicit_reverse(): assert multiple_filter.field_class == GlobalIDMultipleChoiceField -def test_filter_filterset_related_results(): +def test_filter_filterset_related_results_with_filter(): class ReporterFilterNode(DjangoObjectType): class Meta: model = Reporter diff --git a/graphene_django/rest_framework/mutation.py b/graphene_django/rest_framework/mutation.py index 0fe9a02..b5e7160 100644 --- a/graphene_django/rest_framework/mutation.py +++ b/graphene_django/rest_framework/mutation.py @@ -52,7 +52,7 @@ class SerializerMutation(ClientIDMutation): lookup_field=None, serializer_class=None, model_class=None, - model_operations=["create", "update"], + model_operations=("create", "update"), only_fields=(), exclude_fields=(), **options diff --git a/setup.cfg b/setup.cfg index 546ad67..7d93d3e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -5,8 +5,33 @@ test=pytest universal=1 [flake8] -exclude = setup.py,docs/*,examples/*,tests,graphene_django/debug/sql/* +exclude = docs,graphene_django/debug/sql/*,migrations max-line-length = 120 +select = + # Dictionary key repeated + F601, + # Ensure use of ==/!= to compare with str, bytes and int literals + F632, + # Redefinition of unused name + F811, + # Using an undefined variable + F821, + # Defining an undefined variable in __all__ + F822, + # Using a variable before it is assigned + F823, + # Duplicate argument in function declaration + F831, + # Black would format this line + BLK, + # Do not use bare except + B001, + # Don't allow ++n. You probably meant n += 1 + B002, + # Do not use mutable structures for argument defaults + B006, + # Do not perform calls in argument defaults + B008 [coverage:run] omit = */tests/* diff --git a/setup.py b/setup.py index e622a71..bc7dcd3 100644 --- a/setup.py +++ b/setup.py @@ -28,6 +28,8 @@ tests_require = [ dev_requires = [ "black==19.3b0", "flake8==3.7.7", + "flake8-black==0.1.0", + "flake8-bugbear==19.3.0", ] + tests_require setup( @@ -64,7 +66,11 @@ setup( setup_requires=["pytest-runner"], tests_require=tests_require, rest_framework_require=rest_framework_require, - extras_require={"test": tests_require, "rest_framework": rest_framework_require, "dev": dev_requires}, + extras_require={ + "test": tests_require, + "rest_framework": rest_framework_require, + "dev": dev_requires, + }, include_package_data=True, zip_safe=False, platforms="any", diff --git a/tox.ini b/tox.ini index 58f283a..a1b599a 100644 --- a/tox.ini +++ b/tox.ini @@ -28,12 +28,12 @@ commands = {posargs:py.test --cov=graphene_django graphene_django examples} [testenv:black] basepython = python3.7 -deps = black +deps = -e.[dev] commands = - black --exclude "/migrations/" graphene_django examples --check + black --exclude "/migrations/" graphene_django examples setup.py --check [testenv:flake8] basepython = python3.7 -deps = flake8 +deps = -e.[dev] commands = flake8 graphene_django examples From e2e496f505bad4d45a1616baa176a53732766bd1 Mon Sep 17 00:00:00 2001 From: Konstantin Alekseev Date: Tue, 25 Jun 2019 11:40:29 +0300 Subject: [PATCH 080/171] Apply camel case converter to field names in DRF errors (#514) * Apply camel case converter to field names in DRF errors * Implement recursive error camelize, add setting. --- graphene_django/forms/mutation.py | 7 ++--- graphene_django/forms/tests/test_mutation.py | 20 +++++++++++++- graphene_django/rest_framework/mutation.py | 9 +++---- .../rest_framework/tests/test_mutation.py | 14 +++++++--- graphene_django/settings.py | 1 + graphene_django/tests/test_utils.py | 22 +++++++++++++++- graphene_django/types.py | 21 ++++++++++++--- graphene_django/utils/__init__.py | 10 ++++--- graphene_django/utils/utils.py | 26 +++++++++++++++++++ 9 files changed, 107 insertions(+), 23 deletions(-) diff --git a/graphene_django/forms/mutation.py b/graphene_django/forms/mutation.py index 0851a75..f5921e8 100644 --- a/graphene_django/forms/mutation.py +++ b/graphene_django/forms/mutation.py @@ -13,8 +13,8 @@ from graphene.types.mutation import MutationOptions from graphene.types.utils import yank_fields_from_attrs from graphene_django.registry import get_global_registry -from .converter import convert_form_field from ..types import ErrorType +from .converter import convert_form_field def fields_for_form(form, only_fields, exclude_fields): @@ -45,10 +45,7 @@ class BaseDjangoFormMutation(ClientIDMutation): if form.is_valid(): return cls.perform_mutate(form, info) else: - errors = [ - ErrorType(field=key, messages=value) - for key, value in form.errors.items() - ] + errors = ErrorType.from_errors(form.errors) return cls(errors=errors) diff --git a/graphene_django/forms/tests/test_mutation.py b/graphene_django/forms/tests/test_mutation.py index 543e89e..4c46702 100644 --- a/graphene_django/forms/tests/test_mutation.py +++ b/graphene_django/forms/tests/test_mutation.py @@ -2,7 +2,9 @@ from django import forms from django.test import TestCase from py.test import raises -from graphene_django.tests.models import Pet, Film, FilmDetails +from graphene_django.tests.models import Film, FilmDetails, Pet + +from ...settings import graphene_settings from ..mutation import DjangoFormMutation, DjangoModelFormMutation @@ -41,6 +43,22 @@ def test_has_input_fields(): assert "text" in MyMutation.Input._meta.fields +def test_mutation_error_camelcased(): + class ExtraPetForm(PetForm): + test_field = forms.CharField(required=True) + + class PetMutation(DjangoModelFormMutation): + class Meta: + form_class = ExtraPetForm + + result = PetMutation.mutate_and_get_payload(None, None) + assert {f.field for f in result.errors} == {"name", "age", "test_field"} + graphene_settings.DJANGO_GRAPHENE_CAMELCASE_ERRORS = True + result = PetMutation.mutate_and_get_payload(None, None) + assert {f.field for f in result.errors} == {"name", "age", "testField"} + graphene_settings.DJANGO_GRAPHENE_CAMELCASE_ERRORS = False + + class ModelFormMutationTests(TestCase): def test_default_meta_fields(self): class PetMutation(DjangoModelFormMutation): diff --git a/graphene_django/rest_framework/mutation.py b/graphene_django/rest_framework/mutation.py index b5e7160..d9c695e 100644 --- a/graphene_django/rest_framework/mutation.py +++ b/graphene_django/rest_framework/mutation.py @@ -3,13 +3,13 @@ from collections import OrderedDict from django.shortcuts import get_object_or_404 import graphene +from graphene.relay.mutation import ClientIDMutation from graphene.types import Field, InputField from graphene.types.mutation import MutationOptions -from graphene.relay.mutation import ClientIDMutation from graphene.types.objecttype import yank_fields_from_attrs -from .serializer_converter import convert_serializer_field from ..types import ErrorType +from .serializer_converter import convert_serializer_field class SerializerMutationOptions(MutationOptions): @@ -127,10 +127,7 @@ class SerializerMutation(ClientIDMutation): if serializer.is_valid(): return cls.perform_mutate(serializer, info) else: - errors = [ - ErrorType(field=key, messages=value) - for key, value in serializer.errors.items() - ] + errors = ErrorType.from_errors(serializer.errors) return cls(errors=errors) diff --git a/graphene_django/rest_framework/tests/test_mutation.py b/graphene_django/rest_framework/tests/test_mutation.py index 9621ee3..0dd5ad3 100644 --- a/graphene_django/rest_framework/tests/test_mutation.py +++ b/graphene_django/rest_framework/tests/test_mutation.py @@ -1,11 +1,12 @@ import datetime +from py.test import mark, raises +from rest_framework import serializers + from graphene import Field, ResolveInfo from graphene.types.inputobjecttype import InputObjectType -from py.test import raises -from py.test import mark -from rest_framework import serializers +from ...settings import graphene_settings from ...types import DjangoObjectType from ..models import MyFakeModel, MyFakeModelWithPassword from ..mutation import SerializerMutation @@ -213,6 +214,13 @@ def test_model_mutate_and_get_payload_error(): assert len(result.errors) > 0 +def test_mutation_error_camelcased(): + graphene_settings.DJANGO_GRAPHENE_CAMELCASE_ERRORS = True + result = MyModelMutation.mutate_and_get_payload(None, mock_info(), **{}) + assert result.errors[0].field == "coolName" + graphene_settings.DJANGO_GRAPHENE_CAMELCASE_ERRORS = False + + def test_invalid_serializer_operations(): with raises(Exception) as exc: diff --git a/graphene_django/settings.py b/graphene_django/settings.py index e5fad78..1b49dfb 100644 --- a/graphene_django/settings.py +++ b/graphene_django/settings.py @@ -35,6 +35,7 @@ DEFAULTS = { "RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST": False, # Max items returned in ConnectionFields / FilterConnectionFields "RELAY_CONNECTION_MAX_LIMIT": 100, + "DJANGO_GRAPHENE_CAMELCASE_ERRORS": False, } if settings.DEBUG: diff --git a/graphene_django/tests/test_utils.py b/graphene_django/tests/test_utils.py index becd031..55cfd4f 100644 --- a/graphene_django/tests/test_utils.py +++ b/graphene_django/tests/test_utils.py @@ -1,4 +1,6 @@ -from ..utils import get_model_fields +from django.utils.translation import gettext_lazy + +from ..utils import camelize, get_model_fields from .models import Film, Reporter @@ -10,3 +12,21 @@ def test_get_model_fields_no_duplication(): film_fields = get_model_fields(Film) film_name_set = set([field[0] for field in film_fields]) assert len(film_fields) == len(film_name_set) + + +def test_camelize(): + assert camelize({}) == {} + assert camelize("value_a") == "value_a" + assert camelize({"value_a": "value_b"}) == {"valueA": "value_b"} + assert camelize({"value_a": ["value_b"]}) == {"valueA": ["value_b"]} + assert camelize({"value_a": ["value_b"]}) == {"valueA": ["value_b"]} + assert camelize({"nested_field": {"value_a": ["error"], "value_b": ["error"]}}) == { + "nestedField": {"valueA": ["error"], "valueB": ["error"]} + } + assert camelize({"value_a": gettext_lazy("value_b")}) == {"valueA": "value_b"} + assert camelize({"value_a": [gettext_lazy("value_b")]}) == {"valueA": ["value_b"]} + assert camelize(gettext_lazy("value_a")) == "value_a" + assert camelize({gettext_lazy("value_a"): gettext_lazy("value_b")}) == { + "valueA": "value_b" + } + assert camelize({0: {"field_a": ["errors"]}}) == {0: {"fieldA": ["errors"]}} diff --git a/graphene_django/types.py b/graphene_django/types.py index 005300d..c296707 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -1,8 +1,9 @@ -import six from collections import OrderedDict +import six from django.db.models import Model from django.utils.functional import SimpleLazyObject + import graphene from graphene import Field from graphene.relay import Connection, Node @@ -11,8 +12,13 @@ from graphene.types.utils import yank_fields_from_attrs from .converter import convert_django_field_with_choices from .registry import Registry, get_global_registry -from .utils import DJANGO_FILTER_INSTALLED, get_model_fields, is_valid_django_model - +from .settings import graphene_settings +from .utils import ( + DJANGO_FILTER_INSTALLED, + camelize, + get_model_fields, + is_valid_django_model, +) if six.PY3: from typing import Type @@ -182,3 +188,12 @@ class DjangoObjectType(ObjectType): class ErrorType(ObjectType): field = graphene.String(required=True) messages = graphene.List(graphene.NonNull(graphene.String), required=True) + + @classmethod + def from_errors(cls, errors): + data = ( + camelize(errors) + if graphene_settings.DJANGO_GRAPHENE_CAMELCASE_ERRORS + else errors + ) + return [ErrorType(field=key, messages=value) for key, value in data.items()] diff --git a/graphene_django/utils/__init__.py b/graphene_django/utils/__init__.py index f9c388d..9d8658b 100644 --- a/graphene_django/utils/__init__.py +++ b/graphene_django/utils/__init__.py @@ -1,18 +1,20 @@ +from .testing import GraphQLTestCase from .utils import ( DJANGO_FILTER_INSTALLED, - get_reverse_fields, - maybe_queryset, + camelize, get_model_fields, - is_valid_django_model, + get_reverse_fields, import_single_dispatch, + is_valid_django_model, + maybe_queryset, ) -from .testing import GraphQLTestCase __all__ = [ "DJANGO_FILTER_INSTALLED", "get_reverse_fields", "maybe_queryset", "get_model_fields", + "camelize", "is_valid_django_model", "import_single_dispatch", "GraphQLTestCase", diff --git a/graphene_django/utils/utils.py b/graphene_django/utils/utils.py index b8aaba0..47c0c37 100644 --- a/graphene_django/utils/utils.py +++ b/graphene_django/utils/utils.py @@ -2,7 +2,11 @@ import inspect 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 +from graphene.utils.str_converters import to_camel_case try: import django_filters # noqa @@ -12,6 +16,28 @@ except ImportError: DJANGO_FILTER_INSTALLED = False +def isiterable(value): + try: + iter(value) + except TypeError: + return False + return True + + +def _camelize_django_str(s): + if isinstance(s, Promise): + s = force_text(s) + return to_camel_case(s) if isinstance(s, six.string_types) else s + + +def camelize(data): + if isinstance(data, dict): + return {_camelize_django_str(k): camelize(v) for k, v in data.items()} + if isiterable(data) and not isinstance(data, (six.string_types, Promise)): + return [camelize(d) for d in data] + return data + + def get_reverse_fields(model, local_field_names): for name, attr in model.__dict__.items(): # Don't duplicate any local fields From 54cc6a4b13c18b8efebccaacd8ac8df93bf56949 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Tue, 25 Jun 2019 16:30:30 +0100 Subject: [PATCH 081/171] Enforce NonNull for returned related Sets and their content (#690) * Enforce NonNull for returned related Sets and their content. https://github.com/graphql-python/graphene-django/issues/448 * Run format. * Remove duplicate assertion --- graphene_django/converter.py | 6 +++++- graphene_django/fields.py | 3 ++- graphene_django/tests/test_converter.py | 16 ++++++++++++---- graphene_django/tests/test_types.py | 2 +- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/graphene_django/converter.py b/graphene_django/converter.py index 4d0b45f..64bf341 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -198,7 +198,11 @@ def convert_field_to_list_or_connection(field, registry=None): return DjangoConnectionField(_type, description=description) - return DjangoListField(_type, description=description) + return DjangoListField( + _type, + required=True, # A Set is always returned, never None. + description=description, + ) return Dynamic(dynamic_type) diff --git a/graphene_django/fields.py b/graphene_django/fields.py index 791e785..8c8fa2b 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -15,7 +15,8 @@ from .utils import maybe_queryset class DjangoListField(Field): def __init__(self, _type, *args, **kwargs): - super(DjangoListField, self).__init__(List(_type), *args, **kwargs) + # Django would never return a Set of None vvvvvvv + super(DjangoListField, self).__init__(List(NonNull(_type)), *args, **kwargs) @property def model(self): diff --git a/graphene_django/tests/test_converter.py b/graphene_django/tests/test_converter.py index 5542c90..00467b4 100644 --- a/graphene_django/tests/test_converter.py +++ b/graphene_django/tests/test_converter.py @@ -1,6 +1,7 @@ import pytest from django.db import models from django.utils.translation import ugettext_lazy as _ +from graphene import NonNull from py.test import raises import graphene @@ -234,8 +235,12 @@ def test_should_manytomany_convert_connectionorlist_list(): assert isinstance(graphene_field, graphene.Dynamic) dynamic_field = graphene_field.get_type() assert isinstance(dynamic_field, graphene.Field) - assert isinstance(dynamic_field.type, graphene.List) - assert dynamic_field.type.of_type == A + # A NonNull List of NonNull A ([A!]!) + # https://github.com/graphql-python/graphene-django/issues/448 + assert isinstance(dynamic_field.type, NonNull) + assert isinstance(dynamic_field.type.of_type, graphene.List) + assert isinstance(dynamic_field.type.of_type.of_type, NonNull) + assert dynamic_field.type.of_type.of_type.of_type == A def test_should_manytomany_convert_connectionorlist_connection(): @@ -262,8 +267,11 @@ def test_should_manytoone_convert_connectionorlist(): assert isinstance(graphene_field, graphene.Dynamic) dynamic_field = graphene_field.get_type() assert isinstance(dynamic_field, graphene.Field) - assert isinstance(dynamic_field.type, graphene.List) - assert dynamic_field.type.of_type == A + # a NonNull List of NonNull A ([A!]!) + assert isinstance(dynamic_field.type, NonNull) + assert isinstance(dynamic_field.type.of_type, graphene.List) + assert isinstance(dynamic_field.type.of_type.of_type, NonNull) + assert dynamic_field.type.of_type.of_type.of_type == A def test_should_onetoone_reverse_convert_model(): diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py index c1ac6c2..6f5ab7e 100644 --- a/graphene_django/tests/test_types.py +++ b/graphene_django/tests/test_types.py @@ -170,7 +170,7 @@ type Reporter { firstName: String! lastName: String! email: String! - pets: [Reporter] + pets: [Reporter!]! aChoice: ReporterAChoice! reporterType: ReporterReporterType articles(before: String, after: String, first: Int, last: Int): ArticleConnection From 40ae7e53ec4d8be5e540ab26e110506733ea2b9b Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Tue, 2 Jul 2019 19:37:50 +0100 Subject: [PATCH 082/171] Fix manager check in DjangoConnectionField (#693) * Fix default manager check * Add test --- graphene_django/fields.py | 2 +- graphene_django/tests/test_query.py | 51 +++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/graphene_django/fields.py b/graphene_django/fields.py index 8c8fa2b..eb1215e 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -101,7 +101,7 @@ class DjangoConnectionField(ConnectionField): iterable = default_manager iterable = maybe_queryset(iterable) if isinstance(iterable, QuerySet): - if iterable is not default_manager: + if iterable.model.objects is not default_manager: default_queryset = maybe_queryset(default_manager) iterable = cls.merge_querysets(default_queryset, iterable) _len = iterable.count() diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py index 484a225..f466122 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -1065,3 +1065,54 @@ def test_should_resolve_get_queryset_connectionfields(): result = schema.execute(query) assert not result.errors assert result.data == expected + + +def test_should_preserve_prefetch_related(django_assert_num_queries): + class ReporterType(DjangoObjectType): + class Meta: + model = Reporter + interfaces = (graphene.relay.Node,) + + class FilmType(DjangoObjectType): + reporters = DjangoConnectionField(ReporterType) + + 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 + + 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 { + reporters { + edges { + node { + firstName + } + } + } + } + } + } + } + """ + schema = graphene.Schema(query=Query) + with django_assert_num_queries(3) as captured: + result = schema.execute(query) + assert not result.errors From 470fb60dc5341b26a6069c29c6c3c12b4146ccdb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Jul 2019 10:26:27 +0100 Subject: [PATCH 083/171] Bump django from 2.1.9 to 2.1.10 in /examples/cookbook-plain (#695) Bumps [django](https://github.com/django/django) from 2.1.9 to 2.1.10. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/2.1.9...2.1.10) 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 ea1f4ba..1dc8fcd 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.1.9 +django==2.1.10 From 3b541e3d05d0ca8f15a138d9daa4d347019c02b8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Jul 2019 10:26:54 +0100 Subject: [PATCH 084/171] Bump django from 2.2.2 to 2.2.3 in /examples/cookbook (#694) Bumps [django](https://github.com/django/django) from 2.2.2 to 2.2.3. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/2.2.2...2.2.3) 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 ccece5c..49470ed 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.2 +django==2.2.3 django-filter>=2 From 9aabe2cbe62f412ee70ad9b0b47a15d28021b80e Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Sun, 7 Jul 2019 20:06:01 +0100 Subject: [PATCH 085/171] Remove duplicate ErrorType (#701) --- graphene_django/forms/types.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/graphene_django/forms/types.py b/graphene_django/forms/types.py index 1fe33f3..5005040 100644 --- a/graphene_django/forms/types.py +++ b/graphene_django/forms/types.py @@ -1,6 +1,3 @@ import graphene - -class ErrorType(graphene.ObjectType): - field = graphene.String() - messages = graphene.List(graphene.String) +from ..types import ErrorType # noqa Import ErrorType for backwards compatability From aa30750d395dc1cc5f550d933506d978c20d285e Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Sun, 7 Jul 2019 20:11:27 +0100 Subject: [PATCH 086/171] Bugfix: Correct filter types for DjangoFilterConnectionFields (#682) * Get form field from Django model before defaulting to django-filter * Add test * Cleanup some flake8 warnings and pytest warnings * Run isort and add black compatible config --- graphene_django/filter/tests/test_fields.py | 73 +++++++++++++++++---- graphene_django/filter/utils.py | 19 +++++- setup.cfg | 5 ++ 3 files changed, 85 insertions(+), 12 deletions(-) diff --git a/graphene_django/filter/tests/test_fields.py b/graphene_django/filter/tests/test_fields.py index b9bc599..d163ff3 100644 --- a/graphene_django/filter/tests/test_fields.py +++ b/graphene_django/filter/tests/test_fields.py @@ -1,18 +1,17 @@ from datetime import datetime +from textwrap import dedent import pytest +from django.db.models import TextField, Value +from django.db.models.functions import Concat -from graphene import Field, ObjectType, Schema, Argument, Float, Boolean, String +from graphene import Argument, Boolean, Field, Float, ObjectType, Schema, String from graphene.relay import Node from graphene_django import DjangoObjectType from graphene_django.forms import GlobalIDFormField, GlobalIDMultipleChoiceField from graphene_django.tests.models import Article, Pet, Reporter from graphene_django.utils import DJANGO_FILTER_INSTALLED -# for annotation test -from django.db.models import TextField, Value -from django.db.models.functions import Concat - pytestmark = [] if DJANGO_FILTER_INSTALLED: @@ -183,7 +182,7 @@ def test_filter_shortcut_filterset_context(): } """ schema = Schema(query=Query) - result = schema.execute(query, context_value=context()) + result = schema.execute(query, context=context()) assert not result.errors assert len(result.data["contextArticles"]["edges"]) == 1 @@ -462,15 +461,15 @@ def test_filter_filterset_related_results_with_filter(): class Query(ObjectType): all_reporters = DjangoFilterConnectionField(ReporterFilterNode) - r1 = Reporter.objects.create( + Reporter.objects.create( first_name="A test user", last_name="Last Name", email="test1@test.com" ) - r2 = Reporter.objects.create( + Reporter.objects.create( first_name="Other test user", last_name="Other Last Name", email="test2@test.com", ) - r3 = Reporter.objects.create( + Reporter.objects.create( first_name="Random", last_name="RandomLast", email="random@test.com" ) @@ -638,7 +637,7 @@ def test_should_query_filter_node_double_limit_raises(): Reporter.objects.create( first_name="Bob", last_name="Doe", email="bobdoe@example.com", a_choice=2 ) - r = Reporter.objects.create( + Reporter.objects.create( first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1 ) @@ -684,7 +683,7 @@ def test_order_by_is_perserved(): return reporters Reporter.objects.create(first_name="b") - r = Reporter.objects.create(first_name="a") + Reporter.objects.create(first_name="a") schema = Schema(query=Query) query = """ @@ -767,3 +766,55 @@ def test_annotation_is_perserved(): assert not result.errors assert result.data == expected + + +def test_integer_field_filter_type(): + class PetType(DjangoObjectType): + class Meta: + model = Pet + interfaces = (Node,) + filter_fields = {"age": ["exact"]} + only_fields = ["age"] + + class Query(ObjectType): + pets = DjangoFilterConnectionField(PetType) + + schema = Schema(query=Query) + + assert str(schema) == dedent( + """\ + schema { + query: Query + } + + interface Node { + id: ID! + } + + type PageInfo { + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String + endCursor: String + } + + type PetType implements Node { + age: Int! + id: ID! + } + + type PetTypeConnection { + pageInfo: PageInfo! + edges: [PetTypeEdge]! + } + + type PetTypeEdge { + node: PetType + cursor: String! + } + + type Query { + pets(before: String, after: String, first: Int, last: Int, age: Int): PetTypeConnection + } + """ + ) diff --git a/graphene_django/filter/utils.py b/graphene_django/filter/utils.py index cfa5621..00030a0 100644 --- a/graphene_django/filter/utils.py +++ b/graphene_django/filter/utils.py @@ -11,8 +11,25 @@ def get_filtering_args_from_filterset(filterset_class, type): from ..forms.converter import convert_form_field args = {} + model = filterset_class._meta.model for name, filter_field in six.iteritems(filterset_class.base_filters): - field_type = convert_form_field(filter_field.field).Argument() + if name in filterset_class.declared_filters: + form_field = filter_field.field + else: + field_name = name.split("__", 1)[0] + 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) + ) + + # Fallback to field defined on filter if we can't get it from the + # model field + if not form_field: + form_field = filter_field.field + + field_type = convert_form_field(form_field).Argument() field_type.description = filter_field.label args[name] = field_type diff --git a/setup.cfg b/setup.cfg index 7d93d3e..def0b67 100644 --- a/setup.cfg +++ b/setup.cfg @@ -38,3 +38,8 @@ omit = */tests/* [isort] known_first_party=graphene,graphene_django +multi_line_output=3 +include_trailing_comma=True +force_grid_wrap=0 +use_parentheses=True +line_length=88 From 0988e0798ac72a8ebca1b9c133bb31648b3b582b Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Mon, 8 Jul 2019 22:22:08 +0100 Subject: [PATCH 087/171] Adds documentation to `CAMELCASE_ERRORS` setting (#689) * Rename setting and add documentation * Add examples * Use `cls` --- docs/settings.rst | 39 +++++++++++++++++++ graphene_django/forms/tests/test_mutation.py | 4 +- .../rest_framework/tests/test_mutation.py | 4 +- graphene_django/settings.py | 2 +- graphene_django/types.py | 8 +--- 5 files changed, 46 insertions(+), 11 deletions(-) diff --git a/docs/settings.rst b/docs/settings.rst index 4d37a99..4776ce0 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -101,3 +101,42 @@ Default: ``100`` GRAPHENE = { 'RELAY_CONNECTION_MAX_LIMIT': 100, } + + +``CAMELCASE_ERRORS`` +------------------------------------ + +When set to ``True`` field names in the ``errors`` object will be camel case. +By default they will be snake case. + +Default: ``False`` + +.. code:: python + + GRAPHENE = { + 'CAMELCASE_ERRORS': False, + } + + # result = schema.execute(...) + print(result.errors) + # [ + # { + # 'field': 'test_field', + # 'messages': ['This field is required.'], + # } + # ] + +.. code:: python + + GRAPHENE = { + 'CAMELCASE_ERRORS': True, + } + + # result = schema.execute(...) + print(result.errors) + # [ + # { + # 'field': 'testField', + # 'messages': ['This field is required.'], + # } + # ] diff --git a/graphene_django/forms/tests/test_mutation.py b/graphene_django/forms/tests/test_mutation.py index 4c46702..2de5113 100644 --- a/graphene_django/forms/tests/test_mutation.py +++ b/graphene_django/forms/tests/test_mutation.py @@ -53,10 +53,10 @@ def test_mutation_error_camelcased(): result = PetMutation.mutate_and_get_payload(None, None) assert {f.field for f in result.errors} == {"name", "age", "test_field"} - graphene_settings.DJANGO_GRAPHENE_CAMELCASE_ERRORS = True + graphene_settings.CAMELCASE_ERRORS = True result = PetMutation.mutate_and_get_payload(None, None) assert {f.field for f in result.errors} == {"name", "age", "testField"} - graphene_settings.DJANGO_GRAPHENE_CAMELCASE_ERRORS = False + graphene_settings.CAMELCASE_ERRORS = False class ModelFormMutationTests(TestCase): diff --git a/graphene_django/rest_framework/tests/test_mutation.py b/graphene_django/rest_framework/tests/test_mutation.py index 0dd5ad3..9d8b950 100644 --- a/graphene_django/rest_framework/tests/test_mutation.py +++ b/graphene_django/rest_framework/tests/test_mutation.py @@ -215,10 +215,10 @@ def test_model_mutate_and_get_payload_error(): def test_mutation_error_camelcased(): - graphene_settings.DJANGO_GRAPHENE_CAMELCASE_ERRORS = True + graphene_settings.CAMELCASE_ERRORS = True result = MyModelMutation.mutate_and_get_payload(None, mock_info(), **{}) assert result.errors[0].field == "coolName" - graphene_settings.DJANGO_GRAPHENE_CAMELCASE_ERRORS = False + graphene_settings.CAMELCASE_ERRORS = False def test_invalid_serializer_operations(): diff --git a/graphene_django/settings.py b/graphene_django/settings.py index 1b49dfb..af63890 100644 --- a/graphene_django/settings.py +++ b/graphene_django/settings.py @@ -35,7 +35,7 @@ DEFAULTS = { "RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST": False, # Max items returned in ConnectionFields / FilterConnectionFields "RELAY_CONNECTION_MAX_LIMIT": 100, - "DJANGO_GRAPHENE_CAMELCASE_ERRORS": False, + "CAMELCASE_ERRORS": False, } if settings.DEBUG: diff --git a/graphene_django/types.py b/graphene_django/types.py index c296707..6c100ef 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -191,9 +191,5 @@ class ErrorType(ObjectType): @classmethod def from_errors(cls, errors): - data = ( - camelize(errors) - if graphene_settings.DJANGO_GRAPHENE_CAMELCASE_ERRORS - else errors - ) - return [ErrorType(field=key, messages=value) for key, value in data.items()] + data = camelize(errors) if graphene_settings.CAMELCASE_ERRORS else errors + return [cls(field=key, messages=value) for key, value in data.items()] From a2103c19f427888d749be90e525aaec79527300e Mon Sep 17 00:00:00 2001 From: Pablo Burgos Date: Tue, 9 Jul 2019 10:14:04 +0200 Subject: [PATCH 088/171] Fix error of multiple inputs with the same type. When using same serializer. (#530) --- .../rest_framework/serializer_converter.py | 11 +++- .../tests/test_multiple_model_serializers.py | 63 +++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 graphene_django/rest_framework/tests/test_multiple_model_serializers.py diff --git a/graphene_django/rest_framework/serializer_converter.py b/graphene_django/rest_framework/serializer_converter.py index 9f8e516..35c8dc8 100644 --- a/graphene_django/rest_framework/serializer_converter.py +++ b/graphene_django/rest_framework/serializer_converter.py @@ -57,18 +57,25 @@ def convert_serializer_field(field, is_input=True): def convert_serializer_to_input_type(serializer_class): + cached_type = convert_serializer_to_input_type.cache.get(serializer_class.__name__, None) + if cached_type: + return cached_type serializer = serializer_class() items = { name: convert_serializer_field(field) for name, field in serializer.fields.items() } - - return type( + ret_type = type( "{}Input".format(serializer.__class__.__name__), (graphene.InputObjectType,), items, ) + convert_serializer_to_input_type.cache[serializer_class.__name__] = ret_type + return ret_type + + +convert_serializer_to_input_type.cache = {} @get_graphene_type_from_serializer_field.register(serializers.Field) diff --git a/graphene_django/rest_framework/tests/test_multiple_model_serializers.py b/graphene_django/rest_framework/tests/test_multiple_model_serializers.py new file mode 100644 index 0000000..4504610 --- /dev/null +++ b/graphene_django/rest_framework/tests/test_multiple_model_serializers.py @@ -0,0 +1,63 @@ +import graphene +import pytest +from django.db import models +from graphene import Schema +from rest_framework import serializers + +from graphene_django import DjangoObjectType +from graphene_django.rest_framework.mutation import SerializerMutation + +pytestmark = pytest.mark.django_db + + +class MyFakeChildModel(models.Model): + name = models.CharField(max_length=50) + created = models.DateTimeField(auto_now_add=True) + + +class MyFakeParentModel(models.Model): + name = models.CharField(max_length=50) + created = models.DateTimeField(auto_now_add=True) + child1 = models.OneToOneField(MyFakeChildModel, related_name='parent1', on_delete=models.CASCADE) + child2 = models.OneToOneField(MyFakeChildModel, related_name='parent2', on_delete=models.CASCADE) + + +class ParentType(DjangoObjectType): + class Meta: + model = MyFakeParentModel + interfaces = (graphene.relay.Node,) + + +class ChildType(DjangoObjectType): + class Meta: + model = MyFakeChildModel + interfaces = (graphene.relay.Node,) + + +class MyModelChildSerializer(serializers.ModelSerializer): + class Meta: + model = MyFakeChildModel + fields = "__all__" + + +class MyModelParentSerializer(serializers.ModelSerializer): + child1 = MyModelChildSerializer() + child2 = MyModelChildSerializer() + + class Meta: + model = MyFakeParentModel + fields = "__all__" + + +class MyParentModelMutation(SerializerMutation): + class Meta: + serializer_class = MyModelParentSerializer + + +class Mutation(graphene.ObjectType): + createParentWithChild = MyParentModelMutation.Field() + + +def test_create_schema(): + schema = Schema(mutation=Mutation, types=[ParentType, ChildType]) + assert schema From b7e4937775a951c6d3990db58689bd9acee8a222 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Tue, 9 Jul 2019 14:03:11 +0100 Subject: [PATCH 089/171] Alias `only_fields` as `fields` and `exclude_fields` as `exclude` (#691) * Create new fields and exclude options that are aliased to exclude_fields and only_fields * Update docs * Add some checking around fields and exclude definitions * Add all fields option * Update docs to include `__all__` option * Actual order of fields is not stable * Update docs/queries.rst Co-Authored-By: Semyon Pupkov * Fix example code * Format code * Start raising PendingDeprecationWarnings for using only_fields and exclude_fields * Update tests --- docs/queries.rst | 45 ++++++--- graphene_django/filter/tests/test_fields.py | 2 +- .../rest_framework/serializer_converter.py | 4 +- .../tests/test_multiple_model_serializers.py | 8 +- graphene_django/tests/test_query.py | 8 +- graphene_django/tests/test_schema.py | 2 +- graphene_django/tests/test_types.py | 99 +++++++++++++++++-- graphene_django/types.py | 54 +++++++++- 8 files changed, 187 insertions(+), 35 deletions(-) diff --git a/docs/queries.rst b/docs/queries.rst index 7aff572..67ebb06 100644 --- a/docs/queries.rst +++ b/docs/queries.rst @@ -41,14 +41,18 @@ Full example return Question.objects.get(pk=question_id) -Fields ------- +Specifying which fields to include +---------------------------------- By default, ``DjangoObjectType`` will present all fields on a Model through GraphQL. -If you don't want to do this you can change this by setting either ``only_fields`` and ``exclude_fields``. +If you only want a subset of fields to be present, you can do so using +``fields`` or ``exclude``. It is strongly recommended that you explicitly set +all fields that should be exposed using the fields attribute. +This will make it less likely to result in unintentionally exposing data when +your models change. -only_fields -~~~~~~~~~~~ +``fields`` +~~~~~~~~~~ Show **only** these fields on the model: @@ -57,24 +61,35 @@ Show **only** these fields on the model: class QuestionType(DjangoObjectType): class Meta: model = Question - only_fields = ('question_text') + fields = ('id', 'question_text') +You can also set the ``fields`` attribute to the special value ``'__all__'`` to indicate that all fields in the model should be used. -exclude_fields -~~~~~~~~~~~~~~ - -Show all fields **except** those in ``exclude_fields``: +For example: .. code:: python class QuestionType(DjangoObjectType): class Meta: model = Question - exclude_fields = ('question_text') + fields = '__all__' -Customised fields -~~~~~~~~~~~~~~~~~ +``exclude`` +~~~~~~~~~~~ + +Show all fields **except** those in ``exclude``: + +.. code:: python + + class QuestionType(DjangoObjectType): + class Meta: + model = Question + exclude = ('question_text',) + + +Customising fields +------------------ You can completely overwrite a field, or add new fields, to a ``DjangoObjectType`` using a Resolver: @@ -84,7 +99,7 @@ You can completely overwrite a field, or add new fields, to a ``DjangoObjectType class Meta: model = Question - exclude_fields = ('question_text') + fields = ('id', 'question_text') extra_field = graphene.String() @@ -178,7 +193,7 @@ When ``Question`` is published as a ``DjangoObjectType`` and you want to add ``C class QuestionType(DjangoObjectType): class Meta: model = Question - only_fields = ('category',) + fields = ('category',) Then all query-able related models must be defined as DjangoObjectType subclass, or they will fail to show if you are trying to query those relation fields. You only diff --git a/graphene_django/filter/tests/test_fields.py b/graphene_django/filter/tests/test_fields.py index d163ff3..99876b6 100644 --- a/graphene_django/filter/tests/test_fields.py +++ b/graphene_django/filter/tests/test_fields.py @@ -774,7 +774,7 @@ def test_integer_field_filter_type(): model = Pet interfaces = (Node,) filter_fields = {"age": ["exact"]} - only_fields = ["age"] + fields = ("age",) class Query(ObjectType): pets = DjangoFilterConnectionField(PetType) diff --git a/graphene_django/rest_framework/serializer_converter.py b/graphene_django/rest_framework/serializer_converter.py index 35c8dc8..c419419 100644 --- a/graphene_django/rest_framework/serializer_converter.py +++ b/graphene_django/rest_framework/serializer_converter.py @@ -57,7 +57,9 @@ def convert_serializer_field(field, is_input=True): def convert_serializer_to_input_type(serializer_class): - cached_type = convert_serializer_to_input_type.cache.get(serializer_class.__name__, None) + cached_type = convert_serializer_to_input_type.cache.get( + serializer_class.__name__, None + ) if cached_type: return cached_type serializer = serializer_class() diff --git a/graphene_django/rest_framework/tests/test_multiple_model_serializers.py b/graphene_django/rest_framework/tests/test_multiple_model_serializers.py index 4504610..c1f4626 100644 --- a/graphene_django/rest_framework/tests/test_multiple_model_serializers.py +++ b/graphene_django/rest_framework/tests/test_multiple_model_serializers.py @@ -18,8 +18,12 @@ class MyFakeChildModel(models.Model): class MyFakeParentModel(models.Model): name = models.CharField(max_length=50) created = models.DateTimeField(auto_now_add=True) - child1 = models.OneToOneField(MyFakeChildModel, related_name='parent1', on_delete=models.CASCADE) - child2 = models.OneToOneField(MyFakeChildModel, related_name='parent2', on_delete=models.CASCADE) + child1 = models.OneToOneField( + MyFakeChildModel, related_name="parent1", on_delete=models.CASCADE + ) + child2 = models.OneToOneField( + MyFakeChildModel, related_name="parent2", on_delete=models.CASCADE + ) class ParentType(DjangoObjectType): diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py index f466122..f24f84b 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -28,7 +28,7 @@ def test_should_query_only_fields(): class ReporterType(DjangoObjectType): class Meta: model = Reporter - only_fields = ("articles",) + fields = ("articles",) schema = graphene.Schema(query=ReporterType) query = """ @@ -44,7 +44,7 @@ def test_should_query_simplelazy_objects(): class ReporterType(DjangoObjectType): class Meta: model = Reporter - only_fields = ("id",) + fields = ("id",) class Query(graphene.ObjectType): reporter = graphene.Field(ReporterType) @@ -289,7 +289,7 @@ def test_should_query_connectionfields(): class Meta: model = Reporter interfaces = (Node,) - only_fields = ("articles",) + fields = ("articles",) class Query(graphene.ObjectType): all_reporters = DjangoConnectionField(ReporterType) @@ -329,7 +329,7 @@ def test_should_keep_annotations(): class Meta: model = Reporter interfaces = (Node,) - only_fields = ("articles",) + fields = ("articles",) class ArticleType(DjangoObjectType): class Meta: diff --git a/graphene_django/tests/test_schema.py b/graphene_django/tests/test_schema.py index 452449b..2c2f74b 100644 --- a/graphene_django/tests/test_schema.py +++ b/graphene_django/tests/test_schema.py @@ -48,6 +48,6 @@ def test_should_map_only_few_fields(): class Reporter2(DjangoObjectType): class Meta: model = Reporter - only_fields = ("id", "email") + fields = ("id", "email") assert list(Reporter2._meta.fields.keys()) == ["id", "email"] diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py index 6f5ab7e..6cbaae0 100644 --- a/graphene_django/tests/test_types.py +++ b/graphene_django/tests/test_types.py @@ -211,26 +211,113 @@ def with_local_registry(func): @with_local_registry def test_django_objecttype_only_fields(): - class Reporter(DjangoObjectType): - class Meta: - model = ReporterModel - only_fields = ("id", "email", "films") + with pytest.warns(PendingDeprecationWarning): + + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + only_fields = ("id", "email", "films") fields = list(Reporter._meta.fields.keys()) assert fields == ["id", "email", "films"] @with_local_registry -def test_django_objecttype_exclude_fields(): +def test_django_objecttype_fields(): class Reporter(DjangoObjectType): class Meta: model = ReporterModel - exclude_fields = "email" + fields = ("id", "email", "films") + + fields = list(Reporter._meta.fields.keys()) + assert fields == ["id", "email", "films"] + + +@with_local_registry +def test_django_objecttype_only_fields_and_fields(): + with pytest.raises(Exception): + + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + only_fields = ("id", "email", "films") + fields = ("id", "email", "films") + + +@with_local_registry +def test_django_objecttype_all_fields(): + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + fields = "__all__" + + fields = list(Reporter._meta.fields.keys()) + assert len(fields) == len(ReporterModel._meta.get_fields()) + + +@with_local_registry +def test_django_objecttype_exclude_fields(): + with pytest.warns(PendingDeprecationWarning): + + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + exclude_fields = ["email"] fields = list(Reporter._meta.fields.keys()) assert "email" not in fields +@with_local_registry +def test_django_objecttype_exclude(): + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + exclude = ["email"] + + fields = list(Reporter._meta.fields.keys()) + assert "email" not in fields + + +@with_local_registry +def test_django_objecttype_exclude_fields_and_exclude(): + with pytest.raises(Exception): + + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + exclude = ["email"] + exclude_fields = ["email"] + + +@with_local_registry +def test_django_objecttype_exclude_and_only(): + with pytest.raises(AssertionError): + + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + exclude = ["email"] + fields = ["id"] + + +@with_local_registry +def test_django_objecttype_fields_exclude_type_checking(): + with pytest.raises(TypeError): + + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + fields = "foo" + + with pytest.raises(TypeError): + + class Reporter2(DjangoObjectType): + class Meta: + model = ReporterModel + fields = "foo" + + class TestDjangoObjectType: @pytest.fixture def PetModel(self): diff --git a/graphene_django/types.py b/graphene_django/types.py index 6c100ef..ec426f1 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -1,3 +1,4 @@ +import warnings from collections import OrderedDict import six @@ -24,6 +25,9 @@ if six.PY3: from typing import Type +ALL_FIELDS = "__all__" + + def construct_fields( model, registry, only_fields, exclude_fields, convert_choices_to_enum ): @@ -74,8 +78,10 @@ class DjangoObjectType(ObjectType): model=None, registry=None, skip_registry=False, - only_fields=(), - exclude_fields=(), + only_fields=(), # deprecated in favour of `fields` + fields=(), + exclude_fields=(), # deprecated in favour of `exclude` + exclude=(), filter_fields=None, filterset_class=None, connection=None, @@ -109,10 +115,48 @@ class DjangoObjectType(ObjectType): ) ) + assert not (fields and exclude), ( + "Cannot set both 'fields' and 'exclude' options on " + "DjangoObjectType {class_name}.".format(class_name=cls.__name__) + ) + + # Alias only_fields -> fields + if only_fields and fields: + raise Exception("Can't set both only_fields and fields") + if only_fields: + warnings.warn( + "Defining `only_fields` is deprecated in favour of `fields`.", + PendingDeprecationWarning, + stacklevel=2, + ) + fields = only_fields + if fields and fields != ALL_FIELDS and not isinstance(fields, (list, tuple)): + raise TypeError( + 'The `fields` option must be a list or tuple or "__all__". ' + "Got %s." % type(fields).__name__ + ) + + if fields == ALL_FIELDS: + fields = None + + # Alias exclude_fields -> exclude + if exclude_fields and exclude: + raise Exception("Can't set both exclude_fields and exclude") + if exclude_fields: + warnings.warn( + "Defining `exclude_fields` is deprecated in favour of `exclude`.", + PendingDeprecationWarning, + stacklevel=2, + ) + exclude = exclude_fields + if exclude and not isinstance(exclude, (list, tuple)): + raise TypeError( + "The `exclude` option must be a list or tuple. Got %s." + % type(exclude).__name__ + ) + django_fields = yank_fields_from_attrs( - construct_fields( - model, registry, only_fields, exclude_fields, convert_choices_to_enum - ), + construct_fields(model, registry, fields, exclude, convert_choices_to_enum), _as=Field, ) From 224725039bb15373890d49329bb588104ab275cd Mon Sep 17 00:00:00 2001 From: Semyon Pupkov Date: Thu, 11 Jul 2019 22:32:07 +0300 Subject: [PATCH 090/171] =?UTF-8?q?Asserting=20status=20code=20before=20de?= =?UTF-8?q?coding=20json=20in=20assertResponseNoEr=E2=80=A6=20(#708)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 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 db3e9f4..0fdac7e 100644 --- a/graphene_django/utils/testing.py +++ b/graphene_django/utils/testing.py @@ -54,8 +54,8 @@ class GraphQLTestCase(TestCase): the call was fine. :resp HttpResponse: Response """ - content = json.loads(resp.content) self.assertEqual(resp.status_code, 200) + content = json.loads(resp.content) self.assertNotIn("errors", list(content.keys())) def assertResponseHasErrors(self, resp): From de98fb58121ec5c7126800ef59896d4e2fc23702 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Fri, 12 Jul 2019 17:38:26 +0100 Subject: [PATCH 091/171] v2.4.0 (#706) --- 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 51acfd2..e09f2a2 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.3.0" +__version__ = "2.4.0" __all__ = ["__version__", "DjangoObjectType", "DjangoConnectionField"] From 51adb3632bab8ce0f200cde0686a158436f07ab3 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Sat, 27 Jul 2019 16:14:34 +0200 Subject: [PATCH 092/171] Update readme with Django path (#720) --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 159a592..33f71f3 100644 --- a/README.md +++ b/README.md @@ -38,12 +38,12 @@ GRAPHENE = { We need to set up a `GraphQL` endpoint in our Django app, so we can serve the queries. ```python -from django.conf.urls import url +from django.urls import path from graphene_django.views import GraphQLView urlpatterns = [ # ... - url(r'^graphql$', GraphQLView.as_view(graphiql=True)), + path('graphql', GraphQLView.as_view(graphiql=True)), ] ``` @@ -100,4 +100,4 @@ To learn more check out the following [examples](examples/): ## Contributing -See [CONTRIBUTING.md](CONTRIBUTING.md) \ No newline at end of file +See [CONTRIBUTING.md](CONTRIBUTING.md) From b1a9293016a5263efe9ed39b1f6db2dac0b9623a Mon Sep 17 00:00:00 2001 From: Jason Kraus Date: Thu, 1 Aug 2019 01:07:52 -0700 Subject: [PATCH 093/171] fix choices enum: if field can be blank then it isnt required (#714) --- graphene_django/converter.py | 3 ++- graphene_django/tests/models.py | 2 +- graphene_django/tests/test_types.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/graphene_django/converter.py b/graphene_django/converter.py index 64bf341..b1e27fc 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -73,7 +73,8 @@ def convert_django_field_with_choices( return named_choices_descriptions[self.name] enum = Enum(name, list(named_choices), type=EnumWithDescriptionsType) - converted = enum(description=field.help_text, required=not field.null) + required = not (field.blank or field.null) + converted = enum(description=field.help_text, required=required) else: converted = convert_django_field(field, registry) if registry is not None: diff --git a/graphene_django/tests/models.py b/graphene_django/tests/models.py index b4eb3ce..14a8367 100644 --- a/graphene_django/tests/models.py +++ b/graphene_django/tests/models.py @@ -38,7 +38,7 @@ class Reporter(models.Model): last_name = models.CharField(max_length=30) email = models.EmailField() pets = models.ManyToManyField("self") - a_choice = models.CharField(max_length=30, choices=CHOICES) + a_choice = models.CharField(max_length=30, choices=CHOICES, blank=True) objects = models.Manager() doe_objects = DoeReporterManager() diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py index 6cbaae0..8b84fca 100644 --- a/graphene_django/tests/test_types.py +++ b/graphene_django/tests/test_types.py @@ -171,7 +171,7 @@ type Reporter { lastName: String! email: String! pets: [Reporter!]! - aChoice: ReporterAChoice! + aChoice: ReporterAChoice reporterType: ReporterReporterType articles(before: String, after: String, first: Int, last: Int): ArticleConnection } From 59f4f134b584d54e3accc5c8f1abeaca8b17a003 Mon Sep 17 00:00:00 2001 From: Alexandre Kirszenberg Date: Thu, 1 Aug 2019 18:31:18 +0200 Subject: [PATCH 094/171] Set converted Django connections to required (#610) --- graphene_django/converter.py | 6 ++++-- graphene_django/filter/fields.py | 2 +- graphene_django/tests/test_converter.py | 2 +- graphene_django/tests/test_types.py | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/graphene_django/converter.py b/graphene_django/converter.py index b1e27fc..063d6be 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -195,9 +195,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, description=description) + return DjangoFilterConnectionField( + _type, required=True, description=description + ) - return DjangoConnectionField(_type, description=description) + return DjangoConnectionField(_type, required=True, description=description) return DjangoListField( _type, diff --git a/graphene_django/filter/fields.py b/graphene_django/filter/fields.py index 62f4b1a..338becb 100644 --- a/graphene_django/filter/fields.py +++ b/graphene_django/filter/fields.py @@ -111,7 +111,7 @@ class DjangoFilterConnectionField(DjangoConnectionField): return partial( self.connection_resolver, parent_resolver, - self.type, + self.connection_type, self.get_manager(), self.max_limit, self.enforce_first_or_last, diff --git a/graphene_django/tests/test_converter.py b/graphene_django/tests/test_converter.py index 00467b4..3790c4a 100644 --- a/graphene_django/tests/test_converter.py +++ b/graphene_django/tests/test_converter.py @@ -255,7 +255,7 @@ def test_should_manytomany_convert_connectionorlist_connection(): assert isinstance(graphene_field, graphene.Dynamic) dynamic_field = graphene_field.get_type() assert isinstance(dynamic_field, ConnectionField) - assert dynamic_field.type == A._meta.connection + assert dynamic_field.type.of_type == A._meta.connection def test_should_manytoone_convert_connectionorlist(): diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py index 8b84fca..5e9d1c2 100644 --- a/graphene_django/tests/test_types.py +++ b/graphene_django/tests/test_types.py @@ -173,7 +173,7 @@ type Reporter { pets: [Reporter!]! aChoice: ReporterAChoice reporterType: ReporterReporterType - articles(before: String, after: String, first: Int, last: Int): ArticleConnection + articles(before: String, after: String, first: Int, last: Int): ArticleConnection! } enum ReporterAChoice { From 6e137da4695c5d23ade330e3dc21be7c49a8b601 Mon Sep 17 00:00:00 2001 From: Kike Isidoro Date: Wed, 7 Aug 2019 09:04:04 +0200 Subject: [PATCH 095/171] Check for filters defined on base filterset classes (#730) * Check for filters defined on base filterset classes * Make python2.7 compatible and run black * Add filter method and use filter in test * Check article headline and reformat --- graphene_django/filter/tests/test_fields.py | 103 ++++++++++++++++++++ graphene_django/filter/utils.py | 22 +++-- 2 files changed, 116 insertions(+), 9 deletions(-) diff --git a/graphene_django/filter/tests/test_fields.py b/graphene_django/filter/tests/test_fields.py index 99876b6..aa6a903 100644 --- a/graphene_django/filter/tests/test_fields.py +++ b/graphene_django/filter/tests/test_fields.py @@ -818,3 +818,106 @@ def test_integer_field_filter_type(): } """ ) + + +def test_filter_filterset_based_on_mixin(): + class ArticleFilterMixin(FilterSet): + @classmethod + def get_filters(cls): + filters = super(FilterSet, cls).get_filters() + filters.update( + { + "viewer__email__in": django_filters.CharFilter( + method="filter_email_in", field_name="reporter__email__in" + ) + } + ) + + return filters + + def filter_email_in(cls, queryset, name, value): + return queryset.filter(**{name: [value]}) + + class NewArticleFilter(ArticleFilterMixin, ArticleFilter): + pass + + class NewReporterNode(DjangoObjectType): + class Meta: + model = Reporter + interfaces = (Node,) + + class NewArticleFilterNode(DjangoObjectType): + viewer = Field(NewReporterNode) + + class Meta: + model = Article + interfaces = (Node,) + filterset_class = NewArticleFilter + + def resolve_viewer(self, info): + return self.reporter + + class Query(ObjectType): + all_articles = DjangoFilterConnectionField(NewArticleFilterNode) + + reporter_1 = Reporter.objects.create( + first_name="John", last_name="Doe", email="john@doe.com" + ) + + article_1 = Article.objects.create( + headline="Hello", + reporter=reporter_1, + editor=reporter_1, + pub_date=datetime.now(), + pub_date_time=datetime.now(), + ) + + reporter_2 = Reporter.objects.create( + first_name="Adam", last_name="Doe", email="adam@doe.com" + ) + + article_2 = Article.objects.create( + headline="Good Bye", + reporter=reporter_2, + editor=reporter_2, + pub_date=datetime.now(), + pub_date_time=datetime.now(), + ) + + schema = Schema(query=Query) + + query = ( + """ + query NodeFilteringQuery { + allArticles(viewer_Email_In: "%s") { + edges { + node { + headline + viewer { + email + } + } + } + } + } + """ + % reporter_1.email + ) + + expected = { + "allArticles": { + "edges": [ + { + "node": { + "headline": article_1.headline, + "viewer": {"email": reporter_1.email}, + } + } + ] + } + } + + result = schema.execute(query) + + assert not result.errors + assert result.data == expected diff --git a/graphene_django/filter/utils.py b/graphene_django/filter/utils.py index 00030a0..81efb63 100644 --- a/graphene_django/filter/utils.py +++ b/graphene_django/filter/utils.py @@ -13,21 +13,25 @@ def get_filtering_args_from_filterset(filterset_class, type): args = {} model = filterset_class._meta.model for name, filter_field in six.iteritems(filterset_class.base_filters): + form_field = None + if name in filterset_class.declared_filters: form_field = filter_field.field else: field_name = name.split("__", 1)[0] - 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) - ) + if hasattr(model, field_name): + model_field = model._meta.get_field(field_name) - # Fallback to field defined on filter if we can't get it from the - # model field - if not form_field: - form_field = filter_field.field + if 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 + if not form_field: + form_field = filter_field.field field_type = convert_form_field(form_field).Argument() field_type.description = filter_field.label From 11605dcdc6806cbd9d466e84a5e50e0958a438f0 Mon Sep 17 00:00:00 2001 From: Tomasz Kontusz Date: Wed, 7 Aug 2019 09:09:17 +0200 Subject: [PATCH 096/171] Make DjangoDebugContext wait for nested fields (#591) * Make DjangoDebugContext wait for nested fields This commit makes DjangoDebugContext wait for all field's promises, even for fields that only started their resolvers after __debug was resolved. Fixes #293. * Run format --- graphene_django/debug/middleware.py | 6 +- graphene_django/debug/tests/test_query.py | 67 +++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/graphene_django/debug/middleware.py b/graphene_django/debug/middleware.py index 48d471f..0fe3fe3 100644 --- a/graphene_django/debug/middleware.py +++ b/graphene_django/debug/middleware.py @@ -16,14 +16,18 @@ class DjangoDebugContext(object): def get_debug_promise(self): if not self.debug_promise: self.debug_promise = Promise.all(self.promises) + self.promises = [] return self.debug_promise.then(self.on_resolve_all_promises) def on_resolve_all_promises(self, values): + if self.promises: + self.debug_promise = None + return self.get_debug_promise() self.disable_instrumentation() return self.object def add_promise(self, promise): - if self.debug_promise and not self.debug_promise.is_fulfilled: + if self.debug_promise: self.promises.append(promise) def enable_instrumentation(self): diff --git a/graphene_django/debug/tests/test_query.py b/graphene_django/debug/tests/test_query.py index af69715..db8f275 100644 --- a/graphene_django/debug/tests/test_query.py +++ b/graphene_django/debug/tests/test_query.py @@ -60,6 +60,73 @@ def test_should_query_field(): assert result.data == expected +def test_should_query_nested_field(): + r1 = Reporter(last_name="ABA") + r1.save() + r2 = Reporter(last_name="Griffin") + r2.save() + r2.pets.add(r1) + r1.pets.add(r2) + + class ReporterType(DjangoObjectType): + class Meta: + model = Reporter + interfaces = (Node,) + + class Query(graphene.ObjectType): + reporter = graphene.Field(ReporterType) + debug = graphene.Field(DjangoDebug, name="__debug") + + def resolve_reporter(self, info, **args): + return Reporter.objects.first() + + query = """ + query ReporterQuery { + reporter { + lastName + pets { edges { node { + lastName + pets { edges { node { lastName } } } + } } } + } + __debug { + sql { + rawSql + } + } + } + """ + expected = { + "reporter": { + "lastName": "ABA", + "pets": { + "edges": [ + { + "node": { + "lastName": "Griffin", + "pets": {"edges": [{"node": {"lastName": "ABA"}}]}, + } + } + ] + }, + } + } + schema = graphene.Schema(query=Query) + result = schema.execute( + query, context_value=context(), middleware=[DjangoDebugMiddleware()] + ) + assert not result.errors + query = str(Reporter.objects.order_by("pk")[:1].query) + assert result.data["__debug"]["sql"][0]["rawSql"] == query + assert "COUNT" in result.data["__debug"]["sql"][1]["rawSql"] + assert "tests_reporter_pets" in result.data["__debug"]["sql"][2]["rawSql"] + assert "COUNT" in result.data["__debug"]["sql"][3]["rawSql"] + assert "tests_reporter_pets" in result.data["__debug"]["sql"][4]["rawSql"] + assert len(result.data["__debug"]["sql"]) == 5 + + assert result.data["reporter"] == expected["reporter"] + + def test_should_query_list(): r1 = Reporter(last_name="ABA") r1.save() From c432d5875b244a07f929bf5daa68b591e3ec7360 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 7 Aug 2019 08:09:42 +0100 Subject: [PATCH 097/171] Bump django from 2.2.3 to 2.2.4 in /examples/cookbook (#734) Bumps [django](https://github.com/django/django) from 2.2.3 to 2.2.4. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/2.2.3...2.2.4) 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 49470ed..0537103 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.3 +django==2.2.4 django-filter>=2 From 930adb50ce3d418357f09ab5131a2a6f305e09ca Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 7 Aug 2019 08:09:56 +0100 Subject: [PATCH 098/171] Bump django from 2.1.10 to 2.1.11 in /examples/cookbook-plain (#733) Bumps [django](https://github.com/django/django) from 2.1.10 to 2.1.11. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/2.1.10...2.1.11) 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 1dc8fcd..8b8f675 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.1.10 +django==2.1.11 From 87aebdb6300e62a22243573af6994a9077f239e4 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Sat, 10 Aug 2019 11:55:42 +0100 Subject: [PATCH 099/171] v2.5.0 (#739) --- 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 e09f2a2..659cc79 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.4.0" +__version__ = "2.5.0" __all__ = ["__version__", "DjangoObjectType", "DjangoConnectionField"] From a04fff9d70e06d427a06990c30bd6141401fa29c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 10 Aug 2019 18:50:39 +0100 Subject: [PATCH 100/171] Bump django from 2.1.11 to 2.2.4 in /examples/cookbook-plain (#736) Bumps [django](https://github.com/django/django) from 2.1.11 to 2.2.4. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/2.1.11...2.2.4) 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 8b8f675..802aa37 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.1.11 +django==2.2.4 From d5e71bc9be5c11cd4261419629f5ea54d6eae42b Mon Sep 17 00:00:00 2001 From: Gert Van Gool Date: Sat, 10 Aug 2019 22:30:17 +0200 Subject: [PATCH 101/171] Fix typo of imoprt to import (#742) --- docs/mutations.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/mutations.rst b/docs/mutations.rst index 6610151..362df58 100644 --- a/docs/mutations.rst +++ b/docs/mutations.rst @@ -151,7 +151,7 @@ customize the look up with the ``lookup_field`` attribute on the ``SerializerMut .. code:: python from graphene_django.rest_framework.mutation import SerializerMutation - from .serializers imoprt MyModelSerializer + from .serializers import MyModelSerializer class AwesomeModelMutation(SerializerMutation): @@ -168,7 +168,7 @@ Use the method ``get_serializer_kwargs`` to override how updates are applied. .. code:: python from graphene_django.rest_framework.mutation import SerializerMutation - from .serializers imoprt MyModelSerializer + from .serializers import MyModelSerializer class AwesomeModelMutation(SerializerMutation): @@ -199,7 +199,7 @@ You can use relay with mutations. A Relay mutation must inherit from .. code:: python - import graphene + import graphene from graphene import relay from graphene_django import DjangoObjectType from graphql_relay import from_global_id From 9d245287a4bd8a001df54cdbfc14f04e0e0d293a Mon Sep 17 00:00:00 2001 From: A C SREEDHAR REDDY Date: Fri, 16 Aug 2019 19:03:59 +0530 Subject: [PATCH 102/171] is_authenticated is bool not callable. (#749) --- docs/authorization.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/authorization.rst b/docs/authorization.rst index 2c38fa4..5199081 100644 --- a/docs/authorization.rst +++ b/docs/authorization.rst @@ -84,7 +84,7 @@ with the context argument. def resolve_my_posts(self, info): # context will reference to the Django request - if not info.context.user.is_authenticated(): + if not info.context.user.is_authenticated: return Post.objects.none() else: return Post.objects.filter(owner=info.context.user) From 1b8184ece14689d33c613092db49bf57206bbd6b Mon Sep 17 00:00:00 2001 From: A C SREEDHAR REDDY Date: Fri, 16 Aug 2019 19:04:28 +0530 Subject: [PATCH 103/171] make Mutation class ObjectType. (#748) --- docs/mutations.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/mutations.rst b/docs/mutations.rst index 362df58..aef32eb 100644 --- a/docs/mutations.rst +++ b/docs/mutations.rst @@ -44,7 +44,7 @@ Simple example return QuestionMutation(question=question) - class Mutation: + class Mutation(graphene.ObjectType): update_question = QuestionMutation.Field() From ac79b38cf0e51183c624b403379904cf91f2b161 Mon Sep 17 00:00:00 2001 From: Semyon Pupkov Date: Sat, 7 Sep 2019 21:49:41 +0500 Subject: [PATCH 104/171] Use field and exclude in docs instead deprecated attrs (#740) --- docs/authorization.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/authorization.rst b/docs/authorization.rst index 5199081..ebc9795 100644 --- a/docs/authorization.rst +++ b/docs/authorization.rst @@ -20,7 +20,7 @@ Let's use a simple example model. Limiting Field Access --------------------- -To limit fields in a GraphQL query simply use the ``only_fields`` meta attribute. +To limit fields in a GraphQL query simply use the ``fields`` meta attribute. .. code:: python @@ -31,10 +31,10 @@ To limit fields in a GraphQL query simply use the ``only_fields`` meta attribute class PostNode(DjangoObjectType): class Meta: model = Post - only_fields = ('title', 'content') + fields = ('title', 'content') interfaces = (relay.Node, ) -conversely you can use ``exclude_fields`` meta attribute. +conversely you can use ``exclude`` meta attribute. .. code:: python @@ -45,7 +45,7 @@ conversely you can use ``exclude_fields`` meta attribute. class PostNode(DjangoObjectType): class Meta: model = Post - exclude_fields = ('published', 'owner') + exclude = ('published', 'owner') interfaces = (relay.Node, ) Queryset Filtering On Lists @@ -133,7 +133,7 @@ method to your ``DjangoObjectType``. class PostNode(DjangoObjectType): class Meta: model = Post - only_fields = ('title', 'content') + fields = ('title', 'content') interfaces = (relay.Node, ) @classmethod From 254e59c36fa289ddf86b32b528afeae54dfa1bb1 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sat, 7 Sep 2019 14:49:29 -0400 Subject: [PATCH 105/171] Adds variables arg to GraphQLTestCase.query (#699) * add variables arg in GraphQLTestCase.query * update GraphQLTestCase.query docstring and remove type check --- docs/testing.rst | 22 ++++++++++++++++++++++ graphene_django/utils/testing.py | 15 ++++++++++++--- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/docs/testing.rst b/docs/testing.rst index b111642..031cf6b 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -37,6 +37,28 @@ Usage: # Add some more asserts if you like ... + def test_query_with_variables(self): + response = self.query( + ''' + query myModel($id: Int!){ + myModel(id: $id) { + id + name + } + } + ''', + op_name='myModel', + variables={'id': 1} + ) + + content = json.loads(response.content) + + # This validates the status code and if you get errors + self.assertResponseNoErrors(response) + + # Add some more asserts if you like + ... + def test_some_mutation(self): response = self.query( ''' diff --git a/graphene_django/utils/testing.py b/graphene_django/utils/testing.py index 0fdac7e..5b694b2 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): + def query(self, query, op_name=None, input_data=None, variables=None): """ Args: query (string) - GraphQL query to run @@ -32,7 +32,11 @@ class GraphQLTestCase(TestCase): supply the op_name. For annon queries ("{ ... }"), should be None (default). input_data (dict) - If provided, the $input variable in GraphQL will be set - to this value + to this value. If both ``input_data`` and ``variables``, + 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. Returns: Response object from client @@ -40,8 +44,13 @@ class GraphQLTestCase(TestCase): body = {"query": query} if op_name: body["operation_name"] = op_name + if variables: + body["variables"] = variables if input_data: - body["variables"] = {"input": input_data} + if variables in body: + 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" From 4bbc0824a623c874dd2726b7aed5bdeb563dda3b Mon Sep 17 00:00:00 2001 From: Tyler Kennedy Date: Tue, 17 Sep 2019 12:13:47 -0400 Subject: [PATCH 106/171] Fix a small typo, filerset_class -> filterset_class (#762) --- docs/filtering.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/filtering.rst b/docs/filtering.rst index 7661928..6fe7cab 100644 --- a/docs/filtering.rst +++ b/docs/filtering.rst @@ -127,7 +127,7 @@ create your own ``FilterSet``. You can pass it directly as follows: all_animals = DjangoFilterConnectionField(AnimalNode, filterset_class=AnimalFilter) -You can also specify the ``FilterSet`` class using the ``filerset_class`` +You can also specify the ``FilterSet`` class using the ``filterset_class`` parameter when defining your ``DjangoObjectType``, however, this can't be used in unison with the ``filter_fields`` parameter: @@ -218,4 +218,4 @@ with this set up, you can now order the users under group: xxx } } - } \ No newline at end of file + } From fea9b5b194c9ec7dc864143b918c73931f652ef4 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Tue, 17 Sep 2019 17:14:18 +0100 Subject: [PATCH 107/171] Extend DjangoListField to use model queryset if none defined (#732) * Fix model property * Only allow DjangoObjectTypes to DjangoListField * Resolve model queryset by default * Add some more tests to check behaviour --- graphene_django/fields.py | 39 ++++-- graphene_django/tests/test_fields.py | 199 +++++++++++++++++++++++++++ 2 files changed, 230 insertions(+), 8 deletions(-) create mode 100644 graphene_django/tests/test_fields.py diff --git a/graphene_django/fields.py b/graphene_django/fields.py index eb1215e..e6daa88 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -1,13 +1,12 @@ from functools import partial from django.db.models.query import QuerySet -from graphene import NonNull - +from graphql_relay.connection.arrayconnection import connection_from_list_slice from promise import Promise -from graphene.types import Field, List +from graphene import NonNull from graphene.relay import ConnectionField, PageInfo -from graphql_relay.connection.arrayconnection import connection_from_list_slice +from graphene.types import Field, List from .settings import graphene_settings from .utils import maybe_queryset @@ -15,19 +14,43 @@ from .utils import maybe_queryset class DjangoListField(Field): def __init__(self, _type, *args, **kwargs): + from .types import DjangoObjectType + + if isinstance(_type, NonNull): + _type = _type.of_type + + assert issubclass( + _type, DjangoObjectType + ), "DjangoListField only accepts DjangoObjectType types" + # Django would never return a Set of None vvvvvvv super(DjangoListField, self).__init__(List(NonNull(_type)), *args, **kwargs) @property def model(self): - return self.type.of_type._meta.node._meta.model + _type = self.type.of_type + if isinstance(_type, NonNull): + _type = _type.of_type + return _type._meta.model @staticmethod - def list_resolver(resolver, root, info, **args): - return maybe_queryset(resolver(root, info, **args)) + def list_resolver(django_object_type, resolver, root, info, **args): + queryset = maybe_queryset(resolver(root, info, **args)) + 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 + queryset = maybe_queryset( + django_object_type.get_queryset(model.objects, info) + ) + return queryset def get_resolver(self, parent_resolver): - return partial(self.list_resolver, parent_resolver) + _type = self.type + if isinstance(_type, NonNull): + _type = _type.of_type + django_object_type = _type.of_type.of_type + return partial(self.list_resolver, django_object_type, parent_resolver) class DjangoConnectionField(ConnectionField): diff --git a/graphene_django/tests/test_fields.py b/graphene_django/tests/test_fields.py new file mode 100644 index 0000000..f6abf00 --- /dev/null +++ b/graphene_django/tests/test_fields.py @@ -0,0 +1,199 @@ +import datetime + +import pytest + +from graphene import List, NonNull, ObjectType, Schema, String + +from ..fields import DjangoListField +from ..types import DjangoObjectType +from .models import Article as ArticleModel +from .models import Reporter as ReporterModel + + +@pytest.mark.django_db +class TestDjangoListField: + def test_only_django_object_types(self): + class TestType(ObjectType): + foo = String() + + with pytest.raises(AssertionError): + list_field = DjangoListField(TestType) + + def test_non_null_type(self): + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + fields = ("first_name",) + + list_field = DjangoListField(NonNull(Reporter)) + + assert isinstance(list_field.type, List) + assert isinstance(list_field.type.of_type, NonNull) + assert list_field.type.of_type.of_type is Reporter + + def test_get_django_model(self): + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + fields = ("first_name",) + + list_field = DjangoListField(Reporter) + assert list_field.model is ReporterModel + + def test_list_field_default_queryset(self): + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + fields = ("first_name",) + + class Query(ObjectType): + reporters = DjangoListField(Reporter) + + schema = Schema(query=Query) + + query = """ + query { + reporters { + firstName + } + } + """ + + ReporterModel.objects.create(first_name="Tara", last_name="West") + ReporterModel.objects.create(first_name="Debra", last_name="Payne") + + result = schema.execute(query) + + assert not result.errors + assert result.data == { + "reporters": [{"firstName": "Tara"}, {"firstName": "Debra"}] + } + + def test_override_resolver(self): + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + fields = ("first_name",) + + class Query(ObjectType): + reporters = DjangoListField(Reporter) + + def resolve_reporters(_, info): + return ReporterModel.objects.filter(first_name="Tara") + + schema = Schema(query=Query) + + query = """ + query { + reporters { + firstName + } + } + """ + + ReporterModel.objects.create(first_name="Tara", last_name="West") + ReporterModel.objects.create(first_name="Debra", last_name="Payne") + + result = schema.execute(query) + + assert not result.errors + assert result.data == {"reporters": [{"firstName": "Tara"}]} + + def test_nested_list_field(self): + class Article(DjangoObjectType): + class Meta: + model = ArticleModel + fields = ("headline",) + + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + fields = ("first_name", "articles") + + class Query(ObjectType): + reporters = DjangoListField(Reporter) + + schema = Schema(query=Query) + + query = """ + query { + reporters { + firstName + articles { + headline + } + } + } + """ + + r1 = ReporterModel.objects.create(first_name="Tara", last_name="West") + ReporterModel.objects.create(first_name="Debra", last_name="Payne") + + ArticleModel.objects.create( + headline="Amazing news", + reporter=r1, + pub_date=datetime.date.today(), + pub_date_time=datetime.datetime.now(), + editor=r1, + ) + + result = schema.execute(query) + + assert not result.errors + assert result.data == { + "reporters": [ + {"firstName": "Tara", "articles": [{"headline": "Amazing news"}]}, + {"firstName": "Debra", "articles": []}, + ] + } + + def test_override_resolver_nested_list_field(self): + class Article(DjangoObjectType): + class Meta: + model = ArticleModel + fields = ("headline",) + + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + fields = ("first_name", "articles") + + def resolve_reporters(reporter, info): + return reporter.articles.all() + + class Query(ObjectType): + reporters = DjangoListField(Reporter) + + schema = Schema(query=Query) + + query = """ + query { + reporters { + firstName + articles { + headline + } + } + } + """ + + r1 = ReporterModel.objects.create(first_name="Tara", last_name="West") + ReporterModel.objects.create(first_name="Debra", last_name="Payne") + + ArticleModel.objects.create( + headline="Amazing news", + reporter=r1, + pub_date=datetime.date.today(), + pub_date_time=datetime.datetime.now(), + editor=r1, + ) + + result = schema.execute(query) + + assert not result.errors + assert result.data == { + "reporters": [ + {"firstName": "Tara", "articles": [{"headline": "Amazing news"}]}, + {"firstName": "Debra", "articles": []}, + ] + } From 4f21750fc227a0b339c932bdae651c22fe133ba8 Mon Sep 17 00:00:00 2001 From: Gilly Ames Date: Sun, 22 Sep 2019 20:43:46 +0100 Subject: [PATCH 108/171] Upgrade graphiql version to fix history tool (#772) Graphiql has a history tool that allows you to save and label favourites, but this version has a bug (fixed https://github.com/graphql/graphiql/issues/750). This change upgrades to the latest version. --- graphene_django/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/views.py b/graphene_django/views.py index aefe114..d2c8324 100644 --- a/graphene_django/views.py +++ b/graphene_django/views.py @@ -51,7 +51,7 @@ def instantiate_middleware(middlewares): class GraphQLView(View): - graphiql_version = "0.13.0" + graphiql_version = "0.14.0" graphiql_template = "graphene/graphiql.html" react_version = "16.8.6" From 0962db5aa60db972a48acd08cbe7ea0945357fce Mon Sep 17 00:00:00 2001 From: Mel van Londen Date: Sun, 22 Sep 2019 13:09:57 -0700 Subject: [PATCH 109/171] =?UTF-8?q?Pin=20higher=20version=20of=20graphene?= =?UTF-8?q?=20for=20proper=20graphql-core=20version=20r=E2=80=A6=20(#768)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index bc7dcd3..a3d0b74 100644 --- a/setup.py +++ b/setup.py @@ -57,7 +57,7 @@ setup( packages=find_packages(exclude=["tests"]), install_requires=[ "six>=1.10.0", - "graphene>=2.1.3,<3", + "graphene>=2.1.7,<3", "graphql-core>=2.1.0,<3", "Django>=1.11", "singledispatch>=3.4.0.3", From cd73cab6991940805372fa92d1c7b01ff9f81489 Mon Sep 17 00:00:00 2001 From: rishabh Date: Mon, 23 Sep 2019 01:40:21 +0530 Subject: [PATCH 110/171] converter.py: Fix typo posgres->postgres (#765) Fixes typo for HStoreField and RangeField converters. --- 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 063d6be..d69c435 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -235,12 +235,12 @@ def convert_postgres_array_to_list(field, registry=None): @convert_django_field.register(HStoreField) @convert_django_field.register(JSONField) -def convert_posgres_field_to_string(field, registry=None): +def convert_postgres_field_to_string(field, registry=None): return JSONString(description=field.help_text, required=not field.null) @convert_django_field.register(RangeField) -def convert_posgres_range_to_string(field, registry=None): +def convert_postgres_range_to_string(field, registry=None): inner_type = convert_django_field(field.base_field) if not isinstance(inner_type, (List, NonNull)): inner_type = type(inner_type) From a64ba65bef7b80b76c0960eb833a4cec83fb1a67 Mon Sep 17 00:00:00 2001 From: Jason Kraus Date: Sun, 22 Sep 2019 13:13:12 -0700 Subject: [PATCH 111/171] convert DRF ChoiceField to Enum (#537) * convert DRF ChoiceField to Enum, also impacts FilePathField * Pep8 fixes * DRF multiple choices field converts to list of enum * apply black formatting --- graphene_django/converter.py | 27 ++++++++++++------- .../rest_framework/serializer_converter.py | 14 +++++++--- .../tests/test_field_converter.py | 19 +++++++++---- 3 files changed, 42 insertions(+), 18 deletions(-) diff --git a/graphene_django/converter.py b/graphene_django/converter.py index d69c435..b59c906 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -1,3 +1,4 @@ +from collections import OrderedDict from django.db import models from django.utils.encoding import force_text @@ -39,6 +40,8 @@ def convert_choice_name(name): def get_choices(choices): converted_names = [] + if isinstance(choices, OrderedDict): + choices = choices.items() for value, help_text in choices: if isinstance(help_text, (tuple, list)): for choice in get_choices(help_text): @@ -52,6 +55,19 @@ def get_choices(choices): yield name, value, description +def convert_choices_to_named_enum_with_descriptions(name, choices): + choices = list(get_choices(choices)) + named_choices = [(c[0], c[1]) for c in choices] + named_choices_descriptions = {c[0]: c[2] for c in choices} + + class EnumWithDescriptionsType(object): + @property + def description(self): + return named_choices_descriptions[self.name] + + return Enum(name, list(named_choices), type=EnumWithDescriptionsType) + + def convert_django_field_with_choices( field, registry=None, convert_choices_to_enum=True ): @@ -63,16 +79,7 @@ def convert_django_field_with_choices( if choices and convert_choices_to_enum: meta = field.model._meta name = to_camel_case("{}_{}".format(meta.object_name, field.name)) - choices = list(get_choices(choices)) - named_choices = [(c[0], c[1]) for c in choices] - named_choices_descriptions = {c[0]: c[2] for c in choices} - - class EnumWithDescriptionsType(object): - @property - def description(self): - return named_choices_descriptions[self.name] - - enum = Enum(name, list(named_choices), type=EnumWithDescriptionsType) + enum = convert_choices_to_named_enum_with_descriptions(name, choices) required = not (field.blank or field.null) converted = enum(description=field.help_text, required=required) else: diff --git a/graphene_django/rest_framework/serializer_converter.py b/graphene_django/rest_framework/serializer_converter.py index c419419..caeb7dd 100644 --- a/graphene_django/rest_framework/serializer_converter.py +++ b/graphene_django/rest_framework/serializer_converter.py @@ -4,6 +4,7 @@ from rest_framework import serializers import graphene from ..registry import get_global_registry +from ..converter import convert_choices_to_named_enum_with_descriptions from ..utils import import_single_dispatch from .types import DictType @@ -130,7 +131,6 @@ def convert_serializer_field_to_time(field): @get_graphene_type_from_serializer_field.register(serializers.ListField) def convert_serializer_field_to_list(field, is_input=True): child_type = get_graphene_type_from_serializer_field(field.child) - return (graphene.List, child_type) @@ -145,5 +145,13 @@ def convert_serializer_field_to_jsonstring(field): @get_graphene_type_from_serializer_field.register(serializers.MultipleChoiceField) -def convert_serializer_field_to_list_of_string(field): - return (graphene.List, graphene.String) +def convert_serializer_field_to_list_of_enum(field): + child_type = convert_serializer_field_to_enum(field) + return (graphene.List, child_type) + + +@get_graphene_type_from_serializer_field.register(serializers.ChoiceField) +def convert_serializer_field_to_enum(field): + # enums require a name + name = field.field_name or field.source or "Choices" + return convert_choices_to_named_enum_with_descriptions(name, field.choices) diff --git a/graphene_django/rest_framework/tests/test_field_converter.py b/graphene_django/rest_framework/tests/test_field_converter.py index 6fa4ca8..82f5b63 100644 --- a/graphene_django/rest_framework/tests/test_field_converter.py +++ b/graphene_django/rest_framework/tests/test_field_converter.py @@ -60,8 +60,17 @@ def test_should_url_convert_string(): assert_conversion(serializers.URLField, graphene.String) -def test_should_choice_convert_string(): - assert_conversion(serializers.ChoiceField, graphene.String, choices=[]) +def test_should_choice_convert_enum(): + field = assert_conversion( + serializers.ChoiceField, + graphene.Enum, + choices=[("h", "Hello"), ("w", "World")], + source="word", + ) + assert field._meta.enum.__members__["H"].value == "h" + assert field._meta.enum.__members__["H"].description == "Hello" + assert field._meta.enum.__members__["W"].value == "w" + assert field._meta.enum.__members__["W"].description == "World" def test_should_base_field_convert_string(): @@ -174,7 +183,7 @@ def test_should_file_convert_string(): def test_should_filepath_convert_string(): - assert_conversion(serializers.FilePathField, graphene.String, path="/") + assert_conversion(serializers.FilePathField, graphene.Enum, path="/") def test_should_ip_convert_string(): @@ -189,9 +198,9 @@ def test_should_json_convert_jsonstring(): assert_conversion(serializers.JSONField, graphene.types.json.JSONString) -def test_should_multiplechoicefield_convert_to_list_of_string(): +def test_should_multiplechoicefield_convert_to_list_of_enum(): field = assert_conversion( serializers.MultipleChoiceField, graphene.List, choices=[1, 2, 3] ) - assert field.of_type == graphene.String + assert issubclass(field.of_type, graphene.Enum) From e4cf59ecec5a47c6986d36241d8e87acb25152ff Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Sun, 22 Sep 2019 21:14:59 +0100 Subject: [PATCH 112/171] Handle isnull filters differently (#753) * Handle isnull filters differently * Change to rsplit --- graphene_django/filter/tests/test_fields.py | 54 ++++++++++++++++++++- graphene_django/filter/utils.py | 11 ++++- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/graphene_django/filter/tests/test_fields.py b/graphene_django/filter/tests/test_fields.py index aa6a903..1ffa0f4 100644 --- a/graphene_django/filter/tests/test_fields.py +++ b/graphene_django/filter/tests/test_fields.py @@ -56,8 +56,6 @@ if DJANGO_FILTER_INSTALLED: model = Pet interfaces = (Node,) - # schema = Schema() - def get_args(field): return field.args @@ -820,6 +818,58 @@ def test_integer_field_filter_type(): ) +def test_other_filter_types(): + class PetType(DjangoObjectType): + class Meta: + model = Pet + interfaces = (Node,) + filter_fields = {"age": ["exact", "isnull", "lt"]} + fields = ("age",) + + class Query(ObjectType): + pets = DjangoFilterConnectionField(PetType) + + schema = Schema(query=Query) + + assert str(schema) == dedent( + """\ + schema { + query: Query + } + + interface Node { + id: ID! + } + + type PageInfo { + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String + endCursor: String + } + + type PetType implements Node { + age: Int! + id: ID! + } + + type PetTypeConnection { + pageInfo: PageInfo! + edges: [PetTypeEdge]! + } + + type PetTypeEdge { + node: PetType + cursor: String! + } + + type Query { + pets(before: String, after: String, first: Int, last: Int, age: Int, age_Isnull: Boolean, age_Lt: Int): PetTypeConnection + } + """ + ) + + def test_filter_filterset_based_on_mixin(): class ArticleFilterMixin(FilterSet): @classmethod diff --git a/graphene_django/filter/utils.py b/graphene_django/filter/utils.py index 81efb63..abb03a9 100644 --- a/graphene_django/filter/utils.py +++ b/graphene_django/filter/utils.py @@ -18,9 +18,16 @@ def get_filtering_args_from_filterset(filterset_class, type): if name in filterset_class.declared_filters: form_field = filter_field.field else: - field_name = name.split("__", 1)[0] + try: + field_name, filter_type = name.rsplit("__", 1) + except ValueError: + field_name = name + filter_type = None - if hasattr(model, field_name): + # 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"): From 5068ea05c323ad3962fb6d1d8e36c3b2b134e90f Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Sun, 22 Sep 2019 21:17:44 +0100 Subject: [PATCH 113/171] v2.6.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 659cc79..7650dd2 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.5.0" +__version__ = "2.6.0" __all__ = ["__version__", "DjangoObjectType", "DjangoConnectionField"] From 8d95596ffbb10712e4911dae79ecb4703c743a13 Mon Sep 17 00:00:00 2001 From: Jens Diemer Date: Tue, 1 Oct 2019 15:59:52 +0200 Subject: [PATCH 114/171] 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 115/171] 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 116/171] 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 117/171] 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 118/171] 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 119/171] =?UTF-8?q?django-filter:=20resolve=20field=20alon?= =?UTF-8?q?g=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 120/171] 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 121/171] 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 122/171] 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 123/171] 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 124/171] 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 125/171] 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 126/171] 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 127/171] 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 128/171] 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 129/171] 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 130/171] 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 131/171] 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 132/171] 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 133/171] 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 134/171] 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 135/171] 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 136/171] 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 137/171] 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 138/171] 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 139/171] 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 140/171] 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 141/171] 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 142/171] 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 143/171] 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 144/171] 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.