From 219005952a1d143fce6363be3d4d3bc8f8d0ecc5 Mon Sep 17 00:00:00 2001 From: Dan Palmer Date: Thu, 30 Aug 2018 19:29:33 +0100 Subject: [PATCH 01/24] 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 02/24] 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 03/24] 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 04/24] 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 05/24] 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 06/24] 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 07/24] 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 08/24] 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 09/24] 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 10/24] 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 11/24] 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 12/24] 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 13/24] 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 14/24] 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 15/24] 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 16/24] 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 17/24] 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 18/24] 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 19/24] 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 d5d0c519ceaf18e470cd4e2e9cf5270d7a9b079d Mon Sep 17 00:00:00 2001 From: Ronny Vedrilla Date: Wed, 27 Mar 2019 15:21:15 +0100 Subject: [PATCH 20/24] 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 fcc3de2a90bb81a7a02f9099029da3e4aa82b06e Mon Sep 17 00:00:00 2001 From: Gary Donovan Date: Sun, 31 Mar 2019 21:30:29 +1100 Subject: [PATCH 21/24] 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 22/24] 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 23/24] 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 24/24] 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: