├── .editorconfig ├── .gitignore ├── README.md ├── cookbook ├── __init__.py ├── schema.py ├── settings.py ├── urls.py └── wsgi.py ├── demo.json ├── manage.py ├── recipes ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py └── schema │ ├── __init__.py │ ├── ingredients.py │ └── recipes.py ├── requirements.txt └── tox.ini /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | end_of_line = lf 3 | insert_final_newline = true 4 | trim_trailing_whitespace = true 5 | 6 | [*.{js,scss,css}] 7 | indent_style = space 8 | indent_size = 2 9 | 10 | [*.html] 11 | indent_style = tab 12 | 13 | [*.py] 14 | indent_style = space 15 | indent_size = 4 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .vscode 3 | *.pyc 4 | *.sqlite3 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Build a GraphQL API powered by Django 2 | 3 | This repository contains the code samples to build your own GraphQL API using the Django web framework and the Graphene library for Python. It is intended to be accompanied by a presentation (originally prepared for DjangoCon US 2018). 4 | 5 | ## Slides 6 | 7 | The slides are available here: https://keen-mayer-abfbee.netlify.com/ 8 | 9 | You can move across top-level sections using the left/right keys. On each top-level section you can use the up/down keys to drill into the content. Press `Esc` to go into overview mode. 10 | 11 | ## Using diffs to follow along 12 | 13 | You can use the git's diff to see exactly what has changed from one step to the other (including which files have been modified and exactly which lines have been added or removed). GitHub includes an online diff viewer accessible right from the browser. To use it: 14 | 15 | 1. Go to the [**Releases**](https://github.com/unplugstudio/graphql-cookbook/releases) tab in the repository homepage. 16 | 1. Scroll to find the step you want to examine, for example `step-10`. 17 | 1. Right below the step you'll see the commit identifier (a string of letters and numbers like `67266fe`). Click it. 18 | 1. You should now be in GitHub's diff viewer. Lines of code with a green background indicate additions, and a red background indicates deletions. 19 | 20 | At this point you can manually reproduce the steps in your local copy of the repository, or you can checkout each particular step with: `git checkout step-10` (and so forth). This command will apply the changes for a particular step on your codebase so you can be in sync with the slides. 21 | 22 | ## Requirements 23 | 24 | We assume you're comfortable with Python and Django. You should also be familiar with the usage of pip and how to install packages in your Python environment. 25 | 26 | The code and all directions were written for Python 3. It may work with Python 2 but you may find unexpected errors. 27 | 28 | ## Quickstart 29 | 30 | ```bash 31 | # Create a Python 3 virtual environment, then... 32 | pip install -r requirements.txt 33 | python manage.py migrate 34 | python manage.py createsuperuser 35 | python manage.py loaddata demo.json 36 | python manage.py runserver 37 | ``` 38 | 39 | ## Useful links 40 | 41 | ### General GraphQL 42 | - [Introduction to GraphQL](https://graphql.org/learn/) 43 | - [GraphQL technical specification](https://facebook.github.io/graphql/) 44 | - [Relay specification](https://facebook.github.io/relay/docs/en/graphql-server-specification.html) 45 | 46 | ### Python / Django specific 47 | - [Graphene Python documentation](http://docs.graphene-python.org/en/latest/) 48 | - [Graphene Django documentation](https://docs.graphene-python.org/projects/django/en/latest/) 49 | - [Graphene Django examples](https://github.com/graphql-python/graphene-django/tree/master/examples) 50 | - [Migrating to graphene-django 2.0](https://github.com/graphql-python/graphene/blob/master/UPGRADE-v2.0.md) 51 | - [Use DjangoObjectType (or something like it) as an Input class?](https://github.com/graphql-python/graphene-django/issues/121) 52 | 53 | ### Miscellaneous 54 | - [A beginner's guide to GraphQL (using Node.js)](https://medium.freecodecamp.org/a-beginners-guide-to-graphql-60e43b0a41f5) 55 | - [5 reasons you shouldn't be using GraphQL](https://blog.logrocket.com/5-reasons-you-shouldnt-be-using-graphql-61c7846e7ed3) 56 | - [Collection of public GraphQL APIs](http://apis.guru/graphql-apis/) 57 | - [Django Filter documentation](https://django-filter.readthedocs.io/en/master/index.html) 58 | 59 | ### Client-side 60 | - [Apollo Client](https://www.apollographql.com/client) 61 | -------------------------------------------------------------------------------- /cookbook/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unplugstudio/graphql-cookbook/80bea8288b79af50f3e324dd0755de8d564f27dc/cookbook/__init__.py -------------------------------------------------------------------------------- /cookbook/schema.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | 3 | from recipes.schema.ingredients import IngredientQuery, IngredientMutation 4 | from recipes.schema.recipes import RecipeQuery, RecipeMutation 5 | 6 | 7 | class Query(IngredientQuery, RecipeQuery, graphene.ObjectType): 8 | hello = graphene.Field(graphene.String) 9 | 10 | def resolve_hello(self, info, **kwargs): 11 | user = info.context.user 12 | if user.is_authenticated: 13 | return "Hello %s!" % user 14 | return "Hello stranger" 15 | 16 | 17 | class Mutation(IngredientMutation, RecipeMutation, graphene.ObjectType): 18 | pass 19 | 20 | 21 | schema = graphene.Schema(query=Query, mutation=Mutation) 22 | -------------------------------------------------------------------------------- /cookbook/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for cookbook project. 3 | 4 | Generated by 'django-admin startproject' using Django 2.1.2. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/2.1/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/2.1/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = 'wub9t3-qi67yjfep0(l3p68b#4%n@&a^(o^^f(dx43*q5t(c7%' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = ['.ngrok.io', 'localhost', '127.0.0.1'] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | 'django.contrib.admin', 35 | 'django.contrib.auth', 36 | 'django.contrib.contenttypes', 37 | 'django.contrib.sessions', 38 | 'django.contrib.messages', 39 | 'django.contrib.staticfiles', 40 | 41 | 'graphene_django', 42 | 43 | 'recipes', 44 | ] 45 | 46 | GRAPHENE = { 47 | 'SCHEMA': 'cookbook.schema.schema', 48 | } 49 | 50 | MIDDLEWARE = [ 51 | 'django.middleware.security.SecurityMiddleware', 52 | 'django.contrib.sessions.middleware.SessionMiddleware', 53 | 'django.middleware.common.CommonMiddleware', 54 | 'django.middleware.csrf.CsrfViewMiddleware', 55 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 56 | 'django.contrib.messages.middleware.MessageMiddleware', 57 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 58 | ] 59 | 60 | ROOT_URLCONF = 'cookbook.urls' 61 | 62 | TEMPLATES = [ 63 | { 64 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 65 | 'DIRS': [], 66 | 'APP_DIRS': True, 67 | 'OPTIONS': { 68 | 'context_processors': [ 69 | 'django.template.context_processors.debug', 70 | 'django.template.context_processors.request', 71 | 'django.contrib.auth.context_processors.auth', 72 | 'django.contrib.messages.context_processors.messages', 73 | ], 74 | }, 75 | }, 76 | ] 77 | 78 | WSGI_APPLICATION = 'cookbook.wsgi.application' 79 | 80 | 81 | # Database 82 | # https://docs.djangoproject.com/en/2.1/ref/settings/#databases 83 | 84 | DATABASES = { 85 | 'default': { 86 | 'ENGINE': 'django.db.backends.sqlite3', 87 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 88 | } 89 | } 90 | 91 | 92 | # Password validation 93 | # https://docs.djangoproject.com/en/2.1/ref/settings/#auth-password-validators 94 | 95 | AUTH_PASSWORD_VALIDATORS = [ 96 | { 97 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 98 | }, 99 | { 100 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 101 | }, 102 | { 103 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 104 | }, 105 | { 106 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 107 | }, 108 | ] 109 | 110 | 111 | # Internationalization 112 | # https://docs.djangoproject.com/en/2.1/topics/i18n/ 113 | 114 | LANGUAGE_CODE = 'en-us' 115 | 116 | TIME_ZONE = 'UTC' 117 | 118 | USE_I18N = True 119 | 120 | USE_L10N = True 121 | 122 | USE_TZ = True 123 | 124 | 125 | # Static files (CSS, JavaScript, Images) 126 | # https://docs.djangoproject.com/en/2.1/howto/static-files/ 127 | 128 | STATIC_URL = '/static/' 129 | -------------------------------------------------------------------------------- /cookbook/urls.py: -------------------------------------------------------------------------------- 1 | """cookbook URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/2.1/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.urls import path 18 | 19 | from graphene_django.views import GraphQLView 20 | 21 | urlpatterns = [ 22 | path('admin/', admin.site.urls), 23 | path('graphql/', GraphQLView.as_view(graphiql=True)), 24 | ] 25 | -------------------------------------------------------------------------------- /cookbook/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for cookbook project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'cookbook.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /demo.json: -------------------------------------------------------------------------------- 1 | [{"model": "recipes.recipe", "pk": 1, "fields": {"title": "Cheerios With a Shot of Vermouth", "instructions": "https://xkcd.com/720/", "featured": false}}, {"model": "recipes.recipe", "pk": 2, "fields": {"title": "Quail Eggs in Whipped Cream and MSG", "instructions": "https://xkcd.com/720/", "featured": false}}, {"model": "recipes.recipe", "pk": 3, "fields": {"title": "Deep Fried Skittles", "instructions": "https://xkcd.com/720/", "featured": false}}, {"model": "recipes.recipe", "pk": 4, "fields": {"title": "Newt ala Doritos", "instructions": "https://xkcd.com/720/", "featured": false}}, {"model": "recipes.recipe", "pk": 5, "fields": {"title": "Fruit Salad", "instructions": "Chop up and add together", "featured": true}}, {"model": "recipes.recipeelement", "pk": 1, "fields": {"recipe": 5, "ingredient": 9, "amount": 1.0, "unit": "unit"}}, {"model": "recipes.recipeelement", "pk": 2, "fields": {"recipe": 5, "ingredient": 10, "amount": 2.0, "unit": "unit"}}, {"model": "recipes.recipeelement", "pk": 3, "fields": {"recipe": 5, "ingredient": 7, "amount": 3.0, "unit": "unit"}}, {"model": "recipes.recipeelement", "pk": 4, "fields": {"recipe": 5, "ingredient": 8, "amount": 4.0, "unit": "unit"}}, {"model": "recipes.recipeelement", "pk": 5, "fields": {"recipe": 4, "ingredient": 5, "amount": 1.0, "unit": "kg"}}, {"model": "recipes.recipeelement", "pk": 6, "fields": {"recipe": 4, "ingredient": 6, "amount": 2.0, "unit": "l"}}, {"model": "recipes.recipeelement", "pk": 7, "fields": {"recipe": 3, "ingredient": 4, "amount": 1.0, "unit": "unit"}}, {"model": "recipes.recipeelement", "pk": 8, "fields": {"recipe": 2, "ingredient": 2, "amount": 1.0, "unit": "kg"}}, {"model": "recipes.recipeelement", "pk": 9, "fields": {"recipe": 2, "ingredient": 11, "amount": 2.0, "unit": "l"}}, {"model": "recipes.recipeelement", "pk": 10, "fields": {"recipe": 2, "ingredient": 12, "amount": 3.0, "unit": "st"}}, {"model": "recipes.recipeelement", "pk": 11, "fields": {"recipe": 1, "ingredient": 1, "amount": 1.0, "unit": "kg"}}, {"model": "recipes.recipeelement", "pk": 12, "fields": {"recipe": 1, "ingredient": 3, "amount": 1.0, "unit": "st"}}, {"model": "recipes.category", "pk": 1, "fields": {"name": "fruit"}}, {"model": "recipes.category", "pk": 3, "fields": {"name": "veggies"}}, {"model": "recipes.ingredient", "pk": 1, "fields": {"name": "Cheerios", "notes": "this is a note", "category": 3}}, {"model": "recipes.ingredient", "pk": 2, "fields": {"name": "Quail Eggs", "notes": "has more notes", "category": 3}}, {"model": "recipes.ingredient", "pk": 3, "fields": {"name": "Vermouth", "notes": "", "category": 3}}, {"model": "recipes.ingredient", "pk": 4, "fields": {"name": "Skittles", "notes": "", "category": 3}}, {"model": "recipes.ingredient", "pk": 5, "fields": {"name": "Newt", "notes": "Braised and Confuesd", "category": 3}}, {"model": "recipes.ingredient", "pk": 6, "fields": {"name": "Doritos", "notes": "Crushed", "category": 3}}, {"model": "recipes.ingredient", "pk": 7, "fields": {"name": "Apple", "notes": "", "category": 1}}, {"model": "recipes.ingredient", "pk": 8, "fields": {"name": "Orange", "notes": "", "category": 1}}, {"model": "recipes.ingredient", "pk": 9, "fields": {"name": "Banana", "notes": "", "category": 1}}, {"model": "recipes.ingredient", "pk": 10, "fields": {"name": "Grapes", "notes": "", "category": 1}}, {"model": "recipes.ingredient", "pk": 11, "fields": {"name": "Whipped Cream", "notes": "", "category": 3}}, {"model": "recipes.ingredient", "pk": 12, "fields": {"name": "MSG", "notes": "", "category": 3}}] 2 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == '__main__': 6 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'cookbook.settings') 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError as exc: 10 | raise ImportError( 11 | "Couldn't import Django. Are you sure it's installed and " 12 | "available on your PYTHONPATH environment variable? Did you " 13 | "forget to activate a virtual environment?" 14 | ) from exc 15 | execute_from_command_line(sys.argv) 16 | -------------------------------------------------------------------------------- /recipes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unplugstudio/graphql-cookbook/80bea8288b79af50f3e324dd0755de8d564f27dc/recipes/__init__.py -------------------------------------------------------------------------------- /recipes/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Recipe, RecipeElement, Category, Ingredient 4 | 5 | 6 | class RecipeElementInline(admin.TabularInline): 7 | model = RecipeElement 8 | 9 | 10 | @admin.register(Recipe) 11 | class RecipeAdmin(admin.ModelAdmin): 12 | inlines = [RecipeElementInline] 13 | list_display = ['title', 'featured'] 14 | list_editable = ['featured'] 15 | 16 | 17 | @admin.register(Ingredient) 18 | class IngredientAdmin(admin.ModelAdmin): 19 | list_display = ['id', 'name', 'category'] 20 | list_editable = ['name', 'category'] 21 | 22 | 23 | admin.site.register(Category) 24 | -------------------------------------------------------------------------------- /recipes/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class RecipesConfig(AppConfig): 5 | name = 'recipes' 6 | verbose_name = 'Recipes' 7 | -------------------------------------------------------------------------------- /recipes/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.2 on 2018-10-09 01:06 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [ 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='Category', 17 | fields=[ 18 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('name', models.CharField(max_length=100)), 20 | ], 21 | options={ 22 | 'verbose_name': 'category', 23 | 'verbose_name_plural': 'categories', 24 | }, 25 | ), 26 | migrations.CreateModel( 27 | name='Ingredient', 28 | fields=[ 29 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 30 | ('name', models.CharField(max_length=100)), 31 | ('notes', models.TextField(blank=True)), 32 | ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ingredients', to='recipes.Category')), 33 | ], 34 | ), 35 | migrations.CreateModel( 36 | name='Recipe', 37 | fields=[ 38 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 39 | ('title', models.CharField(max_length=100)), 40 | ('instructions', models.TextField()), 41 | ('featured', models.BooleanField(default=False)), 42 | ], 43 | ), 44 | migrations.CreateModel( 45 | name='RecipeElement', 46 | fields=[ 47 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 48 | ('amount', models.FloatField()), 49 | ('unit', models.CharField(choices=[('unit', 'Units'), ('kg', 'Kilograms'), ('l', 'Litres'), ('st', 'Shots')], max_length=20)), 50 | ('ingredient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='elements', to='recipes.Ingredient')), 51 | ('recipe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='elements', to='recipes.Recipe')), 52 | ], 53 | ), 54 | ] 55 | -------------------------------------------------------------------------------- /recipes/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unplugstudio/graphql-cookbook/80bea8288b79af50f3e324dd0755de8d564f27dc/recipes/migrations/__init__.py -------------------------------------------------------------------------------- /recipes/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Category(models.Model): 5 | """ 6 | A group for several ingredients. 7 | """ 8 | name = models.CharField(max_length=100) 9 | 10 | class Meta: 11 | verbose_name = 'category' 12 | verbose_name_plural = 'categories' 13 | 14 | def __str__(self): 15 | return self.name 16 | 17 | 18 | class Ingredient(models.Model): 19 | """ 20 | An ingredient. 21 | """ 22 | name = models.CharField(max_length=100) 23 | notes = models.TextField(blank=True) 24 | category = models.ForeignKey(Category, related_name='ingredients', on_delete=models.CASCADE) 25 | 26 | def __str__(self): 27 | return self.name 28 | 29 | 30 | class Recipe(models.Model): 31 | """ 32 | A simple recipe. 33 | """ 34 | title = models.CharField(max_length=100) 35 | instructions = models.TextField() 36 | featured = models.BooleanField(default=False) 37 | 38 | def __str__(self): 39 | return self.title 40 | 41 | 42 | class RecipeElement(models.Model): 43 | """ 44 | A record of an Ingredient used in a Recipe. 45 | """ 46 | recipe = models.ForeignKey(Recipe, related_name='elements', on_delete=models.CASCADE) 47 | ingredient = models.ForeignKey(Ingredient, related_name='elements', on_delete=models.CASCADE) 48 | amount = models.FloatField() 49 | unit = models.CharField(max_length=20, choices=( 50 | ('unit', 'Units'), 51 | ('kg', 'Kilograms'), 52 | ('l', 'Litres'), 53 | ('st', 'Shots'), 54 | )) 55 | 56 | def __str__(self): 57 | return str(self.ingredient) 58 | -------------------------------------------------------------------------------- /recipes/schema/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unplugstudio/graphql-cookbook/80bea8288b79af50f3e324dd0755de8d564f27dc/recipes/schema/__init__.py -------------------------------------------------------------------------------- /recipes/schema/ingredients.py: -------------------------------------------------------------------------------- 1 | import django_filters 2 | import graphene 3 | 4 | from graphql import GraphQLError 5 | from graphene import relay 6 | from graphene_django.filter import DjangoFilterConnectionField 7 | from graphene_django.types import DjangoObjectType 8 | 9 | from ..models import Category, Ingredient 10 | 11 | 12 | # Filters 13 | 14 | class IngredientFilterSet(django_filters.FilterSet): 15 | category = django_filters.CharFilter('category__name', lookup_expr='exact') 16 | 17 | class Meta: 18 | model = Ingredient 19 | fields = ['category'] 20 | 21 | 22 | # Types 23 | 24 | class CategoryNode(DjangoObjectType): 25 | 26 | class Meta: 27 | model = Category 28 | exclude_fields = ['ingredients'] 29 | interfaces = [relay.Node] 30 | filter_fields = [] 31 | 32 | 33 | class IngredientNode(DjangoObjectType): 34 | 35 | class Meta: 36 | model = Ingredient 37 | interfaces = [relay.Node] 38 | 39 | 40 | class IngredientQuery(object): 41 | all_categories = DjangoFilterConnectionField(CategoryNode) 42 | all_ingredients = DjangoFilterConnectionField( 43 | IngredientNode, filterset_class=IngredientFilterSet) 44 | 45 | 46 | # Mutations 47 | 48 | class AddIngredientMutation(relay.ClientIDMutation): 49 | 50 | class Input: 51 | name = graphene.String(required=True) 52 | notes = graphene.String() 53 | category = graphene.ID(required=True) 54 | 55 | ingredient = graphene.Field(IngredientNode) 56 | 57 | @classmethod 58 | def mutate_and_get_payload(cls, root, info, **input): 59 | name = input.get('name') 60 | notes = input.get('notes', '') 61 | category_id = input.get('category') 62 | 63 | category = relay.Node.get_node_from_global_id(info, category_id) 64 | if category is None: 65 | raise GraphQLError('Category does not exist!') 66 | 67 | ingredient = Ingredient.objects.create(name=name, notes=notes, category=category) 68 | return cls(ingredient=ingredient) 69 | 70 | 71 | class IngredientMutation(object): 72 | add_ingredient = AddIngredientMutation.Field() 73 | -------------------------------------------------------------------------------- /recipes/schema/recipes.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | 3 | from graphql import GraphQLError 4 | from graphene import relay 5 | from graphene_django.filter import DjangoFilterConnectionField 6 | from graphene_django.types import DjangoObjectType 7 | 8 | from ..models import Recipe, RecipeElement 9 | 10 | 11 | # Types 12 | 13 | class RecipeElementNode(DjangoObjectType): 14 | unit_display = graphene.String() 15 | 16 | class Meta: 17 | model = RecipeElement 18 | exclude_fields = ['recipe'] 19 | interfaces = [relay.Node] 20 | 21 | def resolve_unit_display(self, info, **kwargs): 22 | return self.get_unit_display() 23 | 24 | 25 | class RecipeNode(DjangoObjectType): 26 | 27 | class Meta: 28 | model = Recipe 29 | interfaces = [relay.Node] 30 | filter_fields = [] 31 | 32 | 33 | class RecipeQuery(object): 34 | recipe = relay.Node.Field(RecipeNode) 35 | all_recipes = DjangoFilterConnectionField(RecipeNode) 36 | 37 | 38 | # Mutations 39 | 40 | class RecipeElementInput(graphene.InputObjectType): 41 | ingredient = graphene.ID(required=True) 42 | amount = graphene.Float(required=True) 43 | unit = RecipeElementNode._meta.fields['unit'] 44 | 45 | 46 | class UpdateRecipeMutation(relay.ClientIDMutation): 47 | 48 | class Input: 49 | id = graphene.ID(required=True) 50 | title = graphene.String() 51 | instructions = graphene.String() 52 | featured = graphene.Boolean() 53 | elements = graphene.List(RecipeElementInput) 54 | 55 | recipe = graphene.Field(RecipeNode) 56 | 57 | @staticmethod 58 | def set_elements(info, recipe, elements): 59 | updates = [] 60 | recipe.elements.all().delete() 61 | for element in elements: 62 | ingredient = relay.Node.get_node_from_global_id(info, element['ingredient']) 63 | updates.append(RecipeElement( 64 | recipe=recipe, 65 | ingredient=ingredient, 66 | amount=element['amount'], 67 | unit=element['unit'] 68 | )) 69 | RecipeElement.objects.bulk_create(updates) 70 | 71 | @classmethod 72 | def mutate_and_get_payload(cls, root, info, **input): 73 | id = input.pop('id') 74 | recipe = relay.Node.get_node_from_global_id(info, id) 75 | if recipe is None: 76 | raise GraphQLError('Recipe does not exist') 77 | 78 | elements = input.pop('elements', []) 79 | cls.set_elements(info, recipe, elements) 80 | 81 | for k, v in input.items(): 82 | setattr(recipe, k, v) 83 | recipe.save() 84 | return cls(recipe=recipe) 85 | 86 | 87 | class RecipeMutation(object): 88 | update_recipe = UpdateRecipeMutation.Field() 89 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aniso8601==3.0.2 2 | Django==2.1.2 3 | django-filter==2.0.0 4 | graphene==2.1.3 5 | graphene-django==2.2.0 6 | graphql-core==2.1 7 | graphql-relay==0.4.5 8 | promise==2.1 9 | pytz==2018.5 10 | Rx==1.6.1 11 | singledispatch==3.4.0.3 12 | six==1.11.0 13 | typing==3.6.6 14 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E731 3 | max-line-length = 99 4 | 5 | [pytest] 6 | DJANGO_SETTINGS_MODULE = sdmac.settings 7 | python_files = tests.py test_*.py *_tests.py 8 | --------------------------------------------------------------------------------