diff --git a/.travis.yml b/.travis.yml
index a8375ee..07ee59f 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,19 +1,21 @@
language: python
-sudo: false
+sudo: required
+dist: xenial
python:
- 2.7
- 3.4
- 3.5
- 3.6
+- 3.7
install:
- |
if [ "$TEST_TYPE" = build ]; then
pip install -e .[test]
- pip install psycopg2 # Required for Django postgres fields testing
+ pip install psycopg2==2.8.2 # Required for Django postgres fields testing
pip install django==$DJANGO_VERSION
python setup.py develop
elif [ "$TEST_TYPE" = lint ]; then
- pip install flake8
+ pip install flake8==3.7.7
fi
script:
- |
@@ -45,10 +47,16 @@ matrix:
env: TEST_TYPE=build DJANGO_VERSION=2.1
- python: '3.6'
env: TEST_TYPE=build DJANGO_VERSION=2.1
+ - python: '3.6'
+ env: TEST_TYPE=build DJANGO_VERSION=2.2
+ - python: '3.7'
+ env: TEST_TYPE=build DJANGO_VERSION=2.2
- python: '2.7'
env: TEST_TYPE=lint
- python: '3.6'
env: TEST_TYPE=lint
+ - python: '3.7'
+ env: TEST_TYPE=lint
deploy:
provider: pypi
user: syrusakbary
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..5560ba2
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,76 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+In the interest of fostering an open and welcoming environment, we as
+contributors and maintainers pledge to making participation in our project and
+our community a harassment-free experience for everyone, regardless of age, body
+size, disability, ethnicity, sex characteristics, gender identity and expression,
+level of experience, education, socio-economic status, nationality, personal
+appearance, race, religion, or sexual identity and orientation.
+
+## Our Standards
+
+Examples of behavior that contributes to creating a positive environment
+include:
+
+* Using welcoming and inclusive language
+* Being respectful of differing viewpoints and experiences
+* Gracefully accepting constructive criticism
+* Focusing on what is best for the community
+* Showing empathy towards other community members
+
+Examples of unacceptable behavior by participants include:
+
+* The use of sexualized language or imagery and unwelcome sexual attention or
+ advances
+* Trolling, insulting/derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or electronic
+ address, without explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Our Responsibilities
+
+Project maintainers are responsible for clarifying the standards of acceptable
+behavior and are expected to take appropriate and fair corrective action in
+response to any instances of unacceptable behavior.
+
+Project maintainers have the right and responsibility to remove, edit, or
+reject comments, commits, code, wiki edits, issues, and other contributions
+that are not aligned to this Code of Conduct, or to ban temporarily or
+permanently any contributor for other behaviors that they deem inappropriate,
+threatening, offensive, or harmful.
+
+## Scope
+
+This Code of Conduct applies both within project spaces and in public spaces
+when an individual is representing the project or its community. Examples of
+representing a project or community include using an official project e-mail
+address, posting via an official social media account, or acting as an appointed
+representative at an online or offline event. Representation of a project may be
+further defined and clarified by project maintainers.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported by contacting the project team at me@syrusakbary.com. All
+complaints will be reviewed and investigated and will result in a response that
+is deemed necessary and appropriate to the circumstances. The project team is
+obligated to maintain confidentiality with regard to the reporter of an incident.
+Further details of specific enforcement policies may be posted separately.
+
+Project maintainers who do not follow or enforce the Code of Conduct in good
+faith may face temporary or permanent repercussions as determined by other
+members of the project's leadership.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
+available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
+
+[homepage]: https://www.contributor-covenant.org
+
+For answers to common questions about this code of conduct, see
+https://www.contributor-covenant.org/faq
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..f9428e9
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,62 @@
+# Contributing
+
+Thanks for helping to make graphene-django great!
+
+We welcome all kinds of contributions:
+
+- Bug fixes
+- Documentation improvements
+- New features
+- Refactoring & tidying
+
+
+## Getting started
+
+If you have a specific contribution in mind, be sure to check the [issues](https://github.com/graphql-python/graphene-django/issues) and [projects](https://github.com/graphql-python/graphene-django/projects) in progress - someone could already be working on something similar and you can help out.
+
+
+## Project setup
+
+After cloning this repo, ensure dependencies are installed by running:
+
+```sh
+make dev-setup
+```
+
+## Running tests
+
+After developing, the full test suite can be evaluated by running:
+
+```sh
+make tests
+```
+
+## Opening Pull Requests
+
+Please fork the project and open a pull request against the master branch.
+
+This will trigger a series of test and lint checks.
+
+We advise that you format and run lint locally before doing this to save time:
+
+```sh
+make format
+make lint
+```
+
+## Documentation
+
+The [documentation](http://docs.graphene-python.org/projects/django/en/latest/) is generated using the excellent [Sphinx](http://www.sphinx-doc.org/) and a custom theme.
+
+The documentation dependencies are installed by running:
+
+```sh
+cd docs
+pip install -r requirements.txt
+```
+
+Then to produce a HTML version of the documentation:
+
+```sh
+make html
+```
\ No newline at end of file
diff --git a/MANIFEST.in b/MANIFEST.in
index 3c3d4f9..4677330 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,2 +1,3 @@
include README.md LICENSE
recursive-include graphene_django/templates *
+recursive-include graphene_django/static *
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..061ad4e
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,11 @@
+dev-setup:
+ pip install -e ".[dev]"
+
+tests:
+ py.test graphene_django --cov=graphene_django -vv
+
+format:
+ black graphene_django
+
+lint:
+ flake8 graphene_django
diff --git a/README.md b/README.md
index 4e0b01d..159a592 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,11 @@ Please read [UPGRADE-v2.0.md](https://github.com/graphql-python/graphene/blob/ma
A [Django](https://www.djangoproject.com/) integration for [Graphene](http://graphene-python.org/).
-## Installation
+## Documentation
+
+[Visit the documentation to get started!](https://docs.graphene-python.org/projects/django/en/latest/)
+
+## Quickstart
For installing graphene, just run this command in your shell
@@ -20,6 +24,7 @@ pip install "graphene-django>=2.0"
```python
INSTALLED_APPS = (
# ...
+ 'django.contrib.staticfiles', # Required for GraphiQL
'graphene_django',
)
@@ -38,7 +43,7 @@ from graphene_django.views import GraphQLView
urlpatterns = [
# ...
- url(r'^graphql', GraphQLView.as_view(graphiql=True)),
+ url(r'^graphql$', GraphQLView.as_view(graphiql=True)),
]
```
@@ -95,32 +100,4 @@ To learn more check out the following [examples](examples/):
## Contributing
-After cloning this repo, ensure dependencies are installed by running:
-
-```sh
-pip install -e ".[test]"
-```
-
-After developing, the full test suite can be evaluated by running:
-
-```sh
-py.test graphene_django --cov=graphene_django # Use -v -s for verbose mode
-```
-
-
-### Documentation
-
-The [documentation](http://docs.graphene-python.org/projects/django/en/latest/) is generated using the excellent [Sphinx](http://www.sphinx-doc.org/) and a custom theme.
-
-The documentation dependencies are installed by running:
-
-```sh
-cd docs
-pip install -r requirements.txt
-```
-
-Then to produce a HTML version of the documentation:
-
-```sh
-make html
-```
+See [CONTRIBUTING.md](CONTRIBUTING.md)
\ No newline at end of file
diff --git a/README.rst b/README.rst
index a96e60f..44feaee 100644
--- a/README.rst
+++ b/README.rst
@@ -10,8 +10,14 @@ to learn how to upgrade to Graphene ``2.0``.
A `Django `__ integration for
`Graphene `__.
-Installation
-------------
+
+Documentation
+-------------
+
+`Visit the documentation to get started! `__
+
+Quickstart
+----------
For installing graphene, just run this command in your shell
@@ -46,7 +52,7 @@ serve the queries.
urlpatterns = [
# ...
- url(r'^graphql', GraphQLView.as_view(graphiql=True)),
+ url(r'^graphql$', GraphQLView.as_view(graphiql=True)),
]
Examples
@@ -105,36 +111,7 @@ To learn more check out the following `examples `__:
Contributing
------------
-After cloning this repo, ensure dependencies are installed by running:
-
-.. code:: sh
-
- pip install -e ".[test]"
-
-After developing, the full test suite can be evaluated by running:
-
-.. code:: sh
-
- py.test graphene_django --cov=graphene_django # Use -v -s for verbose mode
-
-Documentation
-~~~~~~~~~~~~~
-
-The `documentation `__ is generated using the excellent
-`Sphinx `__ and a custom theme.
-
-The documentation dependencies are installed by running:
-
-.. code:: sh
-
- cd docs
- pip install -r requirements.txt
-
-Then to produce a HTML version of the documentation:
-
-.. code:: sh
-
- make html
+See `CONTRIBUTING.md `__.
.. |Graphene Logo| image:: http://graphene-python.org/favicon.png
.. |Build Status| image:: https://travis-ci.org/graphql-python/graphene-django.svg?branch=master
diff --git a/docs/authorization.rst b/docs/authorization.rst
index 86ad66a..3d0bb8a 100644
--- a/docs/authorization.rst
+++ b/docs/authorization.rst
@@ -96,6 +96,29 @@ schema is simple.
result = schema.execute(query, context_value=request)
+
+Global Filtering
+----------------
+
+If you are using ``DjangoObjectType`` you can define a custom `get_queryset`.
+
+.. code:: python
+
+ from graphene import relay
+ from graphene_django.types import DjangoObjectType
+ from .models import Post
+
+ class PostNode(DjangoObjectType):
+ class Meta:
+ model = Post
+
+ @classmethod
+ def get_queryset(cls, queryset, info):
+ if info.context.user.is_anonymous:
+ return queryset.filter(published=True)
+ return queryset
+
+
Filtering ID-based Node Access
------------------------------
@@ -132,7 +155,7 @@ To restrict users from accessing the GraphQL API page the standard Django LoginR
.. code:: python
#views.py
-
+
from django.contrib.auth.mixins import LoginRequiredMixin
from graphene_django.views import GraphQLView
@@ -148,9 +171,9 @@ For Django 1.9 and below:
urlpatterns = [
# some other urls
- url(r'^graphql', PrivateGraphQLView.as_view(graphiql=True, schema=schema)),
+ url(r'^graphql$', PrivateGraphQLView.as_view(graphiql=True, schema=schema)),
]
-
+
For Django 2.0 and above:
.. code:: python
diff --git a/docs/conf.py b/docs/conf.py
index 2ea2d55..a485d5b 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -1,6 +1,6 @@
import os
-on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
+on_rtd = os.environ.get("READTHEDOCS", None) == "True"
# -*- coding: utf-8 -*-
#
@@ -34,46 +34,44 @@ on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
- 'sphinx.ext.autodoc',
- 'sphinx.ext.intersphinx',
- 'sphinx.ext.todo',
- 'sphinx.ext.coverage',
- 'sphinx.ext.viewcode',
+ "sphinx.ext.autodoc",
+ "sphinx.ext.intersphinx",
+ "sphinx.ext.todo",
+ "sphinx.ext.coverage",
+ "sphinx.ext.viewcode",
]
if not on_rtd:
- extensions += [
- 'sphinx.ext.githubpages',
- ]
+ extensions += ["sphinx.ext.githubpages"]
# Add any paths that contain templates here, relative to this directory.
-templates_path = ['_templates']
+templates_path = ["_templates"]
# The suffix(es) of source filenames.
# You can specify multiple suffix as a list of string:
#
# source_suffix = ['.rst', '.md']
-source_suffix = '.rst'
+source_suffix = ".rst"
# The encoding of source files.
#
# source_encoding = 'utf-8-sig'
# The master toctree document.
-master_doc = 'index'
+master_doc = "index"
# General information about the project.
-project = u'Graphene Django'
-copyright = u'Graphene 2017'
-author = u'Syrus Akbary'
+project = u"Graphene Django"
+copyright = u"Graphene 2017"
+author = u"Syrus Akbary"
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
-version = u'1.0'
+version = u"1.0"
# The full version, including alpha/beta/rc tags.
-release = u'1.0.dev'
+release = u"1.0.dev"
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
@@ -94,7 +92,7 @@ language = None
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This patterns also effect to html_static_path and html_extra_path
-exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
+exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
# The reST default role (used for this markup: `text`) to use for all
# documents.
@@ -116,7 +114,7 @@ exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
# show_authors = False
# The name of the Pygments (syntax highlighting) style to use.
-pygments_style = 'sphinx'
+pygments_style = "sphinx"
# A list of ignored prefixes for module index sorting.
# modindex_common_prefix = []
@@ -175,7 +173,7 @@ html_theme_path = [sphinx_graphene_theme.get_html_theme_path()]
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
-html_static_path = ['_static']
+html_static_path = ["_static"]
# Add any extra paths that contain custom files (such as robots.txt or
# .htaccess) here, relative to this directory. These files are copied
@@ -255,34 +253,30 @@ html_static_path = ['_static']
# html_search_scorer = 'scorer.js'
# Output file base name for HTML help builder.
-htmlhelp_basename = 'Graphenedoc'
+htmlhelp_basename = "Graphenedoc"
# -- Options for LaTeX output ---------------------------------------------
latex_elements = {
- # The paper size ('letterpaper' or 'a4paper').
- #
- # 'papersize': 'letterpaper',
-
- # The font size ('10pt', '11pt' or '12pt').
- #
- # 'pointsize': '10pt',
-
- # Additional stuff for the LaTeX preamble.
- #
- # 'preamble': '',
-
- # Latex figure (float) alignment
- #
- # 'figure_align': 'htbp',
+ # The paper size ('letterpaper' or 'a4paper').
+ #
+ # 'papersize': 'letterpaper',
+ # The font size ('10pt', '11pt' or '12pt').
+ #
+ # 'pointsize': '10pt',
+ # Additional stuff for the LaTeX preamble.
+ #
+ # 'preamble': '',
+ # Latex figure (float) alignment
+ #
+ # 'figure_align': 'htbp',
}
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
latex_documents = [
- (master_doc, 'Graphene.tex', u'Graphene Documentation',
- u'Syrus Akbary', 'manual'),
+ (master_doc, "Graphene.tex", u"Graphene Documentation", u"Syrus Akbary", "manual")
]
# The name of an image file (relative to this directory) to place at the top of
@@ -323,8 +317,7 @@ latex_documents = [
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
- (master_doc, 'graphene_django', u'Graphene Django Documentation',
- [author], 1)
+ (master_doc, "graphene_django", u"Graphene Django Documentation", [author], 1)
]
# If true, show URL addresses after external links.
@@ -338,9 +331,15 @@ man_pages = [
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
- (master_doc, 'Graphene-Django', u'Graphene Django Documentation',
- author, 'Graphene Django', 'One line description of project.',
- 'Miscellaneous'),
+ (
+ master_doc,
+ "Graphene-Django",
+ u"Graphene Django Documentation",
+ author,
+ "Graphene Django",
+ "One line description of project.",
+ "Miscellaneous",
+ )
]
# Documents to append as an appendix to all manuals.
@@ -414,7 +413,7 @@ epub_copyright = copyright
# epub_post_files = []
# A list of files that should not be packed into the epub file.
-epub_exclude_files = ['search.html']
+epub_exclude_files = ["search.html"]
# The depth of the table of contents in toc.ncx.
#
@@ -446,4 +445,4 @@ epub_exclude_files = ['search.html']
# Example configuration for intersphinx: refer to the Python standard library.
-intersphinx_mapping = {'https://docs.python.org/': None}
+intersphinx_mapping = {"https://docs.python.org/": None}
diff --git a/docs/debug.rst b/docs/debug.rst
index 8ef2e86..d1cbb21 100644
--- a/docs/debug.rst
+++ b/docs/debug.rst
@@ -15,7 +15,7 @@ For use the Django Debug plugin in Graphene:
* Add ``graphene_django.debug.DjangoDebugMiddleware`` into ``MIDDLEWARE`` in the ``GRAPHENE`` settings.
-* Add the ``debug`` field into the schema root ``Query`` with the value ``graphene.Field(DjangoDebug, name='__debug')``.
+* Add the ``debug`` field into the schema root ``Query`` with the value ``graphene.Field(DjangoDebug, name='_debug')``.
.. code:: python
@@ -24,7 +24,7 @@ For use the Django Debug plugin in Graphene:
class Query(graphene.ObjectType):
# ...
- debug = graphene.Field(DjangoDebug, name='__debug')
+ debug = graphene.Field(DjangoDebug, name='_debug')
schema = graphene.Schema(query=Query)
@@ -34,6 +34,7 @@ And in your ``settings.py``:
.. code:: python
GRAPHENE = {
+ ...
'MIDDLEWARE': [
'graphene_django.debug.DjangoDebugMiddleware',
]
@@ -58,11 +59,11 @@ the GraphQL request, like:
}
}
# Here is the debug field that will output the SQL queries
- __debug {
+ _debug {
sql {
rawSql
}
}
}
-Note that the ``__debug`` field must be the last field in your query.
+Note that the ``_debug`` field must be the last field in your query.
diff --git a/docs/filtering.rst b/docs/filtering.rst
index feafd40..d02366f 100644
--- a/docs/filtering.rst
+++ b/docs/filtering.rst
@@ -136,7 +136,7 @@ pre-filter animals owned by the authenticated user (set in ``context.user``).
class AnimalFilter(django_filters.FilterSet):
# Do case-insensitive lookups on 'name'
- name = django_filters.CharFilter(lookup_type='iexact')
+ name = django_filters.CharFilter(lookup_type=['iexact'])
class Meta:
model = Animal
@@ -146,3 +146,49 @@ pre-filter animals owned by the authenticated user (set in ``context.user``).
def qs(self):
# The query context can be found in self.request.
return super(AnimalFilter, self).qs.filter(owner=self.request.user)
+
+
+Ordering
+--------
+
+You can use ``OrderFilter`` to define how you want your returned results to be ordered.
+
+Extend the tuple of fields if you want to order by more than one field.
+
+.. code:: python
+
+ from django_filters import FilterSet, OrderingFilter
+
+ class UserFilter(FilterSet):
+ class Meta:
+ model = UserModel
+
+ order_by = OrderingFilter(
+ fields=(
+ ('created_at', 'created_at'),
+ )
+ )
+
+ class Group(DjangoObjectType):
+ users = DjangoFilterConnectionField(Ticket, filterset_class=UserFilter)
+
+ class Meta:
+ name = 'Group'
+ model = GroupModel
+ interfaces = (relay.Node,)
+
+ def resolve_users(self, info, **kwargs):
+ return UserFilter(kwargs).qs
+
+
+with this set up, you can now order the users under group:
+
+.. code::
+
+ query {
+ group(id: "xxx") {
+ users(orderBy: "-created_at") {
+ xxx
+ }
+ }
+ }
\ No newline at end of file
diff --git a/docs/form-mutations.rst b/docs/form-mutations.rst
deleted file mode 100644
index e721a78..0000000
--- a/docs/form-mutations.rst
+++ /dev/null
@@ -1,68 +0,0 @@
-Integration with Django forms
-=============================
-
-Graphene-Django comes with mutation classes that will convert the fields on Django forms into inputs on a mutation.
-*Note: the API is experimental and will likely change in the future.*
-
-FormMutation
-------------
-
-.. code:: python
-
- class MyForm(forms.Form):
- name = forms.CharField()
-
- class MyMutation(FormMutation):
- class Meta:
- form_class = MyForm
-
-``MyMutation`` will automatically receive an ``input`` argument. This argument should be a ``dict`` where the key is ``name`` and the value is a string.
-
-ModelFormMutation
------------------
-
-``ModelFormMutation`` will pull the fields from a ``ModelForm``.
-
-.. code:: python
-
- class Pet(models.Model):
- name = models.CharField()
-
- class PetForm(forms.ModelForm):
- class Meta:
- model = Pet
- fields = ('name',)
-
- # This will get returned when the mutation completes successfully
- class PetType(DjangoObjectType):
- class Meta:
- model = Pet
-
- class PetMutation(DjangoModelFormMutation):
- class Meta:
- form_class = PetForm
-
-``PetMutation`` will grab the fields from ``PetForm`` and turn them into inputs. If the form is valid then the mutation
-will lookup the ``DjangoObjectType`` for the ``Pet`` model and return that under the key ``pet``. Otherwise it will
-return a list of errors.
-
-You can change the input name (default is ``input``) and the return field name (default is the model name lowercase).
-
-.. code:: python
-
- class PetMutation(DjangoModelFormMutation):
- class Meta:
- form_class = PetForm
- input_field_name = 'data'
- return_field_name = 'my_pet'
-
-Form validation
----------------
-
-Form mutations will call ``is_valid()`` on your forms.
-
-If the form is valid then ``form_valid(form, info)`` is called on the mutation. Override this method to change how
-the form is saved or to return a different Graphene object type.
-
-If the form is *not* valid then a list of errors will be returned. These errors have two fields: ``field``, a string
-containing the name of the invalid form field, and ``messages``, a list of strings with the validation messages.
diff --git a/docs/index.rst b/docs/index.rst
index 7c64ae7..602f8dd 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -1,16 +1,34 @@
Graphene-Django
===============
-Contents:
+Welcome to the Graphene-Django docs.
+
+Graphene-Django is built on top of `Graphene `__.
+Graphene-Django provides some additional abstractions that make it easy to add GraphQL functionality to your Django project.
+
+First time? We recommend you start with the installation guide to get set up and the basic tutorial.
+It is worth reading the `core graphene docs `__ to familiarize yourself with the basic utilities.
+
+Core tenants
+------------
+
+If you want to expose your data through GraphQL - read the ``Installation``, ``Schema`` and ``Queries`` section.
+
+
+For more advanced use, check out the Relay tutorial.
.. toctree::
- :maxdepth: 0
+ :maxdepth: 1
+ installation
tutorial-plain
tutorial-relay
+ schema
+ queries
+ mutations
filtering
authorization
debug
- rest-framework
- form-mutations
introspection
+ testing
+ settings
diff --git a/docs/installation.rst b/docs/installation.rst
new file mode 100644
index 0000000..a2dc665
--- /dev/null
+++ b/docs/installation.rst
@@ -0,0 +1,69 @@
+Installation
+============
+
+Graphene-Django takes a few seconds to install and set up.
+
+Requirements
+------------
+
+Graphene-Django currently supports the following versions of Django:
+
+* Django 2.X
+
+Installation
+------------
+
+.. code:: bash
+
+ pip install graphene-django
+
+**We strongly recommend pinning against a specific version of Graphene-Django because new versions could introduce breaking changes to your project.**
+
+Add ``graphene_django`` to the ``INSTALLED_APPS`` in the ``settings.py`` file of your Django project:
+
+.. code:: python
+
+ INSTALLED_APPS = [
+ ...
+ 'django.contrib.staticfiles', # Required for GraphiQL
+ 'graphene_django'
+ ]
+
+
+We need to add a ``graphql`` URL to the ``urls.py`` of your Django project:
+
+.. code:: python
+
+ from django.urls import path
+ from graphene_django.views import GraphQLView
+
+ urlpatterns = [
+ # ...
+ path("graphql", GraphQLView.as_view(graphiql=True)),
+ ]
+
+(Change ``graphiql=True`` to ``graphiql=False`` if you do not want to use the GraphiQL API browser.)
+
+Finally, define the schema location for Graphene in the ``settings.py`` file of your Django project:
+
+.. code:: python
+
+ GRAPHENE = {
+ 'SCHEMA': 'django_root.schema.schema'
+ }
+
+Where ``path.schema.schema`` is the location of the ``Schema`` object in your Django project.
+
+The most basic ``schema.py`` looks like this:
+
+.. code:: python
+
+ import graphene
+
+ class Query(graphene.ObjectType):
+ pass
+
+ schema = graphene.Schema(query=Query)
+
+
+To learn how to extend the schema object for your project, read the basic tutorial.
\ No newline at end of file
diff --git a/docs/introspection.rst b/docs/introspection.rst
index 0d30ee4..0fc6776 100644
--- a/docs/introspection.rst
+++ b/docs/introspection.rst
@@ -1,17 +1,15 @@
Introspection Schema
====================
-Relay uses `Babel Relay
-Plugin `__
-that requires you to provide your GraphQL schema data.
+Relay Modern uses `Babel Relay Plugin `__ which requires you to provide your GraphQL schema data.
-Graphene comes with a management command for Django to dump your schema
-data to ``schema.json`` that is compatible with babel-relay-plugin.
+Graphene comes with a Django management command to dump your schema
+data to ``schema.json`` which is compatible with babel-relay-plugin.
Usage
-----
-Include ``graphene_django`` to ``INSTALLED_APPS`` in you project
+Include ``graphene_django`` to ``INSTALLED_APPS`` in your project
settings:
.. code:: python
@@ -29,6 +27,8 @@ It dumps your full introspection schema to ``schema.json`` inside your
project root directory. Point ``babel-relay-plugin`` to this file and
you're ready to use Relay with Graphene GraphQL implementation.
+The schema file is sorted to create a reproducible canonical representation.
+
Advanced Usage
--------------
diff --git a/docs/mutations.rst b/docs/mutations.rst
new file mode 100644
index 0000000..f6c6f14
--- /dev/null
+++ b/docs/mutations.rst
@@ -0,0 +1,229 @@
+Mutations
+=========
+
+Introduction
+------------
+
+Graphene-Django makes it easy to perform mutations.
+
+With Graphene-Django we can take advantage of pre-existing Django features to
+quickly build CRUD functionality, while still using the core `graphene mutation `__
+features to add custom mutations to a Django project.
+
+Simple example
+--------------
+
+.. code:: python
+
+ import graphene
+
+ from graphene_django import DjangoObjectType
+
+ from .models import Question
+
+
+ class QuestionType(DjangoObjectType):
+ class Meta:
+ model = Question
+
+
+ class QuestionMutation(graphene.Mutation):
+ class Arguments:
+ # The input arguments for this mutation
+ text = graphene.String(required=True)
+ id = graphene.ID()
+
+ # The class attributes define the response of the mutation
+ question = graphene.Field(QuestionType)
+
+ def mutate(self, info, text, id):
+ question = Question.objects.get(pk=id)
+ question.text = text
+ question.save()
+ # Notice we return an instance of this mutation
+ return QuestionMutation(question=question)
+
+
+ class Mutation:
+ update_question = QuestionMutation.Field()
+
+
+Django Forms
+------------
+
+Graphene-Django comes with mutation classes that will convert the fields on Django forms into inputs on a mutation.
+
+DjangoFormMutation
+~~~~~~~~~~~~~~~~~~
+
+.. code:: python
+
+ from graphene_django.forms.mutation import DjangoFormMutation
+
+ class MyForm(forms.Form):
+ name = forms.CharField()
+
+ class MyMutation(DjangoFormMutation):
+ class Meta:
+ form_class = MyForm
+
+``MyMutation`` will automatically receive an ``input`` argument. This argument should be a ``dict`` where the key is ``name`` and the value is a string.
+
+DjangoModelFormMutation
+~~~~~~~~~~~~~~~~~~~~~~~
+
+``DjangoModelFormMutation`` will pull the fields from a ``ModelForm``.
+
+.. code:: python
+
+ from graphene_django.forms.mutation import DjangoModelFormMutation
+
+ class Pet(models.Model):
+ name = models.CharField()
+
+ class PetForm(forms.ModelForm):
+ class Meta:
+ model = Pet
+ fields = ('name',)
+
+ # This will get returned when the mutation completes successfully
+ class PetType(DjangoObjectType):
+ class Meta:
+ model = Pet
+
+ class PetMutation(DjangoModelFormMutation):
+ pet = Field(PetType)
+
+ class Meta:
+ form_class = PetForm
+
+``PetMutation`` will grab the fields from ``PetForm`` and turn them into inputs. If the form is valid then the mutation
+will lookup the ``DjangoObjectType`` for the ``Pet`` model and return that under the key ``pet``. Otherwise it will
+return a list of errors.
+
+You can change the input name (default is ``input``) and the return field name (default is the model name lowercase).
+
+.. code:: python
+
+ class PetMutation(DjangoModelFormMutation):
+ class Meta:
+ form_class = PetForm
+ input_field_name = 'data'
+ return_field_name = 'my_pet'
+
+Form validation
+~~~~~~~~~~~~~~~
+
+Form mutations will call ``is_valid()`` on your forms.
+
+If the form is valid then the class method ``perform_mutate(form, info)`` is called on the mutation. Override this method
+to change how the form is saved or to return a different Graphene object type.
+
+If the form is *not* valid then a list of errors will be returned. These errors have two fields: ``field``, a string
+containing the name of the invalid form field, and ``messages``, a list of strings with the validation messages.
+
+
+Django REST Framework
+---------------------
+
+You can re-use your Django Rest Framework serializer with Graphene Django mutations.
+
+You can create a Mutation based on a serializer by using the `SerializerMutation` base class:
+
+.. code:: python
+
+ from graphene_django.rest_framework.mutation import SerializerMutation
+
+ class MyAwesomeMutation(SerializerMutation):
+ class Meta:
+ serializer_class = MySerializer
+
+
+Create/Update Operations
+~~~~~~~~~~~~~~~~~~~~~~~~
+
+By default ModelSerializers accept create and update operations. To
+customize this use the `model_operations` attribute on the ``SerializerMutation`` class.
+
+The update operation looks up models by the primary key by default. You can
+customize the look up with the ``lookup_field`` attribute on the ``SerializerMutation`` class.
+
+.. code:: python
+
+ from graphene_django.rest_framework.mutation import SerializerMutation
+ from .serializers imoprt MyModelSerializer
+
+
+ class AwesomeModelMutation(SerializerMutation):
+ class Meta:
+ serializer_class = MyModelSerializer
+ model_operations = ['create', 'update']
+ lookup_field = 'id'
+
+Overriding Update Queries
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Use the method ``get_serializer_kwargs`` to override how updates are applied.
+
+.. code:: python
+
+ from graphene_django.rest_framework.mutation import SerializerMutation
+ from .serializers imoprt MyModelSerializer
+
+
+ class AwesomeModelMutation(SerializerMutation):
+ class Meta:
+ serializer_class = MyModelSerializer
+
+ @classmethod
+ def get_serializer_kwargs(cls, root, info, **input):
+ if 'id' in input:
+ instance = Post.objects.filter(
+ id=input['id'], owner=info.context.user
+ ).first()
+ if instance:
+ return {'instance': instance, 'data': input, 'partial': True}
+
+ else:
+ raise http.Http404
+
+ return {'data': input, 'partial': True}
+
+
+
+Relay
+-----
+
+You can use relay with mutations. A Relay mutation must inherit from
+``ClientIDMutation`` and implement the ``mutate_and_get_payload`` method:
+
+.. code:: python
+
+ import graphene import relay, DjangoObjectType
+ from graphql_relay import from_global_id
+
+ from .queries import QuestionType
+
+
+ class QuestionMutation(relay.ClientIDMutation):
+ class Input:
+ text = graphene.String(required=True)
+ id = graphene.ID()
+
+ question = graphene.Field(QuestionType)
+
+ @classmethod
+ def mutate_and_get_payload(cls, root, info, text, id):
+ question = Question.objects.get(pk=from_global_id(id))
+ question.text = text
+ question.save()
+ return QuestionMutation(question=question)
+
+Notice that the ``class Arguments`` is renamed to ``class Input`` with relay.
+This is due to a deprecation of ``class Arguments`` in graphene 2.0.
+
+Relay ClientIDMutation accept a ``clientIDMutation`` argument.
+This argument is also sent back to the client with the mutation result
+(you do not have to do anything). For services that manage
+a pool of many GraphQL requests in bulk, the ``clientIDMutation``
+allows you to match up a specific mutation with the response.
\ No newline at end of file
diff --git a/docs/queries.rst b/docs/queries.rst
new file mode 100644
index 0000000..0edd1dd
--- /dev/null
+++ b/docs/queries.rst
@@ -0,0 +1,332 @@
+Queries & ObjectTypes
+=====================
+
+Introduction
+------------
+
+Graphene-Django offers a host of features for performing GraphQL queries.
+
+Graphene-Django ships with a special ``DjangoObjectType`` that automatically transforms a Django Model
+into a ``ObjectType`` for you.
+
+
+Full example
+~~~~~~~~~~~~
+
+.. code:: python
+
+ # my_app/schema.py
+
+ import graphene
+
+ from graphene_django.types import DjangoObjectType
+ from .models import Question
+
+
+ class QuestionType(DjangoObjectType):
+ class Meta:
+ model = Question
+
+
+ class Query:
+ questions = graphene.List(QuestionType)
+ question = graphene.Field(QuestionType, question_id=graphene.String())
+
+ def resolve_questions(self, info, **kwargs):
+ # Querying a list
+ return Question.objects.all()
+
+ def resolve_question(self, info, question_id):
+ # Querying a single question
+ return Question.objects.get(pk=question_id)
+
+
+Fields
+------
+
+By default, ``DjangoObjectType`` will present all fields on a Model through GraphQL.
+If you don't want to do this you can change this by setting either ``only_fields`` and ``exclude_fields``.
+
+only_fields
+~~~~~~~~~~~
+
+Show **only** these fields on the model:
+
+.. code:: python
+
+ class QuestionType(DjangoObjectType):
+ class Meta:
+ model = Question
+ only_fields = ('question_text')
+
+
+exclude_fields
+~~~~~~~~~~~~~~
+
+Show all fields **except** those in ``exclude_fields``:
+
+.. code:: python
+
+ class QuestionType(DjangoObjectType):
+ class Meta:
+ model = Question
+ exclude_fields = ('question_text')
+
+
+Customised fields
+~~~~~~~~~~~~~~~~~
+
+You can completely overwrite a field, or add new fields, to a ``DjangoObjectType`` using a Resolver:
+
+.. code:: python
+
+ class QuestionType(DjangoObjectType):
+
+ class Meta:
+ model = Question
+ exclude_fields = ('question_text')
+
+ extra_field = graphene.String()
+
+ def resolve_extra_field(self, info):
+ return 'hello!'
+
+
+Related models
+--------------
+
+Say you have the following models:
+
+.. code:: python
+
+ class Category(models.Model):
+ foo = models.CharField(max_length=256)
+
+ class Question(models.Model):
+ category = models.ForeignKey(Category, on_delete=models.CASCADE)
+
+
+When ``Question`` is published as a ``DjangoObjectType`` and you want to add ``Category`` as a query-able field like so:
+
+.. code:: python
+
+ class QuestionType(DjangoObjectType):
+ class Meta:
+ model = Question
+ only_fields = ('category',)
+
+Then all query-able related models must be defined as DjangoObjectType subclass,
+or they will fail to show if you are trying to query those relation fields. You only
+need to create the most basic class for this to work:
+
+.. code:: python
+
+ class CategoryType(DjangoObjectType):
+ class Meta:
+ model = Category
+
+Default QuerySet
+-----------------
+
+If you are using ``DjangoObjectType`` you can define a custom `get_queryset` method.
+Use this to control filtering on the ObjectType level instead of the Query object level.
+
+.. code:: python
+
+ from graphene_django.types import DjangoObjectType
+ from .models import Question
+
+
+ class QuestionType(DjangoObjectType):
+ class Meta:
+ model = Question
+
+ @classmethod
+ def get_queryset(cls, queryset, info):
+ if info.context.user.is_anonymous:
+ return queryset.filter(published=True)
+ return queryset
+
+Resolvers
+---------
+
+When a GraphQL query is received by the ``Schema`` object, it will map it to a "Resolver" related to it.
+
+This resolve method should follow this format:
+
+.. code:: python
+
+ def resolve_foo(self, info, **kwargs):
+
+Where "foo" is the name of the field declared in the ``Query`` object.
+
+.. code:: python
+
+ class Query:
+ foo = graphene.List(QuestionType)
+
+ def resolve_foo(self, info, **kwargs):
+ id = kwargs.get('id')
+ return QuestionModel.objects.get(id)
+
+Arguments
+~~~~~~~~~
+
+Additionally, Resolvers will receive **any arguments declared in the field definition**. This allows you to provide input arguments in your GraphQL server and can be useful for custom queries.
+
+.. code:: python
+
+ class Query:
+ question = graphene.Field(Question, foo=graphene.String(), bar=graphene.Int())
+
+ def resolve_question(self, info, foo, bar):
+ # If `foo` or `bar` are declared in the GraphQL query they will be here, else None.
+ return Question.objects.filter(foo=foo, bar=bar).first()
+
+
+Info
+~~~~
+
+The ``info`` argument passed to all resolve methods holds some useful information.
+For Graphene-Django, the ``info.context`` attribute is the ``HTTPRequest`` object
+that would be familiar to any Django developer. This gives you the full functionality
+of Django's ``HTTPRequest`` in your resolve methods, such as checking for authenticated users:
+
+.. code:: python
+
+ def resolve_questions(self, info, **kwargs):
+ # See if a user is authenticated
+ if info.context.user.is_authenticated():
+ return Question.objects.all()
+ else:
+ return Question.objects.none()
+
+
+Plain ObjectTypes
+-----------------
+
+With Graphene-Django you are not limited to just Django Models - you can use the standard
+``ObjectType`` to create custom fields or to provide an abstraction between your internal
+Django models and your external API.
+
+.. code:: python
+
+ import graphene
+ from .models import Question
+
+
+ class MyQuestion(graphene.ObjectType):
+ text = graphene.String()
+
+
+ class Query:
+ question = graphene.Field(MyQuestion, question_id=graphene.String())
+
+ def resolve_question(self, info, question_id):
+ question = Question.objects.get(pk=question_id)
+ return MyQuestion(
+ text=question.question_text
+ )
+
+For more information and more examples, please see the `core object type documentation `__.
+
+
+Relay
+-----
+
+`Relay `__ with Graphene-Django gives us some additional features:
+
+- Pagination and slicing.
+- An abstract ``id`` value which contains enough info for the server to know its type and its id.
+
+There is one additional import and a single line of code needed to adopt this:
+
+Full example
+~~~~~~~~~~~~
+See the `Relay documentation `__ on
+the core graphene pages for more information on customizing the Relay experience.
+
+.. code:: python
+
+ from graphene import relay
+ from graphene_django import DjangoObjectType
+ from .models import Question
+
+
+ class QuestionType(DjangoObjectType):
+ class Meta:
+ model = Question
+ interfaces = (relay.Node,)
+
+
+ class QuestionConnection(relay.Connection):
+ class Meta:
+ node = QuestionType
+
+
+ class Query:
+ questions = relay.ConnectionField(QuestionConnection)
+
+ def resolve_questions(root, info, **kwargs):
+ return Question.objects.all()
+
+
+You can now execute queries like:
+
+
+.. code:: python
+
+ {
+ questions (first: 2, after: "YXJyYXljb25uZWN0aW9uOjEwNQ==") {
+ pageInfo {
+ startCursor
+ endCursor
+ hasNextPage
+ hasPreviousPage
+ }
+ edges {
+ cursor
+ node {
+ id
+ question_text
+ }
+ }
+ }
+ }
+
+Which returns:
+
+.. code:: python
+
+ {
+ "data": {
+ "questions": {
+ "pageInfo": {
+ "startCursor": "YXJyYXljb25uZWN0aW9uOjEwNg==",
+ "endCursor": "YXJyYXljb25uZWN0aW9uOjEwNw==",
+ "hasNextPage": true,
+ "hasPreviousPage": false
+ },
+ "edges": [
+ {
+ "cursor": "YXJyYXljb25uZWN0aW9uOjEwNg==",
+ "node": {
+ "id": "UGxhY2VUeXBlOjEwNw==",
+ "question_text": "How did we get here?"
+ }
+ },
+ {
+ "cursor": "YXJyYXljb25uZWN0aW9uOjEwNw==",
+ "node": {
+ "id": "UGxhY2VUeXBlOjEwOA==",
+ "name": "Where are we?"
+ }
+ }
+ ]
+ }
+ }
+ }
+
+Note that relay implements :code:`pagination` capabilities automatically, adding a :code:`pageInfo` element, and including :code:`cursor` on nodes. These elements are included in the above example for illustration.
+
+To learn more about Pagination in general, take a look at `Pagination `__ on the GraphQL community site.
diff --git a/docs/rest-framework.rst b/docs/rest-framework.rst
deleted file mode 100644
index ce666de..0000000
--- a/docs/rest-framework.rst
+++ /dev/null
@@ -1,64 +0,0 @@
-Integration with Django Rest Framework
-======================================
-
-You can re-use your Django Rest Framework serializer with
-graphene django.
-
-
-Mutation
---------
-
-You can create a Mutation based on a serializer by using the
-`SerializerMutation` base class:
-
-.. code:: python
-
- from graphene_django.rest_framework.mutation import SerializerMutation
-
- class MyAwesomeMutation(SerializerMutation):
- class Meta:
- serializer_class = MySerializer
-
-Create/Update Operations
----------------------
-
-By default ModelSerializers accept create and update operations. To
-customize this use the `model_operations` attribute. The update
-operation looks up models by the primary key by default. You can
-customize the look up with the lookup attribute.
-
-.. code:: python
-
- from graphene_django.rest_framework.mutation import SerializerMutation
-
- class AwesomeModelMutation(SerializerMutation):
- class Meta:
- serializer_class = MyModelSerializer
- model_operations = ['create', 'update']
- lookup_field = 'id'
-
-Overriding Update Queries
--------------------------
-
-Use the method `get_serializer_kwargs` to override how
-updates are applied.
-
-.. code:: python
-
- from graphene_django.rest_framework.mutation import SerializerMutation
-
- class AwesomeModelMutation(SerializerMutation):
- class Meta:
- serializer_class = MyModelSerializer
-
- @classmethod
- def get_serializer_kwargs(cls, root, info, **input):
- if 'id' in input:
- instance = Post.objects.filter(id=input['id'], owner=info.context.user).first()
- if instance:
- return {'instance': instance, 'data': input, 'partial': True}
-
- else:
- raise http.Http404
-
- return {'data': input, 'partial': True}
diff --git a/docs/schema.rst b/docs/schema.rst
new file mode 100644
index 0000000..9f0c283
--- /dev/null
+++ b/docs/schema.rst
@@ -0,0 +1,50 @@
+Schema
+======
+
+The ``graphene.Schema`` object describes your data model and provides a GraphQL server with an associated set of resolve methods that know how to fetch data. The most basic schema you can create looks like this:
+
+.. code:: python
+
+ import graphene
+
+ class Query(graphene.ObjectType):
+ pass
+
+ class Mutation(graphene.ObjectType):
+ pass
+
+ schema = graphene.Schema(query=Query, mutation=Mutation)
+
+
+This schema doesn't do anything yet, but it is ready to accept new Query or Mutation fields.
+
+
+Adding to the schema
+--------------------
+
+If you have defined a ``Query`` or ``Mutation``, you can register them with the schema:
+
+.. code:: python
+
+ import graphene
+
+ import my_app.schema.Query
+ import my_app.schema.Mutation
+
+ class Query(
+ my_app.schema.Query, # Add your Query objects here
+ graphene.ObjectType
+ ):
+ pass
+
+ class Mutation(
+ my_app.schema.Mutation, # Add your Mutation objects here
+ graphene.ObjectType
+ ):
+ pass
+
+ schema = graphene.Schema(query=Query, mutation=Mutation)
+
+You can add as many mixins to the base ``Query`` and ``Mutation`` objects as you like.
+
+Read more about Schema on the `core graphene docs `__
\ No newline at end of file
diff --git a/docs/settings.rst b/docs/settings.rst
new file mode 100644
index 0000000..547e77f
--- /dev/null
+++ b/docs/settings.rst
@@ -0,0 +1,103 @@
+Settings
+========
+
+Graphene-Django can be customised using settings. This page explains each setting and their defaults.
+
+Usage
+-----
+
+Add settings to your Django project by creating a Dictonary with name ``GRAPHENE`` in the project's ``settings.py``:
+
+.. code:: python
+
+ GRAPHENE = {
+ ...
+ }
+
+
+``SCHEMA``
+----------
+
+The location of the top-level ``Schema`` class.
+
+Default: ``None``
+
+.. code:: python
+
+ GRAPHENE = {
+ 'SCHEMA': 'path.to.schema.schema',
+ }
+
+
+``SCHEMA_OUTPUT``
+----------
+
+The name of the file where the GraphQL schema output will go.
+
+Default: ``schema.json``
+
+.. code:: python
+
+ GRAPHENE = {
+ 'SCHEMA_OUTPUT': 'schema.json',
+ }
+
+
+``SCHEMA_INDENT``
+----------
+
+The indentation level of the schema output.
+
+Default: ``2``
+
+.. code:: python
+
+ GRAPHENE = {
+ 'SCHEMA_INDENT': 2,
+ }
+
+
+``MIDDLEWARE``
+----------
+
+A tuple of middleware that will be executed for each GraphQL query.
+
+See the `middleware documentation `__ for more information.
+
+Default: ``()``
+
+.. code:: python
+
+ GRAPHENE = {
+ 'MIDDLEWARE': (
+ 'path.to.my.middleware.class',
+ ),
+ }
+
+
+``RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST``
+----------
+
+Enforces relay queries to have the ``first`` or ``last`` argument.
+
+Default: ``False``
+
+.. code:: python
+
+ GRAPHENE = {
+ 'RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST': False,
+ }
+
+
+``RELAY_CONNECTION_MAX_LIMIT``
+----------
+
+The maximum size of objects that can be requested through a relay connection.
+
+Default: ``100``
+
+.. code:: python
+
+ GRAPHENE = {
+ 'RELAY_CONNECTION_MAX_LIMIT': 100,
+ }
diff --git a/docs/testing.rst b/docs/testing.rst
new file mode 100644
index 0000000..b111642
--- /dev/null
+++ b/docs/testing.rst
@@ -0,0 +1,60 @@
+Testing API calls with django
+=============================
+
+If you want to unittest your API calls derive your test case from the class `GraphQLTestCase`.
+
+Usage:
+
+.. code:: python
+
+ import json
+
+ from graphene_django.utils.testing import GraphQLTestCase
+ from my_project.config.schema import schema
+
+ class MyFancyTestCase(GraphQLTestCase):
+ # Here you need to inject your test case's schema
+ GRAPHQL_SCHEMA = schema
+
+ def test_some_query(self):
+ response = self.query(
+ '''
+ query {
+ myModel {
+ id
+ name
+ }
+ }
+ ''',
+ op_name='myModel'
+ )
+
+ content = json.loads(response.content)
+
+ # This validates the status code and if you get errors
+ self.assertResponseNoErrors(response)
+
+ # Add some more asserts if you like
+ ...
+
+ def test_some_mutation(self):
+ response = self.query(
+ '''
+ mutation myMutation($input: MyMutationInput!) {
+ myMutation(input: $input) {
+ my-model {
+ id
+ name
+ }
+ }
+ }
+ ''',
+ op_name='myMutation',
+ input_data={'my_field': 'foo', 'other_field': 'bar'}
+ )
+
+ # This validates the status code and if you get errors
+ self.assertResponseNoErrors(response)
+
+ # Add some more asserts if you like
+ ...
diff --git a/docs/tutorial-plain.rst b/docs/tutorial-plain.rst
index a87b011..29df56e 100644
--- a/docs/tutorial-plain.rst
+++ b/docs/tutorial-plain.rst
@@ -1,12 +1,9 @@
-Introduction tutorial - Graphene and Django
+Basic Tutorial
===========================================
-Graphene has a number of additional features that are designed to make
-working with Django *really simple*.
-
-Our primary focus here is to give a good understanding of how to connect models from Django ORM to graphene object types.
-
-A good idea is to check the `graphene `__ documentation first.
+Graphene Django has a number of additional features that are designed to make
+working with Django easy. Our primary focus in this tutorial is to give a good
+understanding of how to connect models from Django ORM to graphene object types.
Set up the Django project
-------------------------
@@ -91,7 +88,7 @@ Don't forget to create & run migrations:
python manage.py makemigrations
python manage.py migrate
-
+
Load some test data
^^^^^^^^^^^^^^^^^^^
@@ -108,7 +105,7 @@ following:
$ python ./manage.py loaddata ingredients
Installed 6 object(s) from 1 fixture(s)
-
+
Alternatively you can use the Django admin interface to create some data
yourself. You'll need to run the development server (see below), and
create a login for yourself too (``./manage.py createsuperuser``).
@@ -255,7 +252,7 @@ aforementioned GraphiQL we specify that on the parameters with ``graphiql=True``
urlpatterns = [
url(r'^admin/', admin.site.urls),
- url(r'^graphql', GraphQLView.as_view(graphiql=True)),
+ url(r'^graphql$', GraphQLView.as_view(graphiql=True)),
]
@@ -273,7 +270,7 @@ as explained above, we can do so here using:
urlpatterns = [
url(r'^admin/', admin.site.urls),
- url(r'^graphql', GraphQLView.as_view(graphiql=True, schema=schema)),
+ url(r'^graphql$', GraphQLView.as_view(graphiql=True, schema=schema)),
]
@@ -487,7 +484,7 @@ Now, with the code in place, we can query for single objects.
For example, lets query ``category``:
-.. code::
+.. code::
query {
category(id: 1) {
@@ -536,3 +533,6 @@ Summary
As you can see, GraphQL is very powerful but there are a lot of repetitions in our example. We can do a lot of improvements by adding layers of abstraction on top of ``graphene-django``.
If you want to put things like ``django-filter`` and automatic pagination in action, you should continue with the **relay tutorial.**
+
+A good idea is to check the `graphene `__
+documentation but it is not essential to understand and use Graphene-Django in your project.
\ No newline at end of file
diff --git a/docs/tutorial-relay.rst b/docs/tutorial-relay.rst
index f2502d7..5f8bd64 100644
--- a/docs/tutorial-relay.rst
+++ b/docs/tutorial-relay.rst
@@ -1,4 +1,4 @@
-Graphene and Django Tutorial using Relay
+Relay tutorial
========================================
Graphene has a number of additional features that are designed to make
@@ -147,7 +147,7 @@ Create ``cookbook/ingredients/schema.py`` and type the following:
interfaces = (relay.Node, )
- class Query(object):
+ class Query(graphene.ObjectType):
category = relay.Node.Field(CategoryNode)
all_categories = DjangoFilterConnectionField(CategoryNode)
@@ -158,7 +158,7 @@ Create ``cookbook/ingredients/schema.py`` and type the following:
The filtering functionality is provided by
`django-filter `__. See the
`usage
-documentation `__
+documentation `__
for details on the format for ``filter_fields``. While optional, this
tutorial makes use of this functionality so you will need to install
``django-filter`` for this tutorial to work:
@@ -244,7 +244,7 @@ aforementioned GraphiQL we specify that on the params with ``graphiql=True``.
urlpatterns = [
url(r'^admin/', admin.site.urls),
- url(r'^graphql', GraphQLView.as_view(graphiql=True)),
+ url(r'^graphql$', GraphQLView.as_view(graphiql=True)),
]
@@ -262,7 +262,7 @@ as explained above, we can do so here using:
urlpatterns = [
url(r'^admin/', admin.site.urls),
- url(r'^graphql', GraphQLView.as_view(graphiql=True, schema=schema)),
+ url(r'^graphql$', GraphQLView.as_view(graphiql=True, schema=schema)),
]
@@ -345,3 +345,10 @@ Or you can get only 'meat' ingredients containing the letter 'e':
}
}
}
+
+
+
+Final Steps
+^^^^^^^^^^^
+
+We have created a GraphQL endpoint that will work with Relay, but for Relay to work it needs access to a (non python) schema. Instructions to export the schema can be found on the `Introspection Schema `__ part of this guide.
diff --git a/examples/cookbook-plain/cookbook/ingredients/migrations/0003_auto_20181018_1746.py b/examples/cookbook-plain/cookbook/ingredients/migrations/0003_auto_20181018_1746.py
new file mode 100644
index 0000000..184e79e
--- /dev/null
+++ b/examples/cookbook-plain/cookbook/ingredients/migrations/0003_auto_20181018_1746.py
@@ -0,0 +1,17 @@
+# Generated by Django 2.0 on 2018-10-18 17:46
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('ingredients', '0002_auto_20161104_0050'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='category',
+ options={'verbose_name_plural': 'Categories'},
+ ),
+ ]
diff --git a/examples/cookbook-plain/cookbook/ingredients/models.py b/examples/cookbook-plain/cookbook/ingredients/models.py
index 2f0eba3..5836949 100644
--- a/examples/cookbook-plain/cookbook/ingredients/models.py
+++ b/examples/cookbook-plain/cookbook/ingredients/models.py
@@ -2,6 +2,8 @@ from django.db import models
class Category(models.Model):
+ class Meta:
+ verbose_name_plural = 'Categories'
name = models.CharField(max_length=100)
def __str__(self):
@@ -11,7 +13,7 @@ class Category(models.Model):
class Ingredient(models.Model):
name = models.CharField(max_length=100)
notes = models.TextField(null=True, blank=True)
- category = models.ForeignKey(Category, related_name='ingredients')
+ category = models.ForeignKey(Category, related_name='ingredients', on_delete=models.CASCADE)
def __str__(self):
return self.name
diff --git a/examples/cookbook-plain/cookbook/ingredients/schema.py b/examples/cookbook-plain/cookbook/ingredients/schema.py
index 1f3bb18..e7ef688 100644
--- a/examples/cookbook-plain/cookbook/ingredients/schema.py
+++ b/examples/cookbook-plain/cookbook/ingredients/schema.py
@@ -1,7 +1,7 @@
import graphene
from graphene_django.types import DjangoObjectType
-from cookbook.ingredients.models import Category, Ingredient
+from .models import Category, Ingredient
class CategoryType(DjangoObjectType):
@@ -25,17 +25,14 @@ class Query(object):
name=graphene.String())
all_ingredients = graphene.List(IngredientType)
- def resolve_all_categories(self, args, context, info):
+ def resolve_all_categories(self, context):
return Category.objects.all()
- def resolve_all_ingredients(self, args, context, info):
+ def resolve_all_ingredients(self, context):
# We can easily optimize query count in the resolve method
return Ingredient.objects.select_related('category').all()
- def resolve_category(self, args, context, info):
- id = args.get('id')
- name = args.get('name')
-
+ def resolve_category(self, context, id=None, name=None):
if id is not None:
return Category.objects.get(pk=id)
@@ -44,10 +41,7 @@ class Query(object):
return None
- def resolve_ingredient(self, args, context, info):
- id = args.get('id')
- name = args.get('name')
-
+ def resolve_ingredient(self, context, id=None, name=None):
if id is not None:
return Ingredient.objects.get(pk=id)
diff --git a/examples/cookbook-plain/cookbook/recipes/migrations/0003_auto_20181018_1728.py b/examples/cookbook-plain/cookbook/recipes/migrations/0003_auto_20181018_1728.py
new file mode 100644
index 0000000..7a8df49
--- /dev/null
+++ b/examples/cookbook-plain/cookbook/recipes/migrations/0003_auto_20181018_1728.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.0 on 2018-10-18 17:28
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('recipes', '0002_auto_20161104_0106'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='recipeingredient',
+ name='unit',
+ field=models.CharField(choices=[('unit', 'Units'), ('kg', 'Kilograms'), ('l', 'Litres'), ('st', 'Shots')], max_length=20),
+ ),
+ ]
diff --git a/examples/cookbook-plain/cookbook/recipes/models.py b/examples/cookbook-plain/cookbook/recipes/models.py
index ca12fac..382b88e 100644
--- a/examples/cookbook-plain/cookbook/recipes/models.py
+++ b/examples/cookbook-plain/cookbook/recipes/models.py
@@ -1,17 +1,18 @@
from django.db import models
-from cookbook.ingredients.models import Ingredient
+from ..ingredients.models import Ingredient
class Recipe(models.Model):
title = models.CharField(max_length=100)
instructions = models.TextField()
- __unicode__ = lambda self: self.title
+ def __str__(self):
+ return self.title
class RecipeIngredient(models.Model):
- recipe = models.ForeignKey(Recipe, related_name='amounts')
- ingredient = models.ForeignKey(Ingredient, related_name='used_by')
+ recipe = models.ForeignKey(Recipe, related_name='amounts', on_delete=models.CASCADE)
+ ingredient = models.ForeignKey(Ingredient, related_name='used_by', on_delete=models.CASCADE)
amount = models.FloatField()
unit = models.CharField(max_length=20, choices=(
('unit', 'Units'),
diff --git a/examples/cookbook-plain/cookbook/recipes/schema.py b/examples/cookbook-plain/cookbook/recipes/schema.py
index 040c985..74692f8 100644
--- a/examples/cookbook-plain/cookbook/recipes/schema.py
+++ b/examples/cookbook-plain/cookbook/recipes/schema.py
@@ -1,7 +1,7 @@
import graphene
from graphene_django.types import DjangoObjectType
-from cookbook.recipes.models import Recipe, RecipeIngredient
+from .models import Recipe, RecipeIngredient
class RecipeType(DjangoObjectType):
@@ -24,10 +24,7 @@ class Query(object):
id=graphene.Int())
all_recipeingredients = graphene.List(RecipeIngredientType)
- def resolve_recipe(self, args, context, info):
- id = args.get('id')
- title = args.get('title')
-
+ def resolve_recipe(self, context, id=None, title=None):
if id is not None:
return Recipe.objects.get(pk=id)
@@ -36,17 +33,15 @@ class Query(object):
return None
- def resolve_recipeingredient(self, args, context, info):
- id = args.get('id')
-
+ def resolve_recipeingredient(self, context, id=None):
if id is not None:
return RecipeIngredient.objects.get(pk=id)
return None
- def resolve_all_recipes(self, args, context, info):
+ def resolve_all_recipes(self, context):
return Recipe.objects.all()
- def resolve_all_recipeingredients(self, args, context, info):
+ def resolve_all_recipeingredients(self, context):
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
index f8606a7..f91d62c 100644
--- a/examples/cookbook-plain/cookbook/schema.py
+++ b/examples/cookbook-plain/cookbook/schema.py
@@ -8,7 +8,7 @@ from graphene_django.debug import DjangoDebug
class Query(cookbook.ingredients.schema.Query,
cookbook.recipes.schema.Query,
graphene.ObjectType):
- debug = graphene.Field(DjangoDebug, name='__debug')
+ debug = graphene.Field(DjangoDebug, name='_debug')
schema = graphene.Schema(query=Query)
diff --git a/examples/cookbook-plain/cookbook/settings.py b/examples/cookbook-plain/cookbook/settings.py
index 948292d..bce2bab 100644
--- a/examples/cookbook-plain/cookbook/settings.py
+++ b/examples/cookbook-plain/cookbook/settings.py
@@ -44,19 +44,19 @@ INSTALLED_APPS = [
'cookbook.recipes.apps.RecipesConfig',
]
-MIDDLEWARE_CLASSES = [
+MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
- 'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
GRAPHENE = {
'SCHEMA': 'cookbook.schema.schema',
+ 'SCHEMA_INDENT': 2,
'MIDDLEWARE': (
'graphene_django.debug.DjangoDebugMiddleware',
)
@@ -131,8 +131,3 @@ USE_TZ = True
# https://docs.djangoproject.com/en/1.9/howto/static-files/
STATIC_URL = '/static/'
-
-GRAPHENE = {
- 'SCHEMA': 'cookbook.schema.schema',
- 'SCHEMA_INDENT': 2,
-}
diff --git a/examples/cookbook-plain/cookbook/urls.py b/examples/cookbook-plain/cookbook/urls.py
index 9f8755b..4f87da0 100644
--- a/examples/cookbook-plain/cookbook/urls.py
+++ b/examples/cookbook-plain/cookbook/urls.py
@@ -1,10 +1,10 @@
-from django.conf.urls import url
+from django.urls import path
from django.contrib import admin
from graphene_django.views import GraphQLView
urlpatterns = [
- url(r'^admin/', admin.site.urls),
- url(r'^graphql', GraphQLView.as_view(graphiql=True)),
+ path('admin/', admin.site.urls),
+ path('graphql/', GraphQLView.as_view(graphiql=True)),
]
diff --git a/examples/cookbook-plain/requirements.txt b/examples/cookbook-plain/requirements.txt
index 362a39a..2154fd8 100644
--- a/examples/cookbook-plain/requirements.txt
+++ b/examples/cookbook-plain/requirements.txt
@@ -1,4 +1,4 @@
graphene
graphene-django
graphql-core>=2.1rc1
-django==1.9
+django==2.1.6
diff --git a/examples/cookbook/cookbook/schema.py b/examples/cookbook/cookbook/schema.py
index f8606a7..f91d62c 100644
--- a/examples/cookbook/cookbook/schema.py
+++ b/examples/cookbook/cookbook/schema.py
@@ -8,7 +8,7 @@ from graphene_django.debug import DjangoDebug
class Query(cookbook.ingredients.schema.Query,
cookbook.recipes.schema.Query,
graphene.ObjectType):
- debug = graphene.Field(DjangoDebug, name='__debug')
+ debug = graphene.Field(DjangoDebug, name='_debug')
schema = graphene.Schema(query=Query)
diff --git a/examples/cookbook/cookbook/settings.py b/examples/cookbook/cookbook/settings.py
index 948292d..0b3207e 100644
--- a/examples/cookbook/cookbook/settings.py
+++ b/examples/cookbook/cookbook/settings.py
@@ -57,6 +57,7 @@ MIDDLEWARE_CLASSES = [
GRAPHENE = {
'SCHEMA': 'cookbook.schema.schema',
+ 'SCHEMA_INDENT': 2,
'MIDDLEWARE': (
'graphene_django.debug.DjangoDebugMiddleware',
)
@@ -131,8 +132,3 @@ USE_TZ = True
# https://docs.djangoproject.com/en/1.9/howto/static-files/
STATIC_URL = '/static/'
-
-GRAPHENE = {
- 'SCHEMA': 'cookbook.schema.schema',
- 'SCHEMA_INDENT': 2,
-}
diff --git a/examples/cookbook/cookbook/urls.py b/examples/cookbook/cookbook/urls.py
index 9f8755b..4bf6003 100644
--- a/examples/cookbook/cookbook/urls.py
+++ b/examples/cookbook/cookbook/urls.py
@@ -6,5 +6,5 @@ from graphene_django.views import GraphQLView
urlpatterns = [
url(r'^admin/', admin.site.urls),
- url(r'^graphql', GraphQLView.as_view(graphiql=True)),
+ url(r'^graphql$', GraphQLView.as_view(graphiql=True)),
]
diff --git a/examples/cookbook/requirements.txt b/examples/cookbook/requirements.txt
index b2ace1f..3fed30f1 100644
--- a/examples/cookbook/requirements.txt
+++ b/examples/cookbook/requirements.txt
@@ -1,5 +1,5 @@
graphene
graphene-django
graphql-core>=2.1rc1
-django==1.9
+django==1.11.19
django-filter>=2
diff --git a/graphene_django/compat.py b/graphene_django/compat.py
index 4a51de8..59fab30 100644
--- a/graphene_django/compat.py
+++ b/graphene_django/compat.py
@@ -5,7 +5,11 @@ class MissingType(object):
try:
# Postgres fields are only available in Django with psycopg2 installed
# and we cannot have psycopg2 on PyPy
- from django.contrib.postgres.fields import (ArrayField, HStoreField,
- JSONField, RangeField)
+ from django.contrib.postgres.fields import (
+ ArrayField,
+ HStoreField,
+ JSONField,
+ RangeField,
+ )
except ImportError:
ArrayField, HStoreField, JSONField, RangeField = (MissingType,) * 4
diff --git a/graphene_django/debug/sql/types.py b/graphene_django/debug/sql/types.py
index 850ced4..eeef482 100644
--- a/graphene_django/debug/sql/types.py
+++ b/graphene_django/debug/sql/types.py
@@ -3,9 +3,7 @@ from graphene import Boolean, Float, ObjectType, String
class DjangoDebugSQL(ObjectType):
class Meta:
- description = (
- "Represents a single database query made to a Django managed DB."
- )
+ description = "Represents a single database query made to a Django managed DB."
vendor = String(
required=True,
@@ -14,37 +12,26 @@ class DjangoDebugSQL(ObjectType):
),
)
alias = String(
- required=True,
- description="The Django database alias (e.g. 'default').",
+ required=True, description="The Django database alias (e.g. 'default')."
)
sql = String(description="The actual SQL sent to this database.")
duration = Float(
- required=True,
- description="Duration of this database query in seconds.",
+ required=True, description="Duration of this database query in seconds."
)
raw_sql = String(
- required=True,
- description="The raw SQL of this query, without params.",
+ required=True, description="The raw SQL of this query, without params."
)
params = String(
- required=True,
- description="JSON encoded database query parameters.",
- )
- start_time = Float(
- required=True,
- description="Start time of this database query.",
- )
- stop_time = Float(
- required=True,
- description="Stop time of this database query.",
+ required=True, description="JSON encoded database query parameters."
)
+ start_time = Float(required=True, description="Start time of this database query.")
+ stop_time = Float(required=True, description="Stop time of this database query.")
is_slow = Boolean(
required=True,
description="Whether this database query took more than 10 seconds.",
)
is_select = Boolean(
- required=True,
- description="Whether this database query was a SELECT.",
+ required=True, description="Whether this database query was a SELECT."
)
# Postgres
diff --git a/graphene_django/debug/tests/test_query.py b/graphene_django/debug/tests/test_query.py
index d0bfe30..928bc3b 100644
--- a/graphene_django/debug/tests/test_query.py
+++ b/graphene_django/debug/tests/test_query.py
@@ -31,7 +31,7 @@ def test_should_query_field():
class Query(graphene.ObjectType):
reporter = graphene.Field(ReporterType)
- debug = graphene.Field(DjangoDebug, name="__debug")
+ debug = graphene.Field(DjangoDebug, name="_debug")
def resolve_reporter(self, info, **args):
return Reporter.objects.first()
@@ -41,7 +41,7 @@ def test_should_query_field():
reporter {
lastName
}
- __debug {
+ _debug {
sql {
rawSql
}
@@ -50,7 +50,7 @@ def test_should_query_field():
"""
expected = {
"reporter": {"lastName": "ABA"},
- "__debug": {
+ "_debug": {
"sql": [{"rawSql": str(Reporter.objects.order_by("pk")[:1].query)}]
},
}
@@ -138,7 +138,7 @@ def test_should_query_list():
class Query(graphene.ObjectType):
all_reporters = graphene.List(ReporterType)
- debug = graphene.Field(DjangoDebug, name="__debug")
+ debug = graphene.Field(DjangoDebug, name="_debug")
def resolve_all_reporters(self, info, **args):
return Reporter.objects.all()
@@ -148,7 +148,7 @@ def test_should_query_list():
allReporters {
lastName
}
- __debug {
+ _debug {
sql {
rawSql
}
@@ -157,7 +157,7 @@ def test_should_query_list():
"""
expected = {
"allReporters": [{"lastName": "ABA"}, {"lastName": "Griffin"}],
- "__debug": {"sql": [{"rawSql": str(Reporter.objects.all().query)}]},
+ "_debug": {"sql": [{"rawSql": str(Reporter.objects.all().query)}]},
}
schema = graphene.Schema(query=Query)
result = schema.execute(
@@ -180,7 +180,7 @@ def test_should_query_connection():
class Query(graphene.ObjectType):
all_reporters = DjangoConnectionField(ReporterType)
- debug = graphene.Field(DjangoDebug, name="__debug")
+ debug = graphene.Field(DjangoDebug, name="_debug")
def resolve_all_reporters(self, info, **args):
return Reporter.objects.all()
@@ -194,7 +194,7 @@ def test_should_query_connection():
}
}
}
- __debug {
+ _debug {
sql {
rawSql
}
@@ -208,9 +208,9 @@ def test_should_query_connection():
)
assert not result.errors
assert result.data["allReporters"] == expected["allReporters"]
- assert "COUNT" in result.data["__debug"]["sql"][0]["rawSql"]
+ assert "COUNT" in result.data["_debug"]["sql"][0]["rawSql"]
query = str(Reporter.objects.all()[:1].query)
- assert result.data["__debug"]["sql"][1]["rawSql"] == query
+ assert result.data["_debug"]["sql"][1]["rawSql"] == query
def test_should_query_connectionfilter():
@@ -229,7 +229,7 @@ def test_should_query_connectionfilter():
class Query(graphene.ObjectType):
all_reporters = DjangoFilterConnectionField(ReporterType, fields=["last_name"])
s = graphene.String(resolver=lambda *_: "S")
- debug = graphene.Field(DjangoDebug, name="__debug")
+ debug = graphene.Field(DjangoDebug, name="_debug")
def resolve_all_reporters(self, info, **args):
return Reporter.objects.all()
@@ -243,7 +243,7 @@ def test_should_query_connectionfilter():
}
}
}
- __debug {
+ _debug {
sql {
rawSql
}
@@ -257,6 +257,6 @@ def test_should_query_connectionfilter():
)
assert not result.errors
assert result.data["allReporters"] == expected["allReporters"]
- assert "COUNT" in result.data["__debug"]["sql"][0]["rawSql"]
+ assert "COUNT" in result.data["_debug"]["sql"][0]["rawSql"]
query = str(Reporter.objects.all()[:1].query)
- assert result.data["__debug"]["sql"][1]["rawSql"] == query
+ assert result.data["_debug"]["sql"][1]["rawSql"] == query
diff --git a/graphene_django/debug/types.py b/graphene_django/debug/types.py
index cda5725..1cd816d 100644
--- a/graphene_django/debug/types.py
+++ b/graphene_django/debug/types.py
@@ -7,7 +7,4 @@ class DjangoDebug(ObjectType):
class Meta:
description = "Debugging information for the current query."
- sql = List(
- DjangoDebugSQL,
- description="Executed SQL queries for this API query.",
- )
+ sql = List(DjangoDebugSQL, description="Executed SQL queries for this API query.")
diff --git a/graphene_django/fields.py b/graphene_django/fields.py
index 1ecce45..791e785 100644
--- a/graphene_django/fields.py
+++ b/graphene_django/fields.py
@@ -1,6 +1,7 @@
from functools import partial
from django.db.models.query import QuerySet
+from graphene import NonNull
from promise import Promise
@@ -45,17 +46,31 @@ class DjangoConnectionField(ConnectionField):
from .types import DjangoObjectType
_type = super(ConnectionField, self).type
+ non_null = False
+ if isinstance(_type, NonNull):
+ _type = _type.of_type
+ non_null = True
assert issubclass(
_type, DjangoObjectType
), "DjangoConnectionField only accepts DjangoObjectType types"
assert _type._meta.connection, "The type {} doesn't have a connection".format(
_type.__name__
)
- return _type._meta.connection
+ connection_type = _type._meta.connection
+ if non_null:
+ return NonNull(connection_type)
+ return connection_type
+
+ @property
+ def connection_type(self):
+ type = self.type
+ if isinstance(type, NonNull):
+ return type.of_type
+ return type
@property
def node_type(self):
- return self.type._meta.node
+ return self.connection_type._meta.node
@property
def model(self):
@@ -67,6 +82,10 @@ class DjangoConnectionField(ConnectionField):
else:
return self.model._default_manager
+ @classmethod
+ def resolve_queryset(cls, connection, queryset, info, args):
+ return connection._meta.node.get_queryset(queryset, info)
+
@classmethod
def merge_querysets(cls, default_queryset, queryset):
if default_queryset.query.distinct and not queryset.query.distinct:
@@ -135,7 +154,8 @@ class DjangoConnectionField(ConnectionField):
args["last"] = min(last, max_limit)
iterable = resolver(root, info, **args)
- on_resolve = partial(cls.resolve_connection, connection, default_manager, args)
+ queryset = cls.resolve_queryset(connection, default_manager, info, args)
+ on_resolve = partial(cls.resolve_connection, connection, queryset, args)
if Promise.is_thenable(iterable):
return Promise.resolve(iterable).then(on_resolve)
@@ -146,7 +166,7 @@ class DjangoConnectionField(ConnectionField):
return partial(
self.connection_resolver,
parent_resolver,
- self.type,
+ self.connection_type,
self.get_manager(),
self.max_limit,
self.enforce_first_or_last,
diff --git a/graphene_django/filter/filterset.py b/graphene_django/filter/filterset.py
index 4059083..7676ea8 100644
--- a/graphene_django/filter/filterset.py
+++ b/graphene_django/filter/filterset.py
@@ -45,8 +45,7 @@ class GrapheneFilterSetMixin(BaseFilterSet):
FILTER_DEFAULTS = dict(
itertools.chain(
- FILTER_FOR_DBFIELD_DEFAULTS.items(),
- GRAPHENE_FILTER_SET_OVERRIDES.items()
+ FILTER_FOR_DBFIELD_DEFAULTS.items(), GRAPHENE_FILTER_SET_OVERRIDES.items()
)
)
@@ -59,7 +58,6 @@ if VERSION[0] < 2:
from django.utils.text import capfirst
class GrapheneFilterSetMixinPython2(GrapheneFilterSetMixin):
-
@classmethod
def filter_for_reverse_field(cls, f, name):
"""Handles retrieving filters for reverse relationships
diff --git a/graphene_django/forms/converter.py b/graphene_django/forms/converter.py
index 87180b2..8916456 100644
--- a/graphene_django/forms/converter.py
+++ b/graphene_django/forms/converter.py
@@ -43,7 +43,7 @@ def convert_form_field_to_int(field):
@convert_form_field.register(forms.BooleanField)
def convert_form_field_to_boolean(field):
- return Boolean(description=field.help_text, required=True)
+ return Boolean(description=field.help_text, required=field.required)
@convert_form_field.register(forms.NullBooleanField)
diff --git a/graphene_django/forms/mutation.py b/graphene_django/forms/mutation.py
index 63ea089..0851a75 100644
--- a/graphene_django/forms/mutation.py
+++ b/graphene_django/forms/mutation.py
@@ -14,7 +14,7 @@ from graphene.types.utils import yank_fields_from_attrs
from graphene_django.registry import get_global_registry
from .converter import convert_form_field
-from .types import ErrorType
+from ..types import ErrorType
def fields_for_form(form, only_fields, exclude_fields):
diff --git a/graphene_django/forms/tests/test_mutation.py b/graphene_django/forms/tests/test_mutation.py
index df0ffd5..543e89e 100644
--- a/graphene_django/forms/tests/test_mutation.py
+++ b/graphene_django/forms/tests/test_mutation.py
@@ -13,7 +13,7 @@ class MyForm(forms.Form):
class PetForm(forms.ModelForm):
class Meta:
model = Pet
- fields = '__all__'
+ fields = "__all__"
def test_needs_form_class():
@@ -66,7 +66,7 @@ class ModelFormMutationTests(TestCase):
class PetMutation(DjangoModelFormMutation):
class Meta:
form_class = PetForm
- exclude_fields = ['id']
+ exclude_fields = ["id"]
self.assertEqual(PetMutation._meta.model, Pet)
self.assertEqual(PetMutation._meta.return_field_name, "pet")
@@ -102,7 +102,9 @@ class ModelFormMutationTests(TestCase):
pet = Pet.objects.create(name="Axel", age=10)
- result = PetMutation.mutate_and_get_payload(None, None, id=pet.pk, name="Mia", age=10)
+ result = PetMutation.mutate_and_get_payload(
+ None, None, id=pet.pk, name="Mia", age=10
+ )
self.assertEqual(Pet.objects.count(), 1)
pet.refresh_from_db()
@@ -132,7 +134,6 @@ class ModelFormMutationTests(TestCase):
# A pet was not created
self.assertEqual(Pet.objects.count(), 0)
-
fields_w_error = [e.field for e in result.errors]
self.assertEqual(len(result.errors), 2)
self.assertIn("name", fields_w_error)
diff --git a/graphene_django/management/commands/graphql_schema.py b/graphene_django/management/commands/graphql_schema.py
index 4e526ec..9f8689e 100644
--- a/graphene_django/management/commands/graphql_schema.py
+++ b/graphene_django/management/commands/graphql_schema.py
@@ -39,7 +39,7 @@ class Command(CommandArguments):
def save_file(self, out, schema_dict, indent):
with open(out, "w") as outfile:
- json.dump(schema_dict, outfile, indent=indent)
+ json.dump(schema_dict, outfile, indent=indent, sort_keys=True)
def handle(self, *args, **options):
options_schema = options.get("schema")
@@ -64,8 +64,8 @@ class Command(CommandArguments):
indent = options.get("indent")
schema_dict = {"data": schema.introspect()}
- if out == '-':
- self.stdout.write(json.dumps(schema_dict, indent=indent))
+ if out == "-":
+ self.stdout.write(json.dumps(schema_dict, indent=indent, sort_keys=True))
else:
self.save_file(out, schema_dict, indent)
diff --git a/graphene_django/rest_framework/mutation.py b/graphene_django/rest_framework/mutation.py
index 5e343aa..b8025f6 100644
--- a/graphene_django/rest_framework/mutation.py
+++ b/graphene_django/rest_framework/mutation.py
@@ -9,7 +9,7 @@ from graphene.relay.mutation import ClientIDMutation
from graphene.types.objecttype import yank_fields_from_attrs
from .serializer_converter import convert_serializer_field
-from .types import ErrorType
+from ..types import ErrorType
class SerializerMutationOptions(MutationOptions):
diff --git a/graphene_django/rest_framework/types.py b/graphene_django/rest_framework/types.py
index 4c84c69..2472c32 100644
--- a/graphene_django/rest_framework/types.py
+++ b/graphene_django/rest_framework/types.py
@@ -2,11 +2,6 @@ import graphene
from graphene.types.unmountedtype import UnmountedType
-class ErrorType(graphene.ObjectType):
- field = graphene.String(required=True)
- messages = graphene.List(graphene.NonNull(graphene.String), required=True)
-
-
class DictType(UnmountedType):
key = graphene.String()
value = graphene.String()
diff --git a/graphene_django/settings.py b/graphene_django/settings.py
index 7cd750a..e5fad78 100644
--- a/graphene_django/settings.py
+++ b/graphene_django/settings.py
@@ -28,7 +28,7 @@ except ImportError:
DEFAULTS = {
"SCHEMA": None,
"SCHEMA_OUTPUT": "schema.json",
- "SCHEMA_INDENT": None,
+ "SCHEMA_INDENT": 2,
"MIDDLEWARE": (),
# Set to True if the connection fields must have
# either the first or last argument
diff --git a/graphene_django/static/graphene_django/graphiql.js b/graphene_django/static/graphene_django/graphiql.js
new file mode 100644
index 0000000..2be7e3c
--- /dev/null
+++ b/graphene_django/static/graphene_django/graphiql.js
@@ -0,0 +1,99 @@
+(function() {
+
+ // Parse the cookie value for a CSRF token
+ var csrftoken;
+ var cookies = ('; ' + document.cookie).split('; csrftoken=');
+ if (cookies.length == 2)
+ csrftoken = cookies.pop().split(';').shift();
+
+ // Collect the URL parameters
+ var parameters = {};
+ window.location.hash.substr(1).split('&').forEach(function (entry) {
+ var eq = entry.indexOf('=');
+ if (eq >= 0) {
+ parameters[decodeURIComponent(entry.slice(0, eq))] =
+ decodeURIComponent(entry.slice(eq + 1));
+ }
+ });
+ // Produce a Location fragment string from a parameter object.
+ function locationQuery(params) {
+ return '#' + Object.keys(params).map(function (key) {
+ return encodeURIComponent(key) + '=' +
+ encodeURIComponent(params[key]);
+ }).join('&');
+ }
+ // Derive a fetch URL from the current URL, sans the GraphQL parameters.
+ var graphqlParamNames = {
+ query: true,
+ variables: true,
+ operationName: true
+ };
+ var otherParams = {};
+ for (var k in parameters) {
+ if (parameters.hasOwnProperty(k) && graphqlParamNames[k] !== true) {
+ otherParams[k] = parameters[k];
+ }
+ }
+
+ var fetchURL = locationQuery(otherParams);
+
+ // Defines a GraphQL fetcher using the fetch API.
+ function graphQLFetcher(graphQLParams) {
+ var headers = {
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json'
+ };
+ if (csrftoken) {
+ headers['X-CSRFToken'] = csrftoken;
+ }
+ return fetch(fetchURL, {
+ method: 'post',
+ headers: headers,
+ body: JSON.stringify(graphQLParams),
+ credentials: 'include',
+ }).then(function (response) {
+ return response.text();
+ }).then(function (responseBody) {
+ try {
+ return JSON.parse(responseBody);
+ } catch (error) {
+ return responseBody;
+ }
+ });
+ }
+ // When the query and variables string is edited, update the URL bar so
+ // that it can be easily shared.
+ function onEditQuery(newQuery) {
+ parameters.query = newQuery;
+ updateURL();
+ }
+ function onEditVariables(newVariables) {
+ parameters.variables = newVariables;
+ updateURL();
+ }
+ function onEditOperationName(newOperationName) {
+ parameters.operationName = newOperationName;
+ updateURL();
+ }
+ function updateURL() {
+ history.replaceState(null, null, locationQuery(parameters));
+ }
+ var options = {
+ fetcher: graphQLFetcher,
+ onEditQuery: onEditQuery,
+ onEditVariables: onEditVariables,
+ onEditOperationName: onEditOperationName,
+ query: parameters.query,
+ }
+ if (parameters.variables) {
+ options.variables = parameters.variables;
+ }
+ if (parameters.operation_name) {
+ options.operationName = parameters.operation_name;
+ }
+ // Render into the body.
+ ReactDOM.render(
+ React.createElement(GraphiQL, options),
+ document.body
+ );
+})();
diff --git a/graphene_django/templates/graphene/graphiql.html b/graphene_django/templates/graphene/graphiql.html
index 1ba0613..d0fb5a8 100644
--- a/graphene_django/templates/graphene/graphiql.html
+++ b/graphene_django/templates/graphene/graphiql.html
@@ -5,6 +5,7 @@ exploring GraphQL.
If you wish to receive JSON, provide the header "Accept: application/json" or
add "&raw" to the end of the URL within a browser.
-->
+{% load static %}
@@ -16,108 +17,20 @@ add "&raw" to the end of the URL within a browser.
width: 100%;
}
-
-
-
-
-
+
+
+
+
+
-
+
diff --git a/graphene_django/tests/issues/__init__.py b/graphene_django/tests/issues/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/graphene_django/tests/issues/test_520.py b/graphene_django/tests/issues/test_520.py
new file mode 100644
index 0000000..60c5b54
--- /dev/null
+++ b/graphene_django/tests/issues/test_520.py
@@ -0,0 +1,44 @@
+# https://github.com/graphql-python/graphene-django/issues/520
+
+import datetime
+
+from django import forms
+
+import graphene
+
+from graphene import Field, ResolveInfo
+from graphene.types.inputobjecttype import InputObjectType
+from py.test import raises
+from py.test import mark
+from rest_framework import serializers
+
+from ...types import DjangoObjectType
+from ...rest_framework.models import MyFakeModel
+from ...rest_framework.mutation import SerializerMutation
+from ...forms.mutation import DjangoFormMutation
+
+
+class MyModelSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = MyFakeModel
+ fields = "__all__"
+
+
+class MyForm(forms.Form):
+ text = forms.CharField()
+
+
+def test_can_use_form_and_serializer_mutations():
+ class MyMutation(SerializerMutation):
+ class Meta:
+ serializer_class = MyModelSerializer
+
+ class MyFormMutation(DjangoFormMutation):
+ class Meta:
+ form_class = MyForm
+
+ class Mutation(graphene.ObjectType):
+ my_mutation = MyMutation.Field()
+ my_form_mutation = MyFormMutation.Field()
+
+ graphene.Schema(mutation=Mutation)
diff --git a/graphene_django/tests/test_command.py b/graphene_django/tests/test_command.py
index ff6e6e1..dbabafa 100644
--- a/graphene_django/tests/test_command.py
+++ b/graphene_django/tests/test_command.py
@@ -1,5 +1,5 @@
from django.core import management
-from mock import patch
+from mock import patch, mock_open
from six import StringIO
@@ -8,3 +8,20 @@ def test_generate_file_on_call_graphql_schema(savefile_mock, settings):
out = StringIO()
management.call_command("graphql_schema", schema="", stdout=out)
assert "Successfully dumped GraphQL schema to schema.json" in out.getvalue()
+
+
+@patch("json.dump")
+def test_files_are_canonical(dump_mock):
+ open_mock = mock_open()
+ with patch("graphene_django.management.commands.graphql_schema.open", open_mock):
+ management.call_command("graphql_schema", schema="")
+
+ open_mock.assert_called_once()
+
+ dump_mock.assert_called_once()
+ assert dump_mock.call_args[1][
+ "sort_keys"
+ ], "json.mock() should be used to sort the output"
+ assert (
+ dump_mock.call_args[1]["indent"] > 0
+ ), "output should be pretty-printed by default"
diff --git a/graphene_django/tests/test_converter.py b/graphene_django/tests/test_converter.py
index 5dc0184..bb176b3 100644
--- a/graphene_django/tests/test_converter.py
+++ b/graphene_django/tests/test_converter.py
@@ -83,7 +83,7 @@ def test_should_image_convert_string():
assert_conversion(models.ImageField, graphene.String)
-def test_should_url_convert_string():
+def test_should_file_path_field_convert_string():
assert_conversion(models.FilePathField, graphene.String)
@@ -91,7 +91,7 @@ def test_should_auto_convert_id():
assert_conversion(models.AutoField, graphene.ID, primary_key=True)
-def test_should_auto_convert_id():
+def test_should_uuid_convert_id():
assert_conversion(models.UUIDField, graphene.UUID)
@@ -241,8 +241,7 @@ def test_should_manytoone_convert_connectionorlist():
class Meta:
model = Article
- graphene_field = convert_django_field(Reporter.articles.rel,
- A._meta.registry)
+ graphene_field = convert_django_field(Reporter.articles.rel, A._meta.registry)
assert isinstance(graphene_field, graphene.Dynamic)
dynamic_field = graphene_field.get_type()
assert isinstance(dynamic_field, graphene.Field)
@@ -255,8 +254,7 @@ def test_should_onetoone_reverse_convert_model():
class Meta:
model = FilmDetails
- graphene_field = convert_django_field(Film.details.related,
- A._meta.registry)
+ graphene_field = convert_django_field(Film.details.related, A._meta.registry)
assert isinstance(graphene_field, graphene.Dynamic)
dynamic_field = graphene_field.get_type()
assert isinstance(dynamic_field, graphene.Field)
diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py
index 1716034..58f46c7 100644
--- a/graphene_django/tests/test_query.py
+++ b/graphene_django/tests/test_query.py
@@ -1007,3 +1007,47 @@ def test_proxy_model_fails():
result = schema.execute(query)
assert result.errors
+
+
+def test_should_resolve_get_queryset_connectionfields():
+ reporter_1 = Reporter.objects.create(
+ first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
+ )
+ reporter_2 = CNNReporter.objects.create(
+ first_name="Some",
+ last_name="Guy",
+ email="someguy@cnn.com",
+ a_choice=1,
+ reporter_type=2, # set this guy to be CNN
+ )
+
+ class ReporterType(DjangoObjectType):
+ class Meta:
+ model = Reporter
+ interfaces = (Node,)
+
+ @classmethod
+ def get_queryset(cls, queryset, info):
+ return queryset.filter(reporter_type=2)
+
+ class Query(graphene.ObjectType):
+ all_reporters = DjangoConnectionField(ReporterType)
+
+ schema = graphene.Schema(query=Query)
+ query = """
+ query ReporterPromiseConnectionQuery {
+ allReporters(first: 1) {
+ edges {
+ node {
+ id
+ }
+ }
+ }
+ }
+ """
+
+ expected = {"allReporters": {"edges": [{"node": {"id": "UmVwb3J0ZXJUeXBlOjI="}}]}}
+
+ result = schema.execute(query)
+ assert not result.errors
+ assert result.data == expected
diff --git a/graphene_django/types.py b/graphene_django/types.py
index 4441a9a..3f99cef 100644
--- a/graphene_django/types.py
+++ b/graphene_django/types.py
@@ -3,6 +3,7 @@ from collections import OrderedDict
from django.db.models import Model
from django.utils.functional import SimpleLazyObject
+import graphene
from graphene import Field
from graphene.relay import Connection, Node
from graphene.types.objecttype import ObjectType, ObjectTypeOptions
@@ -133,9 +134,19 @@ class DjangoObjectType(ObjectType):
model = root._meta.model._meta.concrete_model
return model == cls._meta.model
+ @classmethod
+ def get_queryset(cls, queryset, info):
+ return queryset
+
@classmethod
def get_node(cls, info, id):
+ queryset = cls.get_queryset(cls._meta.model.objects, info)
try:
- return cls._meta.model.objects.get(pk=id)
+ return queryset.get(pk=id)
except cls._meta.model.DoesNotExist:
return None
+
+
+class ErrorType(ObjectType):
+ field = graphene.String(required=True)
+ messages = graphene.List(graphene.NonNull(graphene.String), required=True)
diff --git a/graphene_django/utils/__init__.py b/graphene_django/utils/__init__.py
new file mode 100644
index 0000000..f9c388d
--- /dev/null
+++ b/graphene_django/utils/__init__.py
@@ -0,0 +1,19 @@
+from .utils import (
+ DJANGO_FILTER_INSTALLED,
+ get_reverse_fields,
+ maybe_queryset,
+ get_model_fields,
+ is_valid_django_model,
+ import_single_dispatch,
+)
+from .testing import GraphQLTestCase
+
+__all__ = [
+ "DJANGO_FILTER_INSTALLED",
+ "get_reverse_fields",
+ "maybe_queryset",
+ "get_model_fields",
+ "is_valid_django_model",
+ "import_single_dispatch",
+ "GraphQLTestCase",
+]
diff --git a/graphene_django/utils/testing.py b/graphene_django/utils/testing.py
new file mode 100644
index 0000000..47f8d04
--- /dev/null
+++ b/graphene_django/utils/testing.py
@@ -0,0 +1,67 @@
+import json
+
+from django.test import TestCase, Client
+
+
+class GraphQLTestCase(TestCase):
+ """
+ Based on: https://www.sam.today/blog/testing-graphql-with-graphene-django/
+ """
+
+ # URL to graphql endpoint
+ GRAPHQL_URL = "/graphql/"
+ # Here you need to set your graphql schema for the tests
+ GRAPHQL_SCHEMA = None
+
+ @classmethod
+ def setUpClass(cls):
+ super(GraphQLTestCase, cls).setUpClass()
+
+ if not cls.GRAPHQL_SCHEMA:
+ raise AttributeError(
+ "Variable GRAPHQL_SCHEMA not defined in GraphQLTestCase."
+ )
+
+ cls._client = Client(cls.GRAPHQL_SCHEMA)
+
+ def query(self, query, op_name=None, input_data=None):
+ """
+ Args:
+ query (string) - GraphQL query to run
+ op_name (string) - If the query is a mutation or named query, you must
+ supply the op_name. For annon queries ("{ ... }"),
+ should be None (default).
+ input_data (dict) - If provided, the $input variable in GraphQL will be set
+ to this value
+
+ Returns:
+ Response object from client
+ """
+ body = {"query": query}
+ if op_name:
+ body["operation_name"] = op_name
+ if input_data:
+ body["variables"] = {"input": input_data}
+
+ resp = self._client.post(
+ self.GRAPHQL_URL, json.dumps(body), content_type="application/json"
+ )
+ return resp
+
+ def assertResponseNoErrors(self, resp):
+ """
+ Assert that the call went through correctly. 200 means the syntax is ok, if there are no `errors`,
+ the call was fine.
+ :resp HttpResponse: Response
+ """
+ content = json.loads(resp.content)
+ self.assertEqual(resp.status_code, 200)
+ self.assertNotIn("errors", list(content.keys()))
+
+ def assertResponseHasErrors(self, resp):
+ """
+ Assert that the call was failing. Take care: Even with errors, GraphQL returns status 200!
+ :resp HttpResponse: Response
+ """
+ content = json.loads(resp.content)
+ self.assertIn("errors", list(content.keys()))
diff --git a/graphene_django/utils.py b/graphene_django/utils/utils.py
similarity index 89%
rename from graphene_django/utils.py
rename to graphene_django/utils/utils.py
index 560f604..02c47ee 100644
--- a/graphene_django/utils.py
+++ b/graphene_django/utils/utils.py
@@ -4,13 +4,6 @@ from django.db import models
from django.db.models.manager import Manager
-# from graphene.utils import LazyList
-
-
-class LazyList(object):
- pass
-
-
try:
import django_filters # noqa
@@ -25,8 +18,7 @@ def get_reverse_fields(model, local_field_names):
if name in local_field_names:
continue
- # Django =>1.9 uses 'rel', django <1.9 uses 'related'
- related = getattr(attr, "rel", None) or getattr(attr, "related", None)
+ related = getattr(attr, "rel", None)
if isinstance(related, models.ManyToOneRel):
yield (name, related)
elif isinstance(related, models.ManyToManyRel) and not related.symmetrical:
diff --git a/graphene_django/views.py b/graphene_django/views.py
index be7ccf9..aefe114 100644
--- a/graphene_django/views.py
+++ b/graphene_django/views.py
@@ -51,8 +51,9 @@ def instantiate_middleware(middlewares):
class GraphQLView(View):
- graphiql_version = "0.11.10"
+ graphiql_version = "0.13.0"
graphiql_template = "graphene/graphiql.html"
+ react_version = "16.8.6"
schema = None
graphiql = False
@@ -124,6 +125,13 @@ class GraphQLView(View):
data = self.parse_body(request)
show_graphiql = self.graphiql and self.can_display_graphiql(request, data)
+ if show_graphiql:
+ return self.render_graphiql(
+ request,
+ graphiql_version=self.graphiql_version,
+ react_version=self.react_version,
+ )
+
if self.batch:
responses = [self.get_response(request, entry) for entry in data]
result = "[{}]".format(
@@ -137,19 +145,6 @@ class GraphQLView(View):
else:
result, status_code = self.get_response(request, data, show_graphiql)
- if show_graphiql:
- query, variables, operation_name, id = self.get_graphql_params(
- request, data
- )
- return self.render_graphiql(
- request,
- graphiql_version=self.graphiql_version,
- query=query or "",
- variables=json.dumps(variables) or "",
- operation_name=operation_name or "",
- result=result or "",
- )
-
return HttpResponse(
status=status_code, content=result, content_type="application/json"
)
diff --git a/setup.py b/setup.py
index 3431cd5..e622a71 100644
--- a/setup.py
+++ b/setup.py
@@ -24,6 +24,12 @@ tests_require = [
"pytest-django>=3.3.2",
] + rest_framework_require
+
+dev_requires = [
+ "black==19.3b0",
+ "flake8==3.7.7",
+] + tests_require
+
setup(
name="graphene-django",
version=version,
@@ -58,7 +64,7 @@ setup(
setup_requires=["pytest-runner"],
tests_require=tests_require,
rest_framework_require=rest_framework_require,
- extras_require={"test": tests_require, "rest_framework": rest_framework_require},
+ extras_require={"test": tests_require, "rest_framework": rest_framework_require, "dev": dev_requires},
include_package_data=True,
zip_safe=False,
platforms="any",