diff --git a/.gitignore b/.gitignore index 2c4ca2b..0b25625 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,16 @@ target/ # Databases *.sqlite3 .vscode + +# swap +[._]*.s[a-v][a-z] +[._]*.sw[a-p] +[._]s[a-v][a-z] +[._]sw[a-p] +# session +Session.vim +# temporary +.netrwhist +*~ +# auto-generated tag files +tags diff --git a/docs/authorization.rst b/docs/authorization.rst index 9ee7d0a..88f6b6a 100644 --- a/docs/authorization.rst +++ b/docs/authorization.rst @@ -1,7 +1,7 @@ Authorization in Django ======================= -There are two main ways you may want to limit access to data when +There are several ways you may want to limit access to data when working with Graphene and Django: limiting which fields are accessible via GraphQL and limiting which objects a user can access. @@ -34,6 +34,20 @@ This is easy, simply use the ``only_fields`` meta attribute. only_fields = ('title', 'content') interfaces = (relay.Node, ) +conversely you can use ``exclude_fields`` meta atrribute. + +.. code:: python + + from graphene import relay + from graphene_django.types import DjangoObjectType + from .models import Post + + class PostNode(DjangoObjectType): + class Meta: + model = Post + exclude_fields = ('published', 'owner') + interfaces = (relay.Node, ) + Queryset Filtering On Lists --------------------------- @@ -108,3 +122,28 @@ method to your ``DjangoObjectType``. if post.published or context.user == post.owner: return post return None + +Adding login required +--------------------- + +If you want to use the standard Django LoginRequiredMixin_ you can create your own view, which includes the ``LoginRequiredMixin`` and subclasses the ``GraphQLView``: + +.. code:: python + + from django.contrib.auth.mixins import LoginRequiredMixin + from graphene_django.views import GraphQLView + + + class PrivateGraphQLView(LoginRequiredMixin, GraphQLView): + pass + +After this, you can use the new ``PrivateGraphQLView`` in ``urls.py``: + +.. code:: python + + urlpatterns = [ + # some other urls + url(r'^graphql', PrivateGraphQLView.as_view(graphiql=True, schema=schema)), + ] + +.. _LoginRequiredMixin: https://docs.djangoproject.com/en/1.10/topics/auth/default/#the-loginrequired-mixin diff --git a/docs/conf.py b/docs/conf.py index d729246..2ea2d55 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -63,7 +63,7 @@ master_doc = 'index' # General information about the project. project = u'Graphene Django' -copyright = u'Graphene 2016' +copyright = u'Graphene 2017' author = u'Syrus Akbary' # The version info for the project you're documenting, acts as replacement for diff --git a/docs/index.rst b/docs/index.rst index c8b5515..ccc6bd4 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -6,7 +6,8 @@ Contents: .. toctree:: :maxdepth: 0 - tutorial + tutorial-plain + tutorial-relay filtering authorization debug diff --git a/docs/tutorial-plain.rst b/docs/tutorial-plain.rst new file mode 100644 index 0000000..a4c98a9 --- /dev/null +++ b/docs/tutorial-plain.rst @@ -0,0 +1,516 @@ +Introduction tutorial - Graphene and Django +=========================================== + +Graphene has a number of additional features that are designed to make +working with Django *really simple*. + +Our primary focus here is to give a good understanding of how to connect models from Django ORM to graphene object types. + +A good idea is to check the `graphene `__ documentation first. + +Setup the Django project +------------------------ + +You can find the entire project in ``examples/cookbook-plain``. + +---- + +We will setup the project, create the following: + +- A Django project called ``cookbook`` +- An app within ``cookbook`` called ``ingredients`` + +.. code:: bash + + # Create the project directory + mkdir cookbook + cd cookbook + + # Create a virtualenv to isolate our package dependencies locally + virtualenv env + source env/bin/activate # On Windows use `env\Scripts\activate` + + # Install Django and Graphene with Django support + pip install django + pip install graphene_django + + # Set up a new project with a single application + django-admin.py startproject cookbook . # Note the trailing '.' character + cd cookbook + django-admin.py startapp ingredients + +Now sync your database for the first time: + +.. code:: bash + + python manage.py migrate + +Let's create a few simple models... + +Defining our models +^^^^^^^^^^^^^^^^^^^ + +Let's get started with these models: + +.. code:: python + + # cookbook/ingredients/models.py + from django.db import models + + + class Category(models.Model): + name = models.CharField(max_length=100) + + def __str__(self): + return self.name + + + class Ingredient(models.Model): + name = models.CharField(max_length=100) + notes = models.TextField() + category = models.ForeignKey(Category, related_name='ingredients') + + def __str__(self): + return self.name + +Don't forget to create & run migrations: + +.. code:: bash + + python manage.py makemigrations + python manage.py migrate + +Load some test data +^^^^^^^^^^^^^^^^^^^ + +Now is a good time to load up some test data. The easiest option will be +to `download the +ingredients.json `__ +fixture and place it in +``cookbook/ingredients/fixtures/ingredients.json``. You can then run the +following: + +.. code:: bash + + $ python ./manage.py loaddata ingredients + + Installed 6 object(s) from 1 fixture(s) + +Alternatively you can use the Django admin interface to create some data +yourself. You'll need to run the development server (see below), and +create a login for yourself too (``./manage.py createsuperuser``). + +Hello GraphQL - Schema and Object Types +--------------------------------------- + +In order to make queries to our Django project, we are going to need few things: + +* Schema with defined object types +* A view, taking queries as input and returning the result + +GraphQL presents your objects to the world as a graph structure rather +than a more hierarchical structure to which you may be accustomed. In +order to create this representation, Graphene needs to know about each +*type* of object which will appear in the graph. + +This graph also has a *root type* through which all access begins. This +is the ``Query`` class below. + +This means, for each of our models, we are going to create a type, subclassing ``DjangoObjectType`` + +After we've done that, we will list those types as fields in the ``Query`` class. + +Create ``cookbook/ingredients/schema.py`` and type the following: + +.. code:: python + + # cookbook/ingredients/schema.py + import graphene + + from graphene_django.types import DjangoObjectType + + from cookbook.ingredients.models import Category, Ingredient + + + class CategoryType(DjangoObjectType): + class Meta: + model = Category + + + class IngredientType(DjangoObjectType): + class Meta: + model = Ingredient + + + class Query(graphene.AbstractType): + all_categories = graphene.List(CategoryType) + all_ingredients = graphene.List(IngredientType) + + def resolve_all_categories(self, args, context, info): + return Category.objects.all() + + def resolve_all_ingredients(self, args, context, info): + # We can easily optimize query count in the resolve method + return Ingredient.objects.select_related('category').all() + + +Note that the above ``Query`` class is marked as 'abstract'. This is +because we will now create a project-level query which will combine all +our app-level queries. + +Create the parent project-level ``cookbook/schema.py``: + +.. code:: python + + import graphene + + import cookbook.ingredients.schema + + + class Query(cookbook.ingredients.schema.Query, graphene.ObjectType): + # This class will inherit from multiple Queries + # as we begin to add more apps to our project + pass + + schema = graphene.Schema(query=Query) + +You can think of this as being something like your top-level ``urls.py`` +file (although it currently lacks any namespacing). + +Testing everything so far +------------------------- + +We are going to do some configuration work, in order to have a working Django where we can test queries, before we move on, updating our schema. + +Update settings +^^^^^^^^^^^^^^^ + +Next, install your app and GraphiQL in your Django project. GraphiQL is +a web-based integrated development environment to assist in the writing +and executing of GraphQL queries. It will provide us with a simple and +easy way of testing our cookbook project. + +Add ``ingredients`` and ``graphene_django`` to ``INSTALLED_APPS`` in ``cookbook/settings.py``: + +.. code:: python + + INSTALLED_APPS = [ + ... + # This will also make the `graphql_schema` management command available + 'graphene_django', + + # Install the ingredients app + 'ingredients', + ] + +And then add the ``SCHEMA`` to the ``GRAPHENE`` config in ``cookbook/settings.py``: + +.. code:: python + + GRAPHENE = { + '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 +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Unlike a RESTful API, there is only a single URL from which GraphQL is +accessed. Requests to this URL are handled by Graphene's ``GraphQLView`` +view. + +This view will serve as GraphQL endpoint. As we want to have the +aforementioned GraphiQL we specify that on the parameters 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 + from django.contrib import admin + + from graphene_django.views import GraphQLView + + from cookbook.schema import schema + + urlpatterns = [ + url(r'^admin/', admin.site.urls), + url(r'^graphql', GraphQLView.as_view(graphiql=True, schema=schema)), + ] + + + +Testing our GraphQL schema +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +We're now ready to test the API we've built. Let's fire up the server +from the command line. + +.. code:: bash + + $ python ./manage.py runserver + + Performing system checks... + Django version 1.9, using settings 'cookbook.settings' + Starting development server at http://127.0.0.1:8000/ + Quit the server with CONTROL-C. + +Go to `localhost:8000/graphql `__ and +type your first query! + +.. code:: + + query { + allIngredients { + id + name + } + } + +If you are using the provided fixtures, you will see the following response: + +.. code:: + + { + "data": { + "allIngredients": [ + { + "id": "1", + "name": "Eggs" + }, + { + "id": "2", + "name": "Milk" + }, + { + "id": "3", + "name": "Beef" + }, + { + "id": "4", + "name": "Chicken" + } + ] + } + } + +You can experiment with ``allCategories`` too. + +Something to have in mind is the `auto camelcasing `__ that is happening. + + +Getting relations +----------------- + +Right now, with this simple setup in place, we can query for relations too. This is where graphql becomes really powerful! + +For example, we may want to list all categories and in each category, all ingredients that are in that category. + +We can do that with the following query: + +.. code:: + + query { + allCategories { + id + name + ingredients { + id + name + } + } + } + + +This will give you (in case you are using the fixtures) the following result: + +.. code:: + + { + "data": { + "allCategories": [ + { + "id": "1", + "name": "Dairy", + "ingredients": [ + { + "id": "1", + "name": "Eggs" + }, + { + "id": "2", + "name": "Milk" + } + ] + }, + { + "id": "2", + "name": "Meat", + "ingredients": [ + { + "id": "3", + "name": "Beef" + }, + { + "id": "4", + "name": "Chicken" + } + ] + } + ] + } + } + +We can also list all ingredients and get information for the category they are in: + +.. code:: + + query { + allIngredients { + id + name + category { + id + name + } + } + } + +Getting single objects +---------------------- + +So far, we have been able to fetch list of objects and follow relation. But what about single objects? + +We can update our schema to support that, by adding new query for ``ingredient`` and ``category`` and adding arguments, so we can query for specific objects. + +.. code:: python + + import graphene + + from graphene_django.types import DjangoObjectType + + from cookbook.ingredients.models import Category, Ingredient + + + class CategoryType(DjangoObjectType): + class Meta: + model = Category + + + class IngredientType(DjangoObjectType): + class Meta: + model = Ingredient + + + class Query(graphene.AbstractType): + category = graphene.Field(CategoryType, + id=graphene.Int(), + name=graphene.String()) + all_categories = graphene.List(CategoryType) + + + ingredient = graphene.Field(IngredientType, + id=graphene.Int(), + name=graphene.String()) + all_ingredients = graphene.List(IngredientType) + + def resolve_all_categories(self, args, context, info): + return Category.objects.all() + + def resolve_all_ingredients(self, args, context, info): + return Ingredient.objects.all() + + def resolve_category(self, args, context, info): + id = args.get('id') + name = args.get('name') + + if id is not None: + return Category.objects.get(pk=id) + + if name is not None: + return Category.objects.get(name=name) + + return None + + def resolve_ingredient(self, args, context, info): + id = args.get('id') + name = args.get('name') + + if id is not None: + return Ingredient.objects.get(pk=id) + + if name is not None: + return Ingredient.objects.get(name=name) + + return None + +Now, with the code in place, we can query for single objects. + +For example, lets query ``category``: + + +.. code:: + + query { + category(id: 1) { + name + } + anotherCategory: category(name: "Dairy") { + ingredients { + id + name + } + } + } + +This will give us the following results: + +.. code:: + + { + "data": { + "category": { + "name": "Dairy" + }, + "anotherCategory": { + "ingredients": [ + { + "id": "1", + "name": "Eggs" + }, + { + "id": "2", + "name": "Milk" + } + ] + } + } + } + +As an exercise, you can try making some queries to ``ingredient``. + +Something to keep in mind - since we are using one field several times in our query, we need `aliases `__ + + +Summary +------- + +As you can see, GraphQL is very powerful but there are a lot of repetitions in our example. We can do a lot of improvements by adding layers of abstraction on top of ``graphene-django``. + +If you want to put things like ``django-filter`` and automatic pagination in action, you should continue with the **relay tutorial.** diff --git a/docs/tutorial.rst b/docs/tutorial-relay.rst similarity index 92% rename from docs/tutorial.rst rename to docs/tutorial-relay.rst index 56c492d..3ac4cec 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial-relay.rst @@ -1,5 +1,5 @@ -Graphene-Django Tutorial -======================== +Graphene and Django Tutorial using Relay +======================================== Graphene has a number of additional features that are designed to make working with Django *really simple*. @@ -7,6 +7,11 @@ working with Django *really simple*. Note: The code in this quickstart is pulled from the `cookbook example app `__. +A good idea is to check the following things first: + +* `Graphene Relay documentation `__ +* `GraphQL Relay Specification `__ + Setup the Django project ------------------------ @@ -43,7 +48,7 @@ Now sync your database for the first time: Let's create a few simple models... Defining our models -------------------- +^^^^^^^^^^^^^^^^^^^ Let's get started with these models: @@ -68,6 +73,33 @@ Let's get started with these models: def __str__(self): return self.name +Don't forget to create & run migrations: + +.. code:: bash + + python manage.py makemigrations + python manage.py migrate + +Load some test data +^^^^^^^^^^^^^^^^^^^ + +Now is a good time to load up some test data. The easiest option will be +to `download the +ingredients.json `__ +fixture and place it in +``cookbook/ingredients/fixtures/ingredients.json``. You can then run the +following: + +.. code:: bash + + $ python ./manage.py loaddata ingredients + + Installed 6 object(s) from 1 fixture(s) + +Alternatively you can use the Django admin interface to create some data +yourself. You'll need to run the development server (see below), and +create a login for yourself too (``./manage.py createsuperuser``). + Schema ------ @@ -90,7 +122,7 @@ Create ``cookbook/ingredients/schema.py`` and type the following: from graphene_django import DjangoObjectType from graphene_django.filter import DjangoFilterConnectionField - from cookbook.ingredients.models import Category, Ingredient + from ingredients.models import Category, Ingredient # Graphene will automatically map the Category model's fields onto the CategoryNode. @@ -145,10 +177,10 @@ Create the parent project-level ``cookbook/schema.py``: import graphene - import cookbook.ingredients.schema + import ingredients.schema - class Query(cookbook.ingredients.schema.Query, graphene.ObjectType): + class Query(ingredients.schema.Query, graphene.ObjectType): # This class will inherit from multiple Queries # as we begin to add more apps to our project pass @@ -158,8 +190,11 @@ Create the parent project-level ``cookbook/schema.py``: You can think of this as being something like your top-level ``urls.py`` file (although it currently lacks any namespacing). +Testing everything so far +------------------------- + Update settings ---------------- +^^^^^^^^^^^^^^^ Next, install your app and GraphiQL in your Django project. GraphiQL is a web-based integrated development environment to assist in the writing @@ -191,7 +226,7 @@ Alternatively, we can specify the schema to be used in the urls definition, as explained below. Creating GraphQL and GraphiQL views ------------------------------------ +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Unlike a RESTful API, there is only a single URL from which GraphQL is accessed. Requests to this URL are handled by Graphene's ``GraphQLView`` @@ -230,39 +265,9 @@ as explained above, we can do so here using: url(r'^graphql', GraphQLView.as_view(graphiql=True, schema=schema)), ] -Apply model changes to database -------------------------------- - -Tell Django that we've added models and update the database schema to -reflect these additions. - -.. code:: bash - - python manage.py makemigrations - python manage.py migrate - -Load some test data -------------------- - -Now is a good time to load up some test data. The easiest option will be -to `download the -ingredients.json `__ -fixture and place it in -``cookbook/ingredients/fixtures/ingredients.json``. You can then run the -following: - -.. code:: bash - - $ python ./manage.py loaddata ingredients - - Installed 6 object(s) from 1 fixture(s) - -Alternatively you can use the Django admin interface to create some data -yourself. You'll need to run the development server (see below), and -create a login for yourself too (``./manage.py createsuperuser``). Testing our GraphQL schema --------------------------- +^^^^^^^^^^^^^^^^^^^^^^^^^^ We're now ready to test the API we've built. Let's fire up the server from the command line. @@ -276,7 +281,7 @@ from the command line. Starting development server at http://127.0.0.1:8000/ Quit the server with CONTROL-C. -Go to `localhost:8000/graphiql `__ and +Go to `localhost:8000/graphql `__ and type your first query! .. code:: diff --git a/examples/cookbook-plain/README.md b/examples/cookbook-plain/README.md new file mode 100644 index 0000000..018c584 --- /dev/null +++ b/examples/cookbook-plain/README.md @@ -0,0 +1,64 @@ +Cookbook Example Django Project +=============================== + +This example project demos integration between Graphene and Django. +The project contains two apps, one named `ingredients` and another +named `recepies`. + +Getting started +--------------- + +First you'll need to get the source of the project. Do this by cloning the +whole Graphene repository: + +```bash +# Get the example project code +git clone https://github.com/graphql-python/graphene-django.git +cd graphene-django/examples/cookbook +``` + +It is good idea (but not required) to create a virtual environment +for this project. We'll do this using +[virtualenv](http://docs.python-guide.org/en/latest/dev/virtualenvs/) +to keep things simple, +but you may also find something like +[virtualenvwrapper](https://virtualenvwrapper.readthedocs.org/en/latest/) +to be useful: + +```bash +# Create a virtualenv in which we can install the dependencies +virtualenv env +source env/bin/activate +``` + +Now we can install our dependencies: + +```bash +pip install -r requirements.txt +``` + +Now setup our database: + +```bash +# Setup the database +./manage.py migrate + +# Load some example data +./manage.py loaddata ingredients + +# Create an admin user (useful for logging into the admin UI +# at http://127.0.0.1:8000/admin) +./manage.py createsuperuser +``` + +Now you should be ready to start the server: + +```bash +./manage.py runserver +``` + +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 [Graphene-Django Tutorial](http://docs.graphene-python.org/projects/django/en/latest/tutorial#testing-our-graphql-schema) +for some example queries) diff --git a/examples/cookbook-plain/cookbook/__init__.py b/examples/cookbook-plain/cookbook/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/cookbook-plain/cookbook/ingredients/__init__.py b/examples/cookbook-plain/cookbook/ingredients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/cookbook-plain/cookbook/ingredients/admin.py b/examples/cookbook-plain/cookbook/ingredients/admin.py new file mode 100644 index 0000000..b57cbc3 --- /dev/null +++ b/examples/cookbook-plain/cookbook/ingredients/admin.py @@ -0,0 +1,12 @@ +from django.contrib import admin + +from cookbook.ingredients.models import Category, 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-plain/cookbook/ingredients/apps.py b/examples/cookbook-plain/cookbook/ingredients/apps.py new file mode 100644 index 0000000..21b4b08 --- /dev/null +++ b/examples/cookbook-plain/cookbook/ingredients/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class IngredientsConfig(AppConfig): + name = 'cookbook.ingredients' + label = 'ingredients' + verbose_name = 'Ingredients' diff --git a/examples/cookbook-plain/cookbook/ingredients/fixtures/ingredients.json b/examples/cookbook-plain/cookbook/ingredients/fixtures/ingredients.json new file mode 100644 index 0000000..8625d3c --- /dev/null +++ b/examples/cookbook-plain/cookbook/ingredients/fixtures/ingredients.json @@ -0,0 +1 @@ +[{"model": "ingredients.category", "pk": 1, "fields": {"name": "Dairy"}}, {"model": "ingredients.category", "pk": 2, "fields": {"name": "Meat"}}, {"model": "ingredients.ingredient", "pk": 1, "fields": {"name": "Eggs", "notes": "Good old eggs", "category": 1}}, {"model": "ingredients.ingredient", "pk": 2, "fields": {"name": "Milk", "notes": "Comes from a cow", "category": 1}}, {"model": "ingredients.ingredient", "pk": 3, "fields": {"name": "Beef", "notes": "Much like milk, this comes from a cow", "category": 2}}, {"model": "ingredients.ingredient", "pk": 4, "fields": {"name": "Chicken", "notes": "Definitely doesn't come from a cow", "category": 2}}] \ No newline at end of file diff --git a/examples/cookbook-plain/cookbook/ingredients/migrations/0001_initial.py b/examples/cookbook-plain/cookbook/ingredients/migrations/0001_initial.py new file mode 100644 index 0000000..0494923 --- /dev/null +++ b/examples/cookbook-plain/cookbook/ingredients/migrations/0001_initial.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9 on 2015-12-04 18:15 +from __future__ import unicode_literals + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Category', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ], + ), + migrations.CreateModel( + name='Ingredient', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('notes', models.TextField()), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ingredients', to='ingredients.Category')), + ], + ), + ] diff --git a/examples/cookbook-plain/cookbook/ingredients/migrations/0002_auto_20161104_0050.py b/examples/cookbook-plain/cookbook/ingredients/migrations/0002_auto_20161104_0050.py new file mode 100644 index 0000000..359d4fc --- /dev/null +++ b/examples/cookbook-plain/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-plain/cookbook/ingredients/migrations/__init__.py b/examples/cookbook-plain/cookbook/ingredients/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/cookbook-plain/cookbook/ingredients/models.py b/examples/cookbook-plain/cookbook/ingredients/models.py new file mode 100644 index 0000000..2f0eba3 --- /dev/null +++ b/examples/cookbook-plain/cookbook/ingredients/models.py @@ -0,0 +1,17 @@ +from django.db import models + + +class Category(models.Model): + name = models.CharField(max_length=100) + + def __str__(self): + return self.name + + +class Ingredient(models.Model): + name = models.CharField(max_length=100) + notes = models.TextField(null=True, blank=True) + category = models.ForeignKey(Category, related_name='ingredients') + + def __str__(self): + return self.name diff --git a/examples/cookbook-plain/cookbook/ingredients/schema.py b/examples/cookbook-plain/cookbook/ingredients/schema.py new file mode 100644 index 0000000..895f216 --- /dev/null +++ b/examples/cookbook-plain/cookbook/ingredients/schema.py @@ -0,0 +1,57 @@ +import graphene +from graphene_django.types import DjangoObjectType + +from cookbook.ingredients.models import Category, Ingredient + + +class CategoryType(DjangoObjectType): + class Meta: + model = Category + + +class IngredientType(DjangoObjectType): + class Meta: + model = Ingredient + + +class Query(graphene.AbstractType): + category = graphene.Field(CategoryType, + id=graphene.Int(), + name=graphene.String()) + all_categories = graphene.List(CategoryType) + + ingredient = graphene.Field(IngredientType, + id=graphene.Int(), + name=graphene.String()) + all_ingredients = graphene.List(IngredientType) + + def resolve_all_categories(self, args, context, info): + return Category.objects.all() + + def resolve_all_ingredients(self, args, context, info): + # 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') + + if id is not None: + return Category.objects.get(pk=id) + + if name is not None: + return Category.objects.get(name=name) + + return None + + def resolve_ingredient(self, args, context, info): + id = args.get('id') + name = args.get('name') + + if id is not None: + return Ingredient.objects.get(pk=id) + + if name is not None: + return Ingredient.objects.get(name=name) + + return None diff --git a/examples/cookbook-plain/cookbook/recipes/__init__.py b/examples/cookbook-plain/cookbook/recipes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/cookbook-plain/cookbook/recipes/admin.py b/examples/cookbook-plain/cookbook/recipes/admin.py new file mode 100644 index 0000000..10d568f --- /dev/null +++ b/examples/cookbook-plain/cookbook/recipes/admin.py @@ -0,0 +1,12 @@ +from django.contrib import admin + +from cookbook.recipes.models import Recipe, RecipeIngredient + + +class RecipeIngredientInline(admin.TabularInline): + model = RecipeIngredient + + +@admin.register(Recipe) +class RecipeAdmin(admin.ModelAdmin): + inlines = [RecipeIngredientInline] diff --git a/examples/cookbook-plain/cookbook/recipes/apps.py b/examples/cookbook-plain/cookbook/recipes/apps.py new file mode 100644 index 0000000..1f24f13 --- /dev/null +++ b/examples/cookbook-plain/cookbook/recipes/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class RecipesConfig(AppConfig): + name = 'cookbook.recipes' + label = 'recipes' + verbose_name = 'Recipes' diff --git a/examples/cookbook-plain/cookbook/recipes/migrations/0001_initial.py b/examples/cookbook-plain/cookbook/recipes/migrations/0001_initial.py new file mode 100644 index 0000000..338c71a --- /dev/null +++ b/examples/cookbook-plain/cookbook/recipes/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9 on 2015-12-04 18:20 +from __future__ import unicode_literals + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('ingredients', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Recipe', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=100)), + ('instructions', models.TextField()), + ], + ), + migrations.CreateModel( + name='RecipeIngredient', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('amount', models.FloatField()), + ('unit', models.CharField(choices=[('kg', 'Kilograms'), ('l', 'Litres'), ('', 'Units')], max_length=20)), + ('ingredient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='used_by', to='ingredients.Ingredient')), + ('recipes', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='amounts', to='recipes.Recipe')), + ], + ), + ] diff --git a/examples/cookbook-plain/cookbook/recipes/migrations/0002_auto_20161104_0106.py b/examples/cookbook-plain/cookbook/recipes/migrations/0002_auto_20161104_0106.py new file mode 100644 index 0000000..f135392 --- /dev/null +++ b/examples/cookbook-plain/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-plain/cookbook/recipes/migrations/__init__.py b/examples/cookbook-plain/cookbook/recipes/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/cookbook-plain/cookbook/recipes/models.py b/examples/cookbook-plain/cookbook/recipes/models.py new file mode 100644 index 0000000..e688044 --- /dev/null +++ b/examples/cookbook-plain/cookbook/recipes/models.py @@ -0,0 +1,20 @@ +from django.db import models + +from cookbook.ingredients.models import Ingredient + + +class Recipe(models.Model): + title = models.CharField(max_length=100) + instructions = models.TextField() + + +class RecipeIngredient(models.Model): + recipe = models.ForeignKey(Recipe, related_name='amounts') + ingredient = models.ForeignKey(Ingredient, related_name='used_by') + amount = models.FloatField() + unit = models.CharField(max_length=20, choices=( + ('unit', 'Units'), + ('kg', 'Kilograms'), + ('l', 'Litres'), + ('st', 'Shots'), + )) diff --git a/examples/cookbook-plain/cookbook/recipes/schema.py b/examples/cookbook-plain/cookbook/recipes/schema.py new file mode 100644 index 0000000..8ea1ccd --- /dev/null +++ b/examples/cookbook-plain/cookbook/recipes/schema.py @@ -0,0 +1,52 @@ +import graphene +from graphene_django.types import DjangoObjectType + +from cookbook.recipes.models import Recipe, RecipeIngredient + + +class RecipeType(DjangoObjectType): + class Meta: + model = Recipe + + +class RecipeIngredientType(DjangoObjectType): + class Meta: + model = RecipeIngredient + + +class Query(graphene.AbstractType): + recipe = graphene.Field(RecipeType, + id=graphene.Int(), + title=graphene.String()) + all_recipes = graphene.List(RecipeType) + + recipeingredient = graphene.Field(RecipeIngredientType, + id=graphene.Int()) + all_recipeingredients = graphene.List(RecipeIngredientType) + + def resolve_recipe(self, args, context, info): + id = args.get('id') + title = args.get('title') + + if id is not None: + return Recipe.objects.get(pk=id) + + if title is not None: + return Recipe.objects.get(title=title) + + return None + + def resolve_recipeingredient(self, args, context, info): + id = args.get('id') + + if id is not None: + return RecipeIngredient.objects.get(pk=id) + + return None + + def resolve_all_recipes(self, args, context, info): + return Recipe.objects.all() + + def resolve_all_recipeingredients(self, args, context, info): + related = ['recipe', 'ingredient'] + return RecipeIngredient.objects.select_related(*related).all() diff --git a/examples/cookbook-plain/cookbook/schema.py b/examples/cookbook-plain/cookbook/schema.py new file mode 100644 index 0000000..f8606a7 --- /dev/null +++ b/examples/cookbook-plain/cookbook/schema.py @@ -0,0 +1,14 @@ +import cookbook.ingredients.schema +import cookbook.recipes.schema +import graphene + +from graphene_django.debug import DjangoDebug + + +class Query(cookbook.ingredients.schema.Query, + cookbook.recipes.schema.Query, + graphene.ObjectType): + debug = graphene.Field(DjangoDebug, name='__debug') + + +schema = graphene.Schema(query=Query) diff --git a/examples/cookbook-plain/cookbook/settings.py b/examples/cookbook-plain/cookbook/settings.py new file mode 100644 index 0000000..948292d --- /dev/null +++ b/examples/cookbook-plain/cookbook/settings.py @@ -0,0 +1,138 @@ +# flake8: noqa +""" +Django settings for cookbook project. + +Generated by 'django-admin startproject' using Django 1.9. + +For more information on this file, see +https://docs.djangoproject.com/en/1.9/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/1.9/ref/settings/ +""" + +import os + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/1.9/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = '_$=$%eqxk$8ss4n7mtgarw^5$8^d5+c83!vwatr@i_81myb=e4' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'graphene_django', + + 'cookbook.ingredients.apps.IngredientsConfig', + 'cookbook.recipes.apps.RecipesConfig', +] + +MIDDLEWARE_CLASSES = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +GRAPHENE = { + 'SCHEMA': 'cookbook.schema.schema', + 'MIDDLEWARE': ( + 'graphene_django.debug.DjangoDebugMiddleware', + ) +} + +ROOT_URLCONF = 'cookbook.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'cookbook.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/1.9/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + } +} + + +# Password validation +# https://docs.djangoproject.com/en/1.9/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/1.9/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/1.9/howto/static-files/ + +STATIC_URL = '/static/' + +GRAPHENE = { + 'SCHEMA': 'cookbook.schema.schema', + 'SCHEMA_INDENT': 2, +} diff --git a/examples/cookbook-plain/cookbook/urls.py b/examples/cookbook-plain/cookbook/urls.py new file mode 100644 index 0000000..9f8755b --- /dev/null +++ b/examples/cookbook-plain/cookbook/urls.py @@ -0,0 +1,10 @@ +from django.conf.urls import url +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)), +] diff --git a/examples/cookbook-plain/cookbook/wsgi.py b/examples/cookbook-plain/cookbook/wsgi.py new file mode 100644 index 0000000..954b0a8 --- /dev/null +++ b/examples/cookbook-plain/cookbook/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for cookbook project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/1.9/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cookbook.settings") + +application = get_wsgi_application() diff --git a/examples/cookbook-plain/manage.py b/examples/cookbook-plain/manage.py new file mode 100755 index 0000000..8d8a34d --- /dev/null +++ b/examples/cookbook-plain/manage.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cookbook.settings") + + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) diff --git a/examples/cookbook-plain/requirements.txt b/examples/cookbook-plain/requirements.txt new file mode 100644 index 0000000..6931cf4 --- /dev/null +++ b/examples/cookbook-plain/requirements.txt @@ -0,0 +1,5 @@ +graphene +graphene-django +django_graphiql +graphql-core +django==1.9 diff --git a/examples/cookbook-plain/setup.cfg b/examples/cookbook-plain/setup.cfg new file mode 100644 index 0000000..8c6a6e8 --- /dev/null +++ b/examples/cookbook-plain/setup.cfg @@ -0,0 +1,2 @@ +[flake8] +exclude=migrations,.git,__pycache__ diff --git a/examples/cookbook/README.md b/examples/cookbook/README.md index 018c584..1d3fc31 100644 --- a/examples/cookbook/README.md +++ b/examples/cookbook/README.md @@ -3,7 +3,7 @@ Cookbook Example Django Project This example project demos integration between Graphene and Django. The project contains two apps, one named `ingredients` and another -named `recepies`. +named `recipes`. Getting started --------------- diff --git a/graphene_django/converter.py b/graphene_django/converter.py index 706d968..92812d1 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -27,12 +27,16 @@ def convert_choice_name(name): def get_choices(choices): + converted_names = [] for value, help_text in choices: if isinstance(help_text, (tuple, list)): for choice in get_choices(help_text): yield choice else: name = convert_choice_name(value) + while name in converted_names: + name += '_' + str(len(converted_names)) + converted_names.append(name) description = help_text yield name, value, description diff --git a/graphene_django/fields.py b/graphene_django/fields.py index 74ac4d3..e282ebe 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -49,6 +49,7 @@ class DjangoConnectionField(ConnectionField): iterable = default_manager iterable = maybe_queryset(iterable) if isinstance(iterable, QuerySet): + iterable &= maybe_queryset(default_manager) _len = iterable.count() else: _len = len(iterable) diff --git a/graphene_django/filter/fields.py b/graphene_django/filter/fields.py index 83907e7..c6425b8 100644 --- a/graphene_django/filter/fields.py +++ b/graphene_django/filter/fields.py @@ -5,6 +5,7 @@ from functools import partial from graphene.types.argument import to_arguments from ..fields import DjangoConnectionField +from graphene.relay import is_node from .utils import get_filtering_args_from_filterset, get_filterset_class @@ -28,7 +29,15 @@ class DjangoFilterConnectionField(DjangoConnectionField): @property def meta(self): - meta = dict(model=self.node_type._meta.model, + if is_node(self.node_type): + _model = self.node_type._meta.model + else: + # ConnectionFields can also be passed Connections, + # in which case, we need to use the Node of the connection + # to get our relevant args. + _model = self.node_type._meta.node._meta.model + + meta = dict(model=_model, fields=self.fields) if self._extra_filter_meta: meta.update(self._extra_filter_meta) @@ -36,7 +45,16 @@ class DjangoFilterConnectionField(DjangoConnectionField): @property def fields(self): - return self._fields or self.node_type._meta.filter_fields + if self._fields: + return self._fields + + if is_node(self.node_type): + return self.node_type._meta.filter_fields + else: + # ConnectionFields can also be passed Connections, + # in which case, we need to use the Node of the connection + # to get our relevant args. + return self.node_type._meta.node._meta.filter_fields @property def args(self): diff --git a/graphene_django/registry.py b/graphene_django/registry.py index 488fbb2..21fed12 100644 --- a/graphene_django/registry.py +++ b/graphene_django/registry.py @@ -13,7 +13,8 @@ class Registry(object): # assert self.get_type_for_model(cls._meta.model) == cls, ( # 'Multiple DjangoObjectTypes registered for "{}"'.format(cls._meta.model) # ) - self._registry[cls._meta.model] = cls + if not getattr(cls._meta, 'skip_registry', False): + self._registry[cls._meta.model] = cls def get_type_for_model(self, model): return self._registry.get(model) diff --git a/graphene_django/tests/test_converter.py b/graphene_django/tests/test_converter.py index 69be406..997b03c 100644 --- a/graphene_django/tests/test_converter.py +++ b/graphene_django/tests/test_converter.py @@ -176,6 +176,22 @@ def test_field_with_choices_gettext(): convert_django_field_with_choices(field) +def test_field_with_choices_collision(): + field = models.CharField(help_text='Timezone', choices=( + ('Etc/GMT+1+2', 'Fake choice to produce double collision'), + ('Etc/GMT+1', 'Greenwich Mean Time +1'), + ('Etc/GMT-1', 'Greenwich Mean Time -1'), + )) + + class CollisionChoicesModel(models.Model): + timezone = field + + class Meta: + app_label = 'test' + + convert_django_field_with_choices(field) + + def test_should_float_convert_float(): assert_conversion(models.FloatField, graphene.Float) diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py index 9c31243..750a421 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -8,6 +8,7 @@ from py.test import raises import graphene from graphene.relay import Node +from ..utils import DJANGO_FILTER_INSTALLED from ..compat import MissingType, RangeField from ..fields import DjangoConnectionField from ..types import DjangoObjectType @@ -281,3 +282,85 @@ def test_should_query_connectionfields(): }] } } + + +@pytest.mark.skipif(not DJANGO_FILTER_INSTALLED, + reason="django-filter should be installed") +def test_should_query_node_filtering(): + class ReporterType(DjangoObjectType): + + class Meta: + model = Reporter + interfaces = (Node, ) + + class ArticleType(DjangoObjectType): + + class Meta: + model = Article + interfaces = (Node, ) + filter_fields = ('lang', ) + + class Query(graphene.ObjectType): + all_reporters = DjangoConnectionField(ReporterType) + + r = Reporter.objects.create( + first_name='John', + last_name='Doe', + email='johndoe@example.com', + a_choice=1 + ) + Article.objects.create( + headline='Article Node 1', + pub_date=datetime.date.today(), + reporter=r, + editor=r, + lang='es' + ) + Article.objects.create( + headline='Article Node 2', + pub_date=datetime.date.today(), + reporter=r, + editor=r, + lang='en' + ) + + schema = graphene.Schema(query=Query) + query = ''' + query NodeFilteringQuery { + allReporters { + edges { + node { + id + articles(lang: "es") { + edges { + node { + id + } + } + } + } + } + } + } + ''' + + expected = { + 'allReporters': { + 'edges': [{ + 'node': { + 'id': 'UmVwb3J0ZXJUeXBlOjE=', + 'articles': { + 'edges': [{ + 'node': { + 'id': 'QXJ0aWNsZVR5cGU6MQ==' + } + }] + } + } + }] + } + } + + result = schema.execute(query) + assert not result.errors + assert result.data == expected diff --git a/graphene_django/tests/test_views.py b/graphene_django/tests/test_views.py index e7ec187..da7ad04 100644 --- a/graphene_django/tests/test_views.py +++ b/graphene_django/tests/test_views.py @@ -183,6 +183,15 @@ def test_batch_allows_post_with_json_encoding(client): }] +def test_batch_fails_if_is_empty(client): + response = client.post(batch_url_string(), '[]', 'application/json') + + assert response.status_code == 400 + assert response_json(response) == { + 'errors': [{'message': 'Received an empty list in the batch request.'}] + } + + def test_allows_sending_a_mutation_via_post(client): response = client.post(url_string(), j(query='mutation TestMutation { writeTest { test } }'), 'application/json') @@ -432,9 +441,18 @@ def test_handles_errors_caused_by_a_lack_of_query(client): } -def test_handles_invalid_json_bodies(client): +def test_handles_not_expected_json_bodies(client): response = client.post(url_string(), '[]', 'application/json') + assert response.status_code == 400 + assert response_json(response) == { + 'errors': [{'message': 'The received data is not a valid JSON query.'}] + } + + +def test_handles_invalid_json_bodies(client): + response = client.post(url_string(), '[oh}', 'application/json') + assert response.status_code == 400 assert response_json(response) == { 'errors': [{'message': 'POST body sent invalid JSON.'}] diff --git a/graphene_django/types.py b/graphene_django/types.py index 8174f05..ff88779 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -58,6 +58,7 @@ class DjangoObjectTypeMeta(ObjectTypeMeta): only_fields=(), exclude_fields=(), interfaces=(), + skip_registry=False, registry=None ) if DJANGO_FILTER_INSTALLED: diff --git a/graphene_django/views.py b/graphene_django/views.py index a68fd53..df37931 100644 --- a/graphene_django/views.py +++ b/graphene_django/views.py @@ -193,10 +193,19 @@ class GraphQLView(View): try: request_json = json.loads(request.body.decode('utf-8')) if self.batch: - assert isinstance(request_json, list) + assert isinstance(request_json, list), ( + 'Batch requests should receive a list, but received {}.' + ).format(repr(request_json)) + assert len(request_json) > 0, ( + 'Received an empty list in the batch request.' + ) else: - assert isinstance(request_json, dict) + assert isinstance(request_json, dict), ( + 'The received data is not a valid JSON query.' + ) return request_json + except AssertionError as e: + raise HttpError(HttpResponseBadRequest(str(e))) except: raise HttpError(HttpResponseBadRequest('POST body sent invalid JSON.')) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..4e47ff4 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +DJANGO_SETTINGS_MODULE = django_test_settings diff --git a/setup.cfg b/setup.cfg index c50ce70..fe61dcf 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,9 +1,6 @@ [aliases] test=pytest -[tool:pytest] -DJANGO_SETTINGS_MODULE = django_test_settings - [flake8] exclude = setup.py,docs/*,examples/*,tests,graphene_django/debug/sql/* max-line-length = 120