From 87422a0e40b41ba104ab0bdcc4bb27c348f17e11 Mon Sep 17 00:00:00 2001 From: Beau Barker Date: Mon, 31 Oct 2016 13:05:30 +1100 Subject: [PATCH 01/18] Display variables as JSON in GraphiQL Closes #36 --- 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 cec3aab..0399fec 100644 --- a/graphene_django/views.py +++ b/graphene_django/views.py @@ -131,7 +131,7 @@ class GraphQLView(View): request, graphiql_version=self.graphiql_version, query=query or '', - variables=variables or '', + variables=json.dumps(variables) or '', operation_name=operation_name or '', result=result or '' ) From 0a18558bf6f92d5ddf38b8d8937d5194e1490579 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ochman?= Date: Mon, 31 Oct 2016 11:56:51 +0100 Subject: [PATCH 02/18] Add support for batching several requests into one Batch format compatible with ReactRelayNetworkLayer (https://github.com/nodkz/react-relay-network-layer) --- graphene_django/tests/test_views.py | 78 +++++++++++++++++++++++++-- graphene_django/tests/urls.py | 1 + graphene_django/views.py | 81 +++++++++++++++++++---------- 3 files changed, 129 insertions(+), 31 deletions(-) diff --git a/graphene_django/tests/test_views.py b/graphene_django/tests/test_views.py index 1f9b5de..e7ec187 100644 --- a/graphene_django/tests/test_views.py +++ b/graphene_django/tests/test_views.py @@ -8,20 +8,23 @@ except ImportError: from urllib.parse import urlencode -def url_string(**url_params): - string = '/graphql' - +def url_string(string='/graphql', **url_params): if url_params: string += '?' + urlencode(url_params) return string +def batch_url_string(**url_params): + return url_string('/graphql/batch', **url_params) + + def response_json(response): return json.loads(response.content.decode()) j = lambda **kwargs: json.dumps(kwargs) +jl = lambda **kwargs: json.dumps([kwargs]) def test_graphiql_is_enabled(client): @@ -169,6 +172,17 @@ def test_allows_post_with_json_encoding(client): } +def test_batch_allows_post_with_json_encoding(client): + response = client.post(batch_url_string(), jl(id=1, query='{test}'), 'application/json') + + assert response.status_code == 200 + assert response_json(response) == [{ + 'id': 1, + 'payload': { 'data': {'test': "Hello World"} }, + 'status': 200, + }] + + def test_allows_sending_a_mutation_via_post(client): response = client.post(url_string(), j(query='mutation TestMutation { writeTest { test } }'), 'application/json') @@ -199,6 +213,22 @@ def test_supports_post_json_query_with_string_variables(client): } + +def test_batch_supports_post_json_query_with_string_variables(client): + response = client.post(batch_url_string(), jl( + id=1, + query='query helloWho($who: String){ test(who: $who) }', + variables=json.dumps({'who': "Dolly"}) + ), 'application/json') + + assert response.status_code == 200 + assert response_json(response) == [{ + 'id': 1, + 'payload': { 'data': {'test': "Hello Dolly"} }, + 'status': 200, + }] + + def test_supports_post_json_query_with_json_variables(client): response = client.post(url_string(), j( query='query helloWho($who: String){ test(who: $who) }', @@ -211,6 +241,21 @@ def test_supports_post_json_query_with_json_variables(client): } +def test_batch_supports_post_json_query_with_json_variables(client): + response = client.post(batch_url_string(), jl( + id=1, + query='query helloWho($who: String){ test(who: $who) }', + variables={'who': "Dolly"} + ), 'application/json') + + assert response.status_code == 200 + assert response_json(response) == [{ + 'id': 1, + 'payload': { 'data': {'test': "Hello Dolly"} }, + 'status': 200, + }] + + def test_supports_post_url_encoded_query_with_string_variables(client): response = client.post(url_string(), urlencode(dict( query='query helloWho($who: String){ test(who: $who) }', @@ -285,6 +330,33 @@ def test_allows_post_with_operation_name(client): } +def test_batch_allows_post_with_operation_name(client): + response = client.post(batch_url_string(), jl( + id=1, + query=''' + query helloYou { test(who: "You"), ...shared } + query helloWorld { test(who: "World"), ...shared } + query helloDolly { test(who: "Dolly"), ...shared } + fragment shared on QueryRoot { + shared: test(who: "Everyone") + } + ''', + operationName='helloWorld' + ), 'application/json') + + assert response.status_code == 200 + assert response_json(response) == [{ + 'id': 1, + 'payload': { + 'data': { + 'test': 'Hello World', + 'shared': 'Hello Everyone' + } + }, + 'status': 200, + }] + + def test_allows_post_with_get_operation_name(client): response = client.post(url_string( operationName='helloWorld' diff --git a/graphene_django/tests/urls.py b/graphene_django/tests/urls.py index ff4459e..8597baa 100644 --- a/graphene_django/tests/urls.py +++ b/graphene_django/tests/urls.py @@ -3,5 +3,6 @@ from django.conf.urls import url from ..views import GraphQLView urlpatterns = [ + url(r'^graphql/batch', GraphQLView.as_view(batch=True)), url(r'^graphql', GraphQLView.as_view(graphiql=True)), ] diff --git a/graphene_django/views.py b/graphene_django/views.py index cec3aab..b6344de 100644 --- a/graphene_django/views.py +++ b/graphene_django/views.py @@ -62,8 +62,10 @@ class GraphQLView(View): middleware = None root_value = None pretty = False + batch = False - def __init__(self, schema=None, executor=None, middleware=None, root_value=None, graphiql=False, pretty=False): + def __init__(self, schema=None, executor=None, middleware=None, root_value=None, graphiql=False, pretty=False, + batch=False): if not schema: schema = graphene_settings.SCHEMA @@ -77,8 +79,10 @@ class GraphQLView(View): self.root_value = root_value self.pretty = pretty self.graphiql = graphiql + self.batch = batch assert isinstance(self.schema, GraphQLSchema), 'A Schema is required to be provided to GraphQLView.' + assert not all((graphiql, batch)), 'Use either graphiql or batch processing' # noinspection PyUnusedLocal def get_root_value(self, request): @@ -99,32 +103,12 @@ class GraphQLView(View): data = self.parse_body(request) show_graphiql = self.graphiql and self.can_display_graphiql(request, data) - query, variables, operation_name = self.get_graphql_params(request, data) - - execution_result = self.execute_graphql_request( - request, - data, - query, - variables, - operation_name, - show_graphiql - ) - - if execution_result: - response = {} - - if execution_result.errors: - response['errors'] = [self.format_error(e) for e in execution_result.errors] - - if execution_result.invalid: - status_code = 400 - else: - status_code = 200 - response['data'] = execution_result.data - - result = self.json_encode(request, response, pretty=show_graphiql) + if self.batch: + responses = [self.get_response(request, entry) for entry in data] + result = '[{}]'.format(','.join([response[0] for response in responses])) + status_code = max(responses, key=lambda response: response[1])[1] else: - result = None + result, status_code = self.get_response(request, data, show_graphiql) if show_graphiql: return self.render_graphiql( @@ -150,6 +134,43 @@ class GraphQLView(View): }) return response + def get_response(self, request, data, show_graphiql=False): + query, variables, operation_name, id = self.get_graphql_params(request, data) + + execution_result = self.execute_graphql_request( + request, + data, + query, + variables, + operation_name, + show_graphiql + ) + + if execution_result: + response = {} + + if execution_result.errors: + response['errors'] = [self.format_error(e) for e in execution_result.errors] + + if execution_result.invalid: + status_code = 400 + else: + status_code = 200 + response['data'] = execution_result.data + + if self.batch: + response = { + 'id': id, + 'payload': response, + 'status': status_code, + } + + result = self.json_encode(request, response, pretty=show_graphiql) + else: + result = None + + return result, status_code + def render_graphiql(self, request, **data): return render(request, self.graphiql_template, data) @@ -170,7 +191,10 @@ class GraphQLView(View): elif content_type == 'application/json': try: request_json = json.loads(request.body.decode('utf-8')) - assert isinstance(request_json, dict) + if self.batch: + assert isinstance(request_json, list) + else: + assert isinstance(request_json, dict) return request_json except: raise HttpError(HttpResponseBadRequest('POST body sent invalid JSON.')) @@ -242,6 +266,7 @@ class GraphQLView(View): def get_graphql_params(request, data): query = request.GET.get('query') or data.get('query') variables = request.GET.get('variables') or data.get('variables') + id = request.GET.get('id') or data.get('id') if variables and isinstance(variables, six.text_type): try: @@ -251,7 +276,7 @@ class GraphQLView(View): operation_name = request.GET.get('operationName') or data.get('operationName') - return query, variables, operation_name + return query, variables, operation_name, id @staticmethod def format_error(error): From 6bd89f2c78caae00cf8e0fcb9ba3f1b3dfc5fc03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ochman?= Date: Mon, 31 Oct 2016 12:16:58 +0100 Subject: [PATCH 03/18] Fix UnboundLocalError occurrences --- graphene_django/views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/graphene_django/views.py b/graphene_django/views.py index b6344de..3a363e7 100644 --- a/graphene_django/views.py +++ b/graphene_django/views.py @@ -111,6 +111,7 @@ class GraphQLView(View): 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, @@ -146,6 +147,7 @@ class GraphQLView(View): show_graphiql ) + status_code = 200 if execution_result: response = {} @@ -155,7 +157,6 @@ class GraphQLView(View): if execution_result.invalid: status_code = 400 else: - status_code = 200 response['data'] = execution_result.data if self.batch: From 65f2dabdb6acaf334c4e79249140f6efad9fa381 Mon Sep 17 00:00:00 2001 From: chriscauley Date: Thu, 3 Nov 2016 12:20:40 -0400 Subject: [PATCH 04/18] tutorial was missing import --- README.md | 1 + README.rst | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index e766491..94278a3 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ To create a GraphQL schema for it you simply have to write the following: ```python from graphene_django import DjangoObjectType +import graphene class User(DjangoObjectType): class Meta: diff --git a/README.rst b/README.rst index 176c6a8..976298c 100644 --- a/README.rst +++ b/README.rst @@ -68,6 +68,7 @@ following: .. code:: python from graphene_django import DjangoObjectType + import graphene class User(DjangoObjectType): class Meta: From d3057cd9dc7384cb5ba9f9a4880415e3747f59d1 Mon Sep 17 00:00:00 2001 From: chriscauley Date: Thu, 3 Nov 2016 17:31:28 -0400 Subject: [PATCH 05/18] fixes #8, base import of field no longer works for query property. Use Node.Field instead --- examples/cookbook/cookbook/ingredients/schema.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/cookbook/cookbook/ingredients/schema.py b/examples/cookbook/cookbook/ingredients/schema.py index f79ac0b..5d9a9a5 100644 --- a/examples/cookbook/cookbook/ingredients/schema.py +++ b/examples/cookbook/cookbook/ingredients/schema.py @@ -1,5 +1,5 @@ from cookbook.ingredients.models import Category, Ingredient -from graphene import AbstractType, Field, Node +from graphene import AbstractType, Node from graphene_django.filter import DjangoFilterConnectionField from graphene_django.types import DjangoObjectType @@ -31,8 +31,8 @@ class IngredientNode(DjangoObjectType): class Query(AbstractType): - category = Field(CategoryNode) + category = Node.Field(CategoryNode) all_categories = DjangoFilterConnectionField(CategoryNode) - ingredient = Field(IngredientNode) + ingredient = Node.Field(IngredientNode) all_ingredients = DjangoFilterConnectionField(IngredientNode) From 5ac046a715cdce60abdcbbb5165ef979cabb1fe1 Mon Sep 17 00:00:00 2001 From: chriscauley Date: Thu, 3 Nov 2016 18:06:23 -0400 Subject: [PATCH 06/18] adding recipe schema --- examples/cookbook/cookbook/recipes/schema.py | 32 ++++++++++++++++++++ examples/cookbook/cookbook/schema.py | 3 +- 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 examples/cookbook/cookbook/recipes/schema.py diff --git a/examples/cookbook/cookbook/recipes/schema.py b/examples/cookbook/cookbook/recipes/schema.py new file mode 100644 index 0000000..0eff9e4 --- /dev/null +++ b/examples/cookbook/cookbook/recipes/schema.py @@ -0,0 +1,32 @@ +from cookbook.recipes.models import Recipe, RecipeIngredient +from graphene import AbstractType, Node +from graphene_django.filter import DjangoFilterConnectionField +from graphene_django.types import DjangoObjectType + +class RecipeNode(DjangoObjectType): + + class Meta: + model = Recipe + interfaces = (Node, ) + filter_fields = ['title','amounts'] + filter_order_by = ['title'] + +class RecipeIngredientNode(DjangoObjectType): + + class Meta: + model = RecipeIngredient + # Allow for some more advanced filtering here + interfaces = (Node, ) + filter_fields = { + 'ingredient__name': ['exact', 'icontains', 'istartswith'], + 'recipe': ['exact'], + 'recipe__name': ['icontains'], + } + filter_order_by = ['ingredient__name', 'recipe__name',] + +class Query(AbstractType): + recipe = Node.Field(RecipeNode) + all_recipes = DjangoFilterConnectionField(RecipeNode) + + recipeingredient = Node.Field(RecipeIngredientNode) + all_recipeingredients = DjangoFilterConnectionField(RecipeIngredientNode) diff --git a/examples/cookbook/cookbook/schema.py b/examples/cookbook/cookbook/schema.py index 55fae16..910e259 100644 --- a/examples/cookbook/cookbook/schema.py +++ b/examples/cookbook/cookbook/schema.py @@ -1,10 +1,11 @@ import cookbook.ingredients.schema +import cookbook.recipes.schema import graphene from graphene_django.debug import DjangoDebug -class Query(cookbook.ingredients.schema.Query, graphene.ObjectType): +class Query(cookbook.recipes.schema.Query, cookbook.ingredients.schema.Query, graphene.ObjectType): debug = graphene.Field(DjangoDebug, name='__debug') From 0ceef4626857d6844bdad22919c5d688b8f0062b Mon Sep 17 00:00:00 2001 From: chriscauley Date: Thu, 3 Nov 2016 20:59:40 -0400 Subject: [PATCH 07/18] a few admin changes to make entering data easier --- .../cookbook/cookbook/ingredients/admin.py | 6 +++++- .../migrations/0002_auto_20161104_0050.py | 20 +++++++++++++++++++ .../cookbook/cookbook/ingredients/models.py | 2 +- examples/cookbook/cookbook/recipes/admin.py | 8 ++++++-- examples/cookbook/cookbook/recipes/models.py | 5 +++-- 5 files changed, 35 insertions(+), 6 deletions(-) create mode 100644 examples/cookbook/cookbook/ingredients/migrations/0002_auto_20161104_0050.py diff --git a/examples/cookbook/cookbook/ingredients/admin.py b/examples/cookbook/cookbook/ingredients/admin.py index 766b23f..2b16cdc 100644 --- a/examples/cookbook/cookbook/ingredients/admin.py +++ b/examples/cookbook/cookbook/ingredients/admin.py @@ -2,5 +2,9 @@ from django.contrib import admin from cookbook.ingredients.models import Category, Ingredient -admin.site.register(Ingredient) +@admin.register(Ingredient) +class IngredientAdmin(admin.ModelAdmin): + list_display = ("id","name","category") + list_editable = ("name","category") + admin.site.register(Category) diff --git a/examples/cookbook/cookbook/ingredients/migrations/0002_auto_20161104_0050.py b/examples/cookbook/cookbook/ingredients/migrations/0002_auto_20161104_0050.py new file mode 100644 index 0000000..359d4fc --- /dev/null +++ b/examples/cookbook/cookbook/ingredients/migrations/0002_auto_20161104_0050.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9 on 2016-11-04 00:50 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ingredients', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='ingredient', + name='notes', + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/examples/cookbook/cookbook/ingredients/models.py b/examples/cookbook/cookbook/ingredients/models.py index cffdf1e..a072bcf 100644 --- a/examples/cookbook/cookbook/ingredients/models.py +++ b/examples/cookbook/cookbook/ingredients/models.py @@ -10,7 +10,7 @@ class Category(models.Model): class Ingredient(models.Model): name = models.CharField(max_length=100) - notes = models.TextField() + notes = models.TextField(null=True,blank=True) category = models.ForeignKey(Category, related_name='ingredients') def __str__(self): diff --git a/examples/cookbook/cookbook/recipes/admin.py b/examples/cookbook/cookbook/recipes/admin.py index 862dd4c..57e0418 100644 --- a/examples/cookbook/cookbook/recipes/admin.py +++ b/examples/cookbook/cookbook/recipes/admin.py @@ -2,5 +2,9 @@ from django.contrib import admin from cookbook.recipes.models import Recipe, RecipeIngredient -admin.site.register(Recipe) -admin.site.register(RecipeIngredient) +class RecipeIngredientInline(admin.TabularInline): + model = RecipeIngredient + +@admin.register(Recipe) +class RecipeAdmin(admin.ModelAdmin): + inlines = [RecipeIngredientInline] diff --git a/examples/cookbook/cookbook/recipes/models.py b/examples/cookbook/cookbook/recipes/models.py index a767dd2..8e7f799 100644 --- a/examples/cookbook/cookbook/recipes/models.py +++ b/examples/cookbook/cookbook/recipes/models.py @@ -6,14 +6,15 @@ from cookbook.ingredients.models import Ingredient class Recipe(models.Model): title = models.CharField(max_length=100) instructions = models.TextField() - + __unicode__ = lambda self: self.title class RecipeIngredient(models.Model): recipes = 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'), - ('', 'Units'), + ('st', 'Shots'), )) From ad063aa9472d28a96487ef96f031721bd7437c04 Mon Sep 17 00:00:00 2001 From: chriscauley Date: Thu, 3 Nov 2016 21:01:27 -0400 Subject: [PATCH 08/18] adding some dummy data --- examples/cookbook/dummy_data.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 examples/cookbook/dummy_data.json diff --git a/examples/cookbook/dummy_data.json b/examples/cookbook/dummy_data.json new file mode 100644 index 0000000..f541da5 --- /dev/null +++ b/examples/cookbook/dummy_data.json @@ -0,0 +1 @@ +[{"model": "auth.user", "pk": 1, "fields": {"password": "pbkdf2_sha256$24000$0SgBlSlnbv5c$ijVQipm2aNDlcrTL8Qi3SVNHphTm4HIsDfUi4kn9tog=", "last_login": "2016-11-04T00:46:58Z", "is_superuser": true, "username": "admin", "first_name": "", "last_name": "", "email": "asdf@example.com", "is_staff": true, "is_active": true, "date_joined": "2016-11-03T18:24:40Z", "groups": [], "user_permissions": []}}, {"model": "recipes.recipe", "pk": 1, "fields": {"title": "Cheerios With a Shot of Vermouth", "instructions": "https://xkcd.com/720/"}}, {"model": "recipes.recipe", "pk": 2, "fields": {"title": "Quail Eggs in Whipped Cream and MSG", "instructions": "https://xkcd.com/720/"}}, {"model": "recipes.recipe", "pk": 3, "fields": {"title": "Deep Fried Skittles", "instructions": "https://xkcd.com/720/"}}, {"model": "recipes.recipe", "pk": 4, "fields": {"title": "Newt ala Doritos", "instructions": "https://xkcd.com/720/"}}, {"model": "recipes.recipe", "pk": 5, "fields": {"title": "Fruit Salad", "instructions": "Chop up and add together"}}, {"model": "recipes.recipeingredient", "pk": 1, "fields": {"recipes": 5, "ingredient": 9, "amount": 1.0, "unit": "unit"}}, {"model": "recipes.recipeingredient", "pk": 2, "fields": {"recipes": 5, "ingredient": 10, "amount": 2.0, "unit": "unit"}}, {"model": "recipes.recipeingredient", "pk": 3, "fields": {"recipes": 5, "ingredient": 7, "amount": 3.0, "unit": "unit"}}, {"model": "recipes.recipeingredient", "pk": 4, "fields": {"recipes": 5, "ingredient": 8, "amount": 4.0, "unit": "unit"}}, {"model": "recipes.recipeingredient", "pk": 5, "fields": {"recipes": 4, "ingredient": 5, "amount": 1.0, "unit": "kg"}}, {"model": "recipes.recipeingredient", "pk": 6, "fields": {"recipes": 4, "ingredient": 6, "amount": 2.0, "unit": "l"}}, {"model": "recipes.recipeingredient", "pk": 7, "fields": {"recipes": 3, "ingredient": 4, "amount": 1.0, "unit": "unit"}}, {"model": "recipes.recipeingredient", "pk": 8, "fields": {"recipes": 2, "ingredient": 2, "amount": 1.0, "unit": "kg"}}, {"model": "recipes.recipeingredient", "pk": 9, "fields": {"recipes": 2, "ingredient": 11, "amount": 2.0, "unit": "l"}}, {"model": "recipes.recipeingredient", "pk": 10, "fields": {"recipes": 2, "ingredient": 12, "amount": 3.0, "unit": "st"}}, {"model": "recipes.recipeingredient", "pk": 11, "fields": {"recipes": 1, "ingredient": 1, "amount": 1.0, "unit": "kg"}}, {"model": "recipes.recipeingredient", "pk": 12, "fields": {"recipes": 1, "ingredient": 3, "amount": 1.0, "unit": "st"}}, {"model": "ingredients.category", "pk": 1, "fields": {"name": "fruit"}}, {"model": "ingredients.category", "pk": 3, "fields": {"name": "xkcd"}}, {"model": "ingredients.ingredient", "pk": 1, "fields": {"name": "Cheerios", "notes": "this is a note", "category": 3}}, {"model": "ingredients.ingredient", "pk": 2, "fields": {"name": "Quail Eggs", "notes": "has more notes", "category": 3}}, {"model": "ingredients.ingredient", "pk": 3, "fields": {"name": "Vermouth", "notes": "", "category": 3}}, {"model": "ingredients.ingredient", "pk": 4, "fields": {"name": "Skittles", "notes": "", "category": 3}}, {"model": "ingredients.ingredient", "pk": 5, "fields": {"name": "Newt", "notes": "Braised and Confuesd", "category": 3}}, {"model": "ingredients.ingredient", "pk": 6, "fields": {"name": "Doritos", "notes": "Crushed", "category": 3}}, {"model": "ingredients.ingredient", "pk": 7, "fields": {"name": "Apple", "notes": "", "category": 1}}, {"model": "ingredients.ingredient", "pk": 8, "fields": {"name": "Orange", "notes": "", "category": 1}}, {"model": "ingredients.ingredient", "pk": 9, "fields": {"name": "Banana", "notes": "", "category": 1}}, {"model": "ingredients.ingredient", "pk": 10, "fields": {"name": "Grapes", "notes": "", "category": 1}}, {"model": "ingredients.ingredient", "pk": 11, "fields": {"name": "Whipped Cream", "notes": "", "category": 3}}, {"model": "ingredients.ingredient", "pk": 12, "fields": {"name": "MSG", "notes": "", "category": 3}}] \ No newline at end of file From fa178a00efc9848e8a0c18c801fcd843097995bb Mon Sep 17 00:00:00 2001 From: chriscauley Date: Thu, 3 Nov 2016 21:06:26 -0400 Subject: [PATCH 09/18] errors in code --- .../migrations/0002_auto_20161104_0106.py | 25 ++++++++++++++ examples/cookbook/cookbook/recipes/models.py | 2 +- examples/cookbook/cookbook/recipes/schema.py | 4 +-- examples/cookbook/cookbook/recipes/schema.py~ | 33 +++++++++++++++++++ 4 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 examples/cookbook/cookbook/recipes/migrations/0002_auto_20161104_0106.py create mode 100644 examples/cookbook/cookbook/recipes/schema.py~ diff --git a/examples/cookbook/cookbook/recipes/migrations/0002_auto_20161104_0106.py b/examples/cookbook/cookbook/recipes/migrations/0002_auto_20161104_0106.py new file mode 100644 index 0000000..f135392 --- /dev/null +++ b/examples/cookbook/cookbook/recipes/migrations/0002_auto_20161104_0106.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9 on 2016-11-04 01:06 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recipes', '0001_initial'), + ] + + operations = [ + migrations.RenameField( + model_name='recipeingredient', + old_name='recipes', + new_name='recipe', + ), + migrations.AlterField( + model_name='recipeingredient', + name='unit', + field=models.CharField(choices=[(b'unit', b'Units'), (b'kg', b'Kilograms'), (b'l', b'Litres'), (b'st', b'Shots')], max_length=20), + ), + ] diff --git a/examples/cookbook/cookbook/recipes/models.py b/examples/cookbook/cookbook/recipes/models.py index 8e7f799..f666fe8 100644 --- a/examples/cookbook/cookbook/recipes/models.py +++ b/examples/cookbook/cookbook/recipes/models.py @@ -9,7 +9,7 @@ class Recipe(models.Model): __unicode__ = lambda self: self.title class RecipeIngredient(models.Model): - recipes = models.ForeignKey(Recipe, related_name='amounts') + 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=( diff --git a/examples/cookbook/cookbook/recipes/schema.py b/examples/cookbook/cookbook/recipes/schema.py index 0eff9e4..56379ab 100644 --- a/examples/cookbook/cookbook/recipes/schema.py +++ b/examples/cookbook/cookbook/recipes/schema.py @@ -20,9 +20,9 @@ class RecipeIngredientNode(DjangoObjectType): filter_fields = { 'ingredient__name': ['exact', 'icontains', 'istartswith'], 'recipe': ['exact'], - 'recipe__name': ['icontains'], + 'recipe__title': ['icontains'], } - filter_order_by = ['ingredient__name', 'recipe__name',] + filter_order_by = ['ingredient__name', 'recipe__title',] class Query(AbstractType): recipe = Node.Field(RecipeNode) diff --git a/examples/cookbook/cookbook/recipes/schema.py~ b/examples/cookbook/cookbook/recipes/schema.py~ new file mode 100644 index 0000000..6bd1541 --- /dev/null +++ b/examples/cookbook/cookbook/recipes/schema.py~ @@ -0,0 +1,33 @@ +from cookbook.ingredients.models import Recipe, Ingredient +from graphene import AbstractType, Node +from graphene_django.filter import DjangoFilterConnectionField +from graphene_django.types import DjangoObjectType + +class RecipeNode(DjangoObjectType): + + class Meta: + model = Recipe + interfaces = (Node, ) + filter_fields = ['name', 'ingredients'] + filter_order_by = ['name'] + +class RecipeIngredientNode(DjangoObjectType): + + class Meta: + model = RecipeIngredient + # Allow for some more advanced filtering here + interfaces = (Node, ) + filter_fields = { + 'name': ['exact', 'icontains', 'istartswith'], + 'notes': ['exact', 'icontains'], + 'recipe': ['exact'], + 'recipe__name': ['icontains'], + } + filter_order_by = ['name', 'recipe__name',] + +class Query(AbstractType): + recipe = Node.Field(RecipeNode) + all_categories = DjangoFilterConnectionField(RecipeNode) + + recipeingredient = Node.Field(IngredientNode) + all_recipeingredients = DjangoFilterConnectionField(RecipeIngredientNode) From f9f8eca8042400387a332ed74fc2631709acc8e6 Mon Sep 17 00:00:00 2001 From: drzix Date: Sat, 5 Nov 2016 22:21:34 +0900 Subject: [PATCH 10/18] Fix graphql query guide url Signed-off-by: drzix --- examples/cookbook/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/cookbook/README.md b/examples/cookbook/README.md index ea971e0..1fdae7d 100644 --- a/examples/cookbook/README.md +++ b/examples/cookbook/README.md @@ -60,5 +60,5 @@ Now you should be ready to start the server: Now head on over to [http://127.0.0.1:8000/graphql](http://127.0.0.1:8000/graphql) and run some queries! -(See the [Django quickstart guide](http://graphene-python.org/docs/quickstart-django/) +(See the [Graphene-Django Tutorial](http://docs.graphene-python.org/projects/django/en/latest/tutorial.html#testing-our-graphql-schema) for some example queries) From 810de7b26af37244990adbae5a8ed8f2077e689e Mon Sep 17 00:00:00 2001 From: drzix Date: Sun, 6 Nov 2016 02:08:25 +0900 Subject: [PATCH 11/18] Remove an unneeded backup file Signed-off-by: drzix --- examples/cookbook/cookbook/recipes/schema.py~ | 33 ------------------- 1 file changed, 33 deletions(-) delete mode 100644 examples/cookbook/cookbook/recipes/schema.py~ diff --git a/examples/cookbook/cookbook/recipes/schema.py~ b/examples/cookbook/cookbook/recipes/schema.py~ deleted file mode 100644 index 6bd1541..0000000 --- a/examples/cookbook/cookbook/recipes/schema.py~ +++ /dev/null @@ -1,33 +0,0 @@ -from cookbook.ingredients.models import Recipe, Ingredient -from graphene import AbstractType, Node -from graphene_django.filter import DjangoFilterConnectionField -from graphene_django.types import DjangoObjectType - -class RecipeNode(DjangoObjectType): - - class Meta: - model = Recipe - interfaces = (Node, ) - filter_fields = ['name', 'ingredients'] - filter_order_by = ['name'] - -class RecipeIngredientNode(DjangoObjectType): - - class Meta: - model = RecipeIngredient - # Allow for some more advanced filtering here - interfaces = (Node, ) - filter_fields = { - 'name': ['exact', 'icontains', 'istartswith'], - 'notes': ['exact', 'icontains'], - 'recipe': ['exact'], - 'recipe__name': ['icontains'], - } - filter_order_by = ['name', 'recipe__name',] - -class Query(AbstractType): - recipe = Node.Field(RecipeNode) - all_categories = DjangoFilterConnectionField(RecipeNode) - - recipeingredient = Node.Field(IngredientNode) - all_recipeingredients = DjangoFilterConnectionField(RecipeIngredientNode) From 6801b69ce94636d70f57321ac0e0410fcaeb9bcf Mon Sep 17 00:00:00 2001 From: Timothy Laurent Date: Wed, 9 Nov 2016 23:56:14 -0800 Subject: [PATCH 12/18] don\'t use fields that end in a plus --- graphene_django/tests/models.py | 1 + graphene_django/tests/test_types.py | 3 ++- graphene_django/types.py | 7 +++++-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/graphene_django/tests/models.py b/graphene_django/tests/models.py index a055912..0c62f28 100644 --- a/graphene_django/tests/models.py +++ b/graphene_django/tests/models.py @@ -38,6 +38,7 @@ class Article(models.Model): headline = models.CharField(max_length=100) pub_date = models.DateField() reporter = models.ForeignKey(Reporter, related_name='articles') + editor = models.ForeignKey(Reporter, related_name='edited_articles_+') lang = models.CharField(max_length=2, help_text='Language', choices=[ ('es', 'Spanish'), ('en', 'English') diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py index 2510e1d..5c04651 100644 --- a/graphene_django/tests/test_types.py +++ b/graphene_django/tests/test_types.py @@ -52,7 +52,7 @@ def test_django_objecttype_map_correct_fields(): def test_django_objecttype_with_node_have_correct_fields(): fields = Article._meta.fields - assert list(fields.keys()) == ['id', 'headline', 'pub_date', 'reporter', 'lang', 'importance'] + assert list(fields.keys()) == ['id', 'headline', 'pub_date', 'reporter', 'editor', 'lang', 'importance'] def test_schema_representation(): @@ -66,6 +66,7 @@ type Article implements Node { headline: String! pubDate: DateTime! reporter: Reporter! + editor: Reporter! lang: ArticleLang! importance: ArticleImportance } diff --git a/graphene_django/types.py b/graphene_django/types.py index ae2dc18..20a0e73 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -26,9 +26,12 @@ def construct_fields(options): is_not_in_only = only_fields and name not in options.only_fields is_already_created = name in options.fields is_excluded = name in exclude_fields or is_already_created - if is_not_in_only or is_excluded: + # https://docs.djangoproject.com/en/1.10/ref/models/fields/#django.db.models.ForeignKey.related_query_name + is_no_backref = str.endswith(name, '+') + if is_not_in_only or is_excluded or is_no_backref: # We skip this field if we specify only_fields and is not - # in there. Or when we exclude this field in 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, options.registry) if not converted: From aa6be2c527303773cbb6f09b04ba5d236c955883 Mon Sep 17 00:00:00 2001 From: Timothy Laurent Date: Thu, 10 Nov 2016 00:19:35 -0800 Subject: [PATCH 13/18] cast name to string --- 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 20a0e73..646a4b5 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -27,7 +27,7 @@ def construct_fields(options): is_already_created = name in options.fields is_excluded = name in exclude_fields or is_already_created # https://docs.djangoproject.com/en/1.10/ref/models/fields/#django.db.models.ForeignKey.related_query_name - is_no_backref = str.endswith(name, '+') + is_no_backref = str.endswith(str(name), '+') if is_not_in_only or is_excluded or is_no_backref: # We skip this field if we specify only_fields and is not # in there. Or when we exclude this field in exclude_fields. From 1fdd7756ec0b3040c3db01f9af497d4caf67fa57 Mon Sep 17 00:00:00 2001 From: Timothy Laurent Date: Thu, 10 Nov 2016 10:16:44 -0800 Subject: [PATCH 14/18] better endswith --- 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 646a4b5..1973a85 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -27,7 +27,7 @@ def construct_fields(options): is_already_created = name in options.fields is_excluded = name in exclude_fields or is_already_created # https://docs.djangoproject.com/en/1.10/ref/models/fields/#django.db.models.ForeignKey.related_query_name - is_no_backref = str.endswith(str(name), '+') + is_no_backref = str(name).endswith('+') if is_not_in_only or is_excluded or is_no_backref: # We skip this field if we specify only_fields and is not # in there. Or when we exclude this field in exclude_fields. From a6d3887fb36da3f0e9b8f335febe92d49b5c5cae Mon Sep 17 00:00:00 2001 From: chaffeqa Date: Fri, 11 Nov 2016 14:35:41 -0500 Subject: [PATCH 15/18] Fix missing operation_name warning --- 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 3285683..949b850 100644 --- a/graphene_django/templates/graphene/graphiql.html +++ b/graphene_django/templates/graphene/graphiql.html @@ -112,7 +112,7 @@ add "&raw" to the end of the URL within a browser. {% if variables %} variables: '{{ variables|escapejs }}', {% endif %} - {% if operationName %} + {% if operation_name %} operationName: '{{ operation_name|escapejs }}', {% endif %} }), From 9e9a08ffe4e0296181ac8a235643e0ffeeebe692 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Israel=20Saeta=20P=C3=A9rez?= Date: Sat, 12 Nov 2016 18:27:25 +0100 Subject: [PATCH 16/18] Add `pytest-runner` and test-->pytest alias to make tests run as documented. --- setup.cfg | 3 +++ setup.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/setup.cfg b/setup.cfg index d1d6da9..c50ce70 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,3 +1,6 @@ +[aliases] +test=pytest + [tool:pytest] DJANGO_SETTINGS_MODULE = django_test_settings diff --git a/setup.py b/setup.py index bc18814..d5787c7 100644 --- a/setup.py +++ b/setup.py @@ -38,6 +38,9 @@ setup( 'iso8601', 'singledispatch>=3.4.0.3', ], + setup_requires=[ + 'pytest-runner', + ], tests_require=[ 'django-filter>=0.10.0', 'pytest', From d84c651bb499104a2fbe8cac874302940bd604a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Israel=20Saeta=20P=C3=A9rez?= Date: Sat, 12 Nov 2016 19:47:44 +0100 Subject: [PATCH 17/18] Add docs about how to build docs, and add sphinx as docs requirement. --- README.md | 18 ++++++++++++++++++ README.rst | 19 +++++++++++++++++++ docs/requirements.txt | 1 + 3 files changed, 38 insertions(+) diff --git a/README.md b/README.md index 94278a3..ed8b22d 100644 --- a/README.md +++ b/README.md @@ -107,3 +107,21 @@ After developing, the full test suite can be evaluated by running: ```sh python setup.py test # Use --pytest-args="-v -s" for verbose mode ``` + + +### Documentation + +The documentation 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 +``` diff --git a/README.rst b/README.rst index 976298c..1f594a9 100644 --- a/README.rst +++ b/README.rst @@ -117,6 +117,25 @@ After developing, the full test suite can be evaluated by running: python setup.py test # Use --pytest-args="-v -s" for verbose mode +Documentation +~~~~~~~~~~~~~ + +The documentation can be generated using the excellent +`Sphinx `__ and a custom theme. + +To install the documentation dependencies, run the following: + +.. 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 diff --git a/docs/requirements.txt b/docs/requirements.txt index 5de8cc6..2548604 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,2 +1,3 @@ +sphinx # Docs template https://github.com/graphql-python/graphene-python.org/archive/docs.zip From e72f6b2610a6a6af1b5ae709a9019d2aa83334ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Israel=20Saeta=20P=C3=A9rez?= Date: Sat, 12 Nov 2016 20:08:01 +0100 Subject: [PATCH 18/18] Explain alternative way to specify schema in tutorial. --- docs/tutorial.rst | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index e4a54a7..e1537a3 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -188,6 +188,8 @@ And then add the ``SCHEMA`` to the ``GRAPHENE`` config in ``cookbook/settings.py 'SCHEMA': 'cookbook.schema.schema' } +Alternatively, we can specify the schema to be used in the urls definition, +as explained below. Creating GraphQL and GraphiQL views ----------------------------------- @@ -199,6 +201,22 @@ view. This view will serve as GraphQL endpoint. As we want to have the aforementioned GraphiQL we specify that on the params with ``graphiql=True``. +.. code:: python + + from django.conf.urls import url, include + 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)), + ] + + +If we didn't specify the target schema in the Django settings file +as explained above, we can do so here using: + .. code:: python from django.conf.urls import url, include @@ -210,7 +228,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, schema=schema)), ] Apply model changes to database