diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 0000000..dc90e5a --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,17 @@ +# Number of days of inactivity before an issue becomes stale +daysUntilStale: 60 +# Number of days of inactivity before a stale issue is closed +daysUntilClose: 7 +# Issues with these labels will never be considered stale +exemptLabels: + - pinned + - security +# Label to use when marking an issue as stale +staleLabel: wontfix +# Comment to post when marking an issue as stale. Set to `false` to disable +markComment: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. Thank you + for your contributions. +# Comment to post when closing a stale issue. Set to `false` to disable +closeComment: false diff --git a/.gitignore b/.gitignore index 0b25625..150025a 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,5 @@ Session.vim *~ # auto-generated tag files tags +.tox/ +.pytest_cache/ diff --git a/.travis.yml b/.travis.yml index a8375ee..871d4e3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,54 +1,60 @@ language: python -sudo: false -python: -- 2.7 -- 3.4 -- 3.5 -- 3.6 +cache: pip +dist: xenial + install: -- | - if [ "$TEST_TYPE" = build ]; then - pip install -e .[test] - pip install psycopg2 # Required for Django postgres fields testing - pip install django==$DJANGO_VERSION - python setup.py develop - elif [ "$TEST_TYPE" = lint ]; then - pip install flake8 - fi -script: -- | - if [ "$TEST_TYPE" = lint ]; then - echo "Checking Python code lint." - flake8 graphene_django - exit - elif [ "$TEST_TYPE" = build ]; then - py.test --cov=graphene_django graphene_django examples - fi -after_success: -- | - if [ "$TEST_TYPE" = build ]; then - coveralls - fi -env: - matrix: - - TEST_TYPE=build DJANGO_VERSION=1.11 + - pip install tox tox-travis + +script: + - tox + +after_success: + - pip install coveralls + - coveralls + matrix: fast_finish: true include: - - python: '3.4' - env: TEST_TYPE=build DJANGO_VERSION=2.0 - - python: '3.5' - env: TEST_TYPE=build DJANGO_VERSION=2.0 - - python: '3.6' - env: TEST_TYPE=build DJANGO_VERSION=2.0 - - python: '3.5' - env: TEST_TYPE=build DJANGO_VERSION=2.1 - - python: '3.6' - env: TEST_TYPE=build DJANGO_VERSION=2.1 - - python: '2.7' - env: TEST_TYPE=lint - - python: '3.6' - env: TEST_TYPE=lint + - python: 2.7 + env: DJANGO=1.11 + + - python: 3.5 + env: DJANGO=1.11 + - python: 3.5 + env: DJANGO=2.0 + - python: 3.5 + env: DJANGO=2.1 + - python: 3.5 + env: DJANGO=2.2 + + - python: 3.6 + env: DJANGO=1.11 + - python: 3.6 + env: DJANGO=2.0 + - python: 3.6 + env: DJANGO=2.1 + - python: 3.6 + env: DJANGO=2.2 + - python: 3.6 + env: DJANGO=master + + - python: 3.7 + env: DJANGO=1.11 + - python: 3.7 + env: DJANGO=2.0 + - python: 3.7 + env: DJANGO=2.1 + - python: 3.7 + env: DJANGO=2.2 + - python: 3.7 + env: DJANGO=master + + - python: 3.7 + env: TOXENV=black,flake8 + + allow_failures: + - env: DJANGO=master + 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..70badcb --- /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 --exclude "/migrations/" graphene_django examples + +lint: + flake8 graphene_django examples 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 7a08481..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 ------------------------------ @@ -114,7 +137,7 @@ method to your ``DjangoObjectType``. interfaces = (relay.Node, ) @classmethod - def get_node(cls, id, info): + def get_node(cls, info, id): try: post = cls._meta.model.objects.get(id=id) except cls._meta.model.DoesNotExist: @@ -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..7661928 100644 --- a/docs/filtering.rst +++ b/docs/filtering.rst @@ -100,7 +100,7 @@ features of ``django-filter``. This is done by transparently creating a ``filter_fields``. However, you may find this to be insufficient. In these cases you can -create your own ``Filterset`` as follows: +create your own ``FilterSet``. You can pass it directly as follows: .. code:: python @@ -127,6 +127,33 @@ create your own ``Filterset`` as follows: all_animals = DjangoFilterConnectionField(AnimalNode, filterset_class=AnimalFilter) +You can also specify the ``FilterSet`` class using the ``filerset_class`` +parameter when defining your ``DjangoObjectType``, however, this can't be used +in unison with the ``filter_fields`` parameter: + +.. code:: python + + class AnimalFilter(django_filters.FilterSet): + # Do case-insensitive lookups on 'name' + name = django_filters.CharFilter(lookup_expr=['iexact']) + + class Meta: + # Assume you have an Animal model defined with the following fields + model = Animal + fields = ['name', 'genus', 'is_domesticated'] + + + class AnimalNode(DjangoObjectType): + class Meta: + model = Animal + filterset_class = AnimalFilter + interfaces = (relay.Node, ) + + + class Query(ObjectType): + animal = relay.Node.Field(AnimalNode) + all_animals = DjangoFilterConnectionField(AnimalNode) + The context argument is passed on as the `request argument `__ in a ``django_filters.FilterSet`` instance. You can use this to customize your filters to be context-dependent. We could modify the ``AnimalFilter`` above to @@ -136,7 +163,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 +173,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..c1d6ede 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,12 +27,16 @@ 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 -------------- The ``--indent`` option can be used to specify the number of indentation spaces to be used in the output. Defaults to `None` which displays all data on a single line. +The ``--watch`` option can be used to run ``./manage.py graphql_schema`` in watch mode, where it will automatically output a new schema every time there are file changes in your project + To simplify the command to ``./manage.py graphql_schema``, you can specify the parameters in your settings.py: diff --git a/docs/mutations.rst b/docs/mutations.rst new file mode 100644 index 0000000..6610151 --- /dev/null +++ b/docs/mutations.rst @@ -0,0 +1,231 @@ +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 + from graphene import relay + from graphene_django import 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)[1]) + 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. 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/admin.py b/examples/cookbook-plain/cookbook/ingredients/admin.py index b57cbc3..042682f 100644 --- a/examples/cookbook-plain/cookbook/ingredients/admin.py +++ b/examples/cookbook-plain/cookbook/ingredients/admin.py @@ -5,8 +5,8 @@ from cookbook.ingredients.models import Category, Ingredient @admin.register(Ingredient) class IngredientAdmin(admin.ModelAdmin): - list_display = ('id', 'name', 'category') - list_editable = ('name', 'category') + 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 index 21b4b08..3ad0143 100644 --- a/examples/cookbook-plain/cookbook/ingredients/apps.py +++ b/examples/cookbook-plain/cookbook/ingredients/apps.py @@ -2,6 +2,6 @@ from django.apps import AppConfig class IngredientsConfig(AppConfig): - name = 'cookbook.ingredients' - label = 'ingredients' - verbose_name = 'Ingredients' + name = "cookbook.ingredients" + label = "ingredients" + verbose_name = "Ingredients" 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..5d88785 100644 --- a/examples/cookbook-plain/cookbook/ingredients/models.py +++ b/examples/cookbook-plain/cookbook/ingredients/models.py @@ -2,6 +2,9 @@ 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 +14,9 @@ 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..1a54c4b 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): @@ -15,27 +15,22 @@ class IngredientType(DjangoObjectType): class Query(object): - category = graphene.Field(CategoryType, - id=graphene.Int(), - name=graphene.String()) + 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()) + ingredient = graphene.Field( + IngredientType, id=graphene.Int(), 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') + return Ingredient.objects.select_related("category").all() + def resolve_category(self, context, id=None, name=None): if id is not None: return Category.objects.get(pk=id) @@ -44,10 +39,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/ingredients/tests.py b/examples/cookbook-plain/cookbook/ingredients/tests.py index 4929020..a39b155 100644 --- a/examples/cookbook-plain/cookbook/ingredients/tests.py +++ b/examples/cookbook-plain/cookbook/ingredients/tests.py @@ -1,2 +1 @@ - # Create your tests here. diff --git a/examples/cookbook-plain/cookbook/ingredients/views.py b/examples/cookbook-plain/cookbook/ingredients/views.py index b8e4ee0..60f00ef 100644 --- a/examples/cookbook-plain/cookbook/ingredients/views.py +++ b/examples/cookbook-plain/cookbook/ingredients/views.py @@ -1,2 +1 @@ - # Create your views here. diff --git a/examples/cookbook-plain/cookbook/recipes/apps.py b/examples/cookbook-plain/cookbook/recipes/apps.py index 1f24f13..f1e4dde 100644 --- a/examples/cookbook-plain/cookbook/recipes/apps.py +++ b/examples/cookbook-plain/cookbook/recipes/apps.py @@ -2,6 +2,6 @@ from django.apps import AppConfig class RecipesConfig(AppConfig): - name = 'cookbook.recipes' - label = 'recipes' - verbose_name = 'Recipes' + name = "cookbook.recipes" + label = "recipes" + verbose_name = "Recipes" 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..f6e955e 100644 --- a/examples/cookbook-plain/cookbook/recipes/models.py +++ b/examples/cookbook-plain/cookbook/recipes/models.py @@ -1,21 +1,28 @@ 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'), - ('kg', 'Kilograms'), - ('l', 'Litres'), - ('st', 'Shots'), - )) + 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 index 040c985..b029570 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): @@ -15,19 +15,13 @@ class RecipeIngredientType(DjangoObjectType): class Query(object): - recipe = graphene.Field(RecipeType, - id=graphene.Int(), - title=graphene.String()) + recipe = graphene.Field(RecipeType, id=graphene.Int(), title=graphene.String()) all_recipes = graphene.List(RecipeType) - recipeingredient = graphene.Field(RecipeIngredientType, - id=graphene.Int()) + 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') - + def resolve_recipe(self, context, id=None, title=None): if id is not None: return Recipe.objects.get(pk=id) @@ -36,17 +30,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): - related = ['recipe', 'ingredient'] + def resolve_all_recipeingredients(self, context): + related = ["recipe", "ingredient"] return RecipeIngredient.objects.select_related(*related).all() diff --git a/examples/cookbook-plain/cookbook/recipes/tests.py b/examples/cookbook-plain/cookbook/recipes/tests.py index 4929020..a39b155 100644 --- a/examples/cookbook-plain/cookbook/recipes/tests.py +++ b/examples/cookbook-plain/cookbook/recipes/tests.py @@ -1,2 +1 @@ - # Create your tests here. diff --git a/examples/cookbook-plain/cookbook/recipes/views.py b/examples/cookbook-plain/cookbook/recipes/views.py index b8e4ee0..60f00ef 100644 --- a/examples/cookbook-plain/cookbook/recipes/views.py +++ b/examples/cookbook-plain/cookbook/recipes/views.py @@ -1,2 +1 @@ - # Create your views here. diff --git a/examples/cookbook-plain/cookbook/schema.py b/examples/cookbook-plain/cookbook/schema.py index f8606a7..bde9372 100644 --- a/examples/cookbook-plain/cookbook/schema.py +++ b/examples/cookbook-plain/cookbook/schema.py @@ -5,10 +5,12 @@ 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') +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 index 948292d..7eb9d56 100644 --- a/examples/cookbook-plain/cookbook/settings.py +++ b/examples/cookbook-plain/cookbook/settings.py @@ -21,7 +21,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # 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' +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 @@ -32,64 +32,61 @@ 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', + "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', +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.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", ] GRAPHENE = { - 'SCHEMA': 'cookbook.schema.schema', - 'MIDDLEWARE': ( - 'graphene_django.debug.DjangoDebugMiddleware', - ) + "SCHEMA": "cookbook.schema.schema", + "SCHEMA_INDENT": 2, + "MIDDLEWARE": ("graphene_django.debug.DjangoDebugMiddleware",), } -ROOT_URLCONF = 'cookbook.urls' +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', - ], + "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' +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'), + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": os.path.join(BASE_DIR, "db.sqlite3"), } } @@ -99,26 +96,20 @@ DATABASES = { 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', + "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' +LANGUAGE_CODE = "en-us" -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" USE_I18N = True @@ -130,9 +121,4 @@ 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, -} +STATIC_URL = "/static/" diff --git a/examples/cookbook-plain/cookbook/urls.py b/examples/cookbook-plain/cookbook/urls.py index 9f8755b..a64a875 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/ingredients/admin.py b/examples/cookbook/cookbook/ingredients/admin.py index b57cbc3..042682f 100644 --- a/examples/cookbook/cookbook/ingredients/admin.py +++ b/examples/cookbook/cookbook/ingredients/admin.py @@ -5,8 +5,8 @@ from cookbook.ingredients.models import Category, Ingredient @admin.register(Ingredient) class IngredientAdmin(admin.ModelAdmin): - list_display = ('id', 'name', 'category') - list_editable = ('name', 'category') + list_display = ("id", "name", "category") + list_editable = ("name", "category") admin.site.register(Category) diff --git a/examples/cookbook/cookbook/ingredients/apps.py b/examples/cookbook/cookbook/ingredients/apps.py index 21b4b08..3ad0143 100644 --- a/examples/cookbook/cookbook/ingredients/apps.py +++ b/examples/cookbook/cookbook/ingredients/apps.py @@ -2,6 +2,6 @@ from django.apps import AppConfig class IngredientsConfig(AppConfig): - name = 'cookbook.ingredients' - label = 'ingredients' - verbose_name = 'Ingredients' + name = "cookbook.ingredients" + label = "ingredients" + verbose_name = "Ingredients" diff --git a/examples/cookbook/cookbook/ingredients/models.py b/examples/cookbook/cookbook/ingredients/models.py index 2f0eba3..6426dab 100644 --- a/examples/cookbook/cookbook/ingredients/models.py +++ b/examples/cookbook/cookbook/ingredients/models.py @@ -11,7 +11,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") def __str__(self): return self.name diff --git a/examples/cookbook/cookbook/ingredients/schema.py b/examples/cookbook/cookbook/ingredients/schema.py index 5ad92e8..5e5da80 100644 --- a/examples/cookbook/cookbook/ingredients/schema.py +++ b/examples/cookbook/cookbook/ingredients/schema.py @@ -7,24 +7,22 @@ from graphene_django.types import DjangoObjectType # Graphene will automatically map the Category model's fields onto the CategoryNode. # This is configured in the CategoryNode's Meta class (as you can see below) class CategoryNode(DjangoObjectType): - class Meta: model = Category - interfaces = (Node, ) - filter_fields = ['name', 'ingredients'] + interfaces = (Node,) + filter_fields = ["name", "ingredients"] class IngredientNode(DjangoObjectType): - class Meta: model = Ingredient # Allow for some more advanced filtering here - interfaces = (Node, ) + interfaces = (Node,) filter_fields = { - 'name': ['exact', 'icontains', 'istartswith'], - 'notes': ['exact', 'icontains'], - 'category': ['exact'], - 'category__name': ['exact'], + "name": ["exact", "icontains", "istartswith"], + "notes": ["exact", "icontains"], + "category": ["exact"], + "category__name": ["exact"], } diff --git a/examples/cookbook/cookbook/ingredients/tests.py b/examples/cookbook/cookbook/ingredients/tests.py index 4929020..a39b155 100644 --- a/examples/cookbook/cookbook/ingredients/tests.py +++ b/examples/cookbook/cookbook/ingredients/tests.py @@ -1,2 +1 @@ - # Create your tests here. diff --git a/examples/cookbook/cookbook/ingredients/views.py b/examples/cookbook/cookbook/ingredients/views.py index b8e4ee0..60f00ef 100644 --- a/examples/cookbook/cookbook/ingredients/views.py +++ b/examples/cookbook/cookbook/ingredients/views.py @@ -1,2 +1 @@ - # Create your views here. diff --git a/examples/cookbook/cookbook/recipes/apps.py b/examples/cookbook/cookbook/recipes/apps.py index 1f24f13..f1e4dde 100644 --- a/examples/cookbook/cookbook/recipes/apps.py +++ b/examples/cookbook/cookbook/recipes/apps.py @@ -2,6 +2,6 @@ from django.apps import AppConfig class RecipesConfig(AppConfig): - name = 'cookbook.recipes' - label = 'recipes' - verbose_name = 'Recipes' + name = "cookbook.recipes" + label = "recipes" + verbose_name = "Recipes" diff --git a/examples/cookbook/cookbook/recipes/models.py b/examples/cookbook/cookbook/recipes/models.py index ca12fac..b98664c 100644 --- a/examples/cookbook/cookbook/recipes/models.py +++ b/examples/cookbook/cookbook/recipes/models.py @@ -10,12 +10,15 @@ class Recipe(models.Model): 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") + 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'), - )) + unit = models.CharField( + max_length=20, + choices=( + ("unit", "Units"), + ("kg", "Kilograms"), + ("l", "Litres"), + ("st", "Shots"), + ), + ) diff --git a/examples/cookbook/cookbook/recipes/schema.py b/examples/cookbook/cookbook/recipes/schema.py index 8018322..fbbedd8 100644 --- a/examples/cookbook/cookbook/recipes/schema.py +++ b/examples/cookbook/cookbook/recipes/schema.py @@ -3,24 +3,23 @@ from graphene import Node from graphene_django.filter import DjangoFilterConnectionField from graphene_django.types import DjangoObjectType -class RecipeNode(DjangoObjectType): +class RecipeNode(DjangoObjectType): class Meta: model = Recipe - interfaces = (Node, ) - filter_fields = ['title','amounts'] + interfaces = (Node,) + filter_fields = ["title", "amounts"] class RecipeIngredientNode(DjangoObjectType): - class Meta: model = RecipeIngredient # Allow for some more advanced filtering here - interfaces = (Node, ) + interfaces = (Node,) filter_fields = { - 'ingredient__name': ['exact', 'icontains', 'istartswith'], - 'recipe': ['exact'], - 'recipe__title': ['icontains'], + "ingredient__name": ["exact", "icontains", "istartswith"], + "recipe": ["exact"], + "recipe__title": ["icontains"], } diff --git a/examples/cookbook/cookbook/recipes/tests.py b/examples/cookbook/cookbook/recipes/tests.py index 4929020..a39b155 100644 --- a/examples/cookbook/cookbook/recipes/tests.py +++ b/examples/cookbook/cookbook/recipes/tests.py @@ -1,2 +1 @@ - # Create your tests here. diff --git a/examples/cookbook/cookbook/recipes/views.py b/examples/cookbook/cookbook/recipes/views.py index b8e4ee0..60f00ef 100644 --- a/examples/cookbook/cookbook/recipes/views.py +++ b/examples/cookbook/cookbook/recipes/views.py @@ -1,2 +1 @@ - # Create your views here. diff --git a/examples/cookbook/cookbook/schema.py b/examples/cookbook/cookbook/schema.py index f8606a7..bde9372 100644 --- a/examples/cookbook/cookbook/schema.py +++ b/examples/cookbook/cookbook/schema.py @@ -5,10 +5,12 @@ 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') +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/cookbook/settings.py b/examples/cookbook/cookbook/settings.py index 948292d..ed41a65 100644 --- a/examples/cookbook/cookbook/settings.py +++ b/examples/cookbook/cookbook/settings.py @@ -21,7 +21,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # 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' +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 @@ -32,64 +32,62 @@ 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', + "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', + "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', - ) + "SCHEMA": "cookbook.schema.schema", + "SCHEMA_INDENT": 2, + "MIDDLEWARE": ("graphene_django.debug.DjangoDebugMiddleware",), } -ROOT_URLCONF = 'cookbook.urls' +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', - ], + "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' +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'), + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": os.path.join(BASE_DIR, "db.sqlite3"), } } @@ -99,26 +97,20 @@ DATABASES = { 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', + "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' +LANGUAGE_CODE = "en-us" -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" USE_I18N = True @@ -130,9 +122,4 @@ 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, -} +STATIC_URL = "/static/" diff --git a/examples/cookbook/cookbook/urls.py b/examples/cookbook/cookbook/urls.py index 9f8755b..6f8a302 100644 --- a/examples/cookbook/cookbook/urls.py +++ b/examples/cookbook/cookbook/urls.py @@ -5,6 +5,6 @@ from graphene_django.views import GraphQLView urlpatterns = [ - url(r'^admin/', admin.site.urls), - url(r'^graphql', GraphQLView.as_view(graphiql=True)), + url(r"^admin/", admin.site.urls), + url(r"^graphql$", GraphQLView.as_view(graphiql=True)), ] diff --git a/examples/cookbook/requirements.txt b/examples/cookbook/requirements.txt index b2ace1f..fe0527a 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.20 django-filter>=2 diff --git a/examples/starwars/data.py b/examples/starwars/data.py index 9b52006..6bdbf57 100644 --- a/examples/starwars/data.py +++ b/examples/starwars/data.py @@ -2,97 +2,50 @@ from .models import Character, Faction, Ship def initialize(): - human = Character( - name='Human' - ) + human = Character(name="Human") human.save() - droid = Character( - name='Droid' - ) + droid = Character(name="Droid") droid.save() - rebels = Faction( - id='1', - name='Alliance to Restore the Republic', - hero=human - ) + rebels = Faction(id="1", name="Alliance to Restore the Republic", hero=human) rebels.save() - empire = Faction( - id='2', - name='Galactic Empire', - hero=droid - ) + empire = Faction(id="2", name="Galactic Empire", hero=droid) empire.save() - xwing = Ship( - id='1', - name='X-Wing', - faction=rebels, - ) + xwing = Ship(id="1", name="X-Wing", faction=rebels) xwing.save() human.ship = xwing human.save() - ywing = Ship( - id='2', - name='Y-Wing', - faction=rebels, - ) + ywing = Ship(id="2", name="Y-Wing", faction=rebels) ywing.save() - awing = Ship( - id='3', - name='A-Wing', - faction=rebels, - ) + awing = Ship(id="3", name="A-Wing", faction=rebels) awing.save() # Yeah, technically it's Corellian. But it flew in the service of the rebels, # so for the purposes of this demo it's a rebel ship. - falcon = Ship( - id='4', - name='Millenium Falcon', - faction=rebels, - ) + falcon = Ship(id="4", name="Millenium Falcon", faction=rebels) falcon.save() - homeOne = Ship( - id='5', - name='Home One', - faction=rebels, - ) + homeOne = Ship(id="5", name="Home One", faction=rebels) homeOne.save() - tieFighter = Ship( - id='6', - name='TIE Fighter', - faction=empire, - ) + tieFighter = Ship(id="6", name="TIE Fighter", faction=empire) tieFighter.save() - tieInterceptor = Ship( - id='7', - name='TIE Interceptor', - faction=empire, - ) + tieInterceptor = Ship(id="7", name="TIE Interceptor", faction=empire) tieInterceptor.save() - executor = Ship( - id='8', - name='Executor', - faction=empire, - ) + executor = Ship(id="8", name="Executor", faction=empire) executor.save() def create_ship(ship_name, faction_id): - new_ship = Ship( - name=ship_name, - faction_id=faction_id - ) + new_ship = Ship(name=ship_name, faction_id=faction_id) new_ship.save() return new_ship diff --git a/examples/starwars/models.py b/examples/starwars/models.py index 45741da..03e06a2 100644 --- a/examples/starwars/models.py +++ b/examples/starwars/models.py @@ -5,7 +5,13 @@ from django.db import models class Character(models.Model): name = models.CharField(max_length=50) - ship = models.ForeignKey('Ship', on_delete=models.CASCADE, blank=True, null=True, related_name='characters') + ship = models.ForeignKey( + "Ship", + on_delete=models.CASCADE, + blank=True, + null=True, + related_name="characters", + ) def __str__(self): return self.name @@ -21,7 +27,7 @@ class Faction(models.Model): class Ship(models.Model): name = models.CharField(max_length=50) - faction = models.ForeignKey(Faction, on_delete=models.CASCADE, related_name='ships') + faction = models.ForeignKey(Faction, on_delete=models.CASCADE, related_name="ships") def __str__(self): return self.name diff --git a/examples/starwars/schema.py b/examples/starwars/schema.py index 492918e..fb22840 100644 --- a/examples/starwars/schema.py +++ b/examples/starwars/schema.py @@ -2,18 +2,16 @@ import graphene from graphene import Schema, relay, resolve_only_args from graphene_django import DjangoConnectionField, DjangoObjectType -from .data import (create_ship, get_empire, get_faction, get_rebels, get_ship, - get_ships) +from .data import create_ship, get_empire, get_faction, get_rebels, get_ship, get_ships from .models import Character as CharacterModel from .models import Faction as FactionModel from .models import Ship as ShipModel class Ship(DjangoObjectType): - class Meta: model = ShipModel - interfaces = (relay.Node, ) + interfaces = (relay.Node,) @classmethod def get_node(cls, info, id): @@ -22,16 +20,14 @@ class Ship(DjangoObjectType): class Character(DjangoObjectType): - class Meta: model = CharacterModel class Faction(DjangoObjectType): - class Meta: model = FactionModel - interfaces = (relay.Node, ) + interfaces = (relay.Node,) @classmethod def get_node(cls, info, id): @@ -39,7 +35,6 @@ class Faction(DjangoObjectType): class IntroduceShip(relay.ClientIDMutation): - class Input: ship_name = graphene.String(required=True) faction_id = graphene.String(required=True) @@ -48,7 +43,9 @@ class IntroduceShip(relay.ClientIDMutation): faction = graphene.Field(Faction) @classmethod - def mutate_and_get_payload(cls, root, info, ship_name, faction_id, client_mutation_id=None): + def mutate_and_get_payload( + cls, root, info, ship_name, faction_id, client_mutation_id=None + ): ship = create_ship(ship_name, faction_id) faction = get_faction(faction_id) return IntroduceShip(ship=ship, faction=faction) @@ -58,7 +55,7 @@ class Query(graphene.ObjectType): rebels = graphene.Field(Faction) empire = graphene.Field(Faction) node = relay.Node.Field() - ships = DjangoConnectionField(Ship, description='All the ships.') + ships = DjangoConnectionField(Ship, description="All the ships.") @resolve_only_args def resolve_ships(self): diff --git a/examples/starwars/tests/test_connections.py b/examples/starwars/tests/test_connections.py index d266df3..425dce5 100644 --- a/examples/starwars/tests/test_connections.py +++ b/examples/starwars/tests/test_connections.py @@ -8,7 +8,7 @@ pytestmark = pytest.mark.django_db def test_correct_fetch_first_ship_rebels(): initialize() - query = ''' + query = """ query RebelsShipsQuery { rebels { name, @@ -24,22 +24,12 @@ def test_correct_fetch_first_ship_rebels(): } } } - ''' + """ expected = { - 'rebels': { - 'name': 'Alliance to Restore the Republic', - 'hero': { - 'name': 'Human' - }, - 'ships': { - 'edges': [ - { - 'node': { - 'name': 'X-Wing' - } - } - ] - } + "rebels": { + "name": "Alliance to Restore the Republic", + "hero": {"name": "Human"}, + "ships": {"edges": [{"node": {"name": "X-Wing"}}]}, } } result = schema.execute(query) @@ -49,7 +39,7 @@ def test_correct_fetch_first_ship_rebels(): def test_correct_list_characters(): initialize() - query = ''' + query = """ query RebelsShipsQuery { node(id: "U2hpcDox") { ... on Ship { @@ -60,15 +50,8 @@ def test_correct_list_characters(): } } } - ''' - expected = { - 'node': { - 'name': 'X-Wing', - 'characters': [{ - 'name': 'Human' - }], - } - } + """ + expected = {"node": {"name": "X-Wing", "characters": [{"name": "Human"}]}} result = schema.execute(query) assert not result.errors assert result.data == expected diff --git a/examples/starwars/tests/test_mutation.py b/examples/starwars/tests/test_mutation.py index aa312ff..e24bf8a 100644 --- a/examples/starwars/tests/test_mutation.py +++ b/examples/starwars/tests/test_mutation.py @@ -9,7 +9,7 @@ pytestmark = pytest.mark.django_db def test_mutations(): initialize() - query = ''' + query = """ mutation MyMutation { introduceShip(input:{clientMutationId:"abc", shipName: "Peter", factionId: "1"}) { ship { @@ -29,49 +29,23 @@ def test_mutations(): } } } - ''' + """ expected = { - 'introduceShip': { - 'ship': { - 'id': 'U2hpcDo5', - 'name': 'Peter' - }, - 'faction': { - 'name': 'Alliance to Restore the Republic', - 'ships': { - 'edges': [{ - 'node': { - 'id': 'U2hpcDox', - 'name': 'X-Wing' - } - }, { - 'node': { - 'id': 'U2hpcDoy', - 'name': 'Y-Wing' - } - }, { - 'node': { - 'id': 'U2hpcDoz', - 'name': 'A-Wing' - } - }, { - 'node': { - 'id': 'U2hpcDo0', - 'name': 'Millenium Falcon' - } - }, { - 'node': { - 'id': 'U2hpcDo1', - 'name': 'Home One' - } - }, { - 'node': { - 'id': 'U2hpcDo5', - 'name': 'Peter' - } - }] + "introduceShip": { + "ship": {"id": "U2hpcDo5", "name": "Peter"}, + "faction": { + "name": "Alliance to Restore the Republic", + "ships": { + "edges": [ + {"node": {"id": "U2hpcDox", "name": "X-Wing"}}, + {"node": {"id": "U2hpcDoy", "name": "Y-Wing"}}, + {"node": {"id": "U2hpcDoz", "name": "A-Wing"}}, + {"node": {"id": "U2hpcDo0", "name": "Millenium Falcon"}}, + {"node": {"id": "U2hpcDo1", "name": "Home One"}}, + {"node": {"id": "U2hpcDo5", "name": "Peter"}}, + ] }, - } + }, } } result = schema.execute(query) diff --git a/examples/starwars/tests/test_objectidentification.py b/examples/starwars/tests/test_objectidentification.py index fad1958..6e04a7b 100644 --- a/examples/starwars/tests/test_objectidentification.py +++ b/examples/starwars/tests/test_objectidentification.py @@ -8,19 +8,16 @@ pytestmark = pytest.mark.django_db def test_correctly_fetches_id_name_rebels(): initialize() - query = ''' + query = """ query RebelsQuery { rebels { id name } } - ''' + """ expected = { - 'rebels': { - 'id': 'RmFjdGlvbjox', - 'name': 'Alliance to Restore the Republic' - } + "rebels": {"id": "RmFjdGlvbjox", "name": "Alliance to Restore the Republic"} } result = schema.execute(query) assert not result.errors @@ -29,7 +26,7 @@ def test_correctly_fetches_id_name_rebels(): def test_correctly_refetches_rebels(): initialize() - query = ''' + query = """ query RebelsRefetchQuery { node(id: "RmFjdGlvbjox") { id @@ -38,12 +35,9 @@ def test_correctly_refetches_rebels(): } } } - ''' + """ expected = { - 'node': { - 'id': 'RmFjdGlvbjox', - 'name': 'Alliance to Restore the Republic' - } + "node": {"id": "RmFjdGlvbjox", "name": "Alliance to Restore the Republic"} } result = schema.execute(query) assert not result.errors @@ -52,20 +46,15 @@ def test_correctly_refetches_rebels(): def test_correctly_fetches_id_name_empire(): initialize() - query = ''' + query = """ query EmpireQuery { empire { id name } } - ''' - expected = { - 'empire': { - 'id': 'RmFjdGlvbjoy', - 'name': 'Galactic Empire' - } - } + """ + expected = {"empire": {"id": "RmFjdGlvbjoy", "name": "Galactic Empire"}} result = schema.execute(query) assert not result.errors assert result.data == expected @@ -73,7 +62,7 @@ def test_correctly_fetches_id_name_empire(): def test_correctly_refetches_empire(): initialize() - query = ''' + query = """ query EmpireRefetchQuery { node(id: "RmFjdGlvbjoy") { id @@ -82,13 +71,8 @@ def test_correctly_refetches_empire(): } } } - ''' - expected = { - 'node': { - 'id': 'RmFjdGlvbjoy', - 'name': 'Galactic Empire' - } - } + """ + expected = {"node": {"id": "RmFjdGlvbjoy", "name": "Galactic Empire"}} result = schema.execute(query) assert not result.errors assert result.data == expected @@ -96,7 +80,7 @@ def test_correctly_refetches_empire(): def test_correctly_refetches_xwing(): initialize() - query = ''' + query = """ query XWingRefetchQuery { node(id: "U2hpcDox") { id @@ -105,13 +89,8 @@ def test_correctly_refetches_xwing(): } } } - ''' - expected = { - 'node': { - 'id': 'U2hpcDox', - 'name': 'X-Wing' - } - } + """ + expected = {"node": {"id": "U2hpcDox", "name": "X-Wing"}} result = schema.execute(query) assert not result.errors assert result.data == expected diff --git a/graphene_django/__init__.py b/graphene_django/__init__.py index 4538cb3..51acfd2 100644 --- a/graphene_django/__init__.py +++ b/graphene_django/__init__.py @@ -1,6 +1,6 @@ from .types import DjangoObjectType from .fields import DjangoConnectionField -__version__ = "2.2.0" +__version__ = "2.3.0" __all__ = ["__version__", "DjangoObjectType", "DjangoConnectionField"] 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/converter.py b/graphene_django/converter.py index c40313d..1bb16f4 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -177,19 +177,26 @@ def convert_field_to_list_or_connection(field, registry=None): if not _type: return + description = ( + field.help_text + if isinstance(field, models.ManyToManyField) + else field.field.help_text + ) + # If there is a connection, we should transform the field # into a DjangoConnectionField if _type._meta.connection: # Use a DjangoFilterConnectionField if there are - # defined filter_fields in the DjangoObjectType Meta - if _type._meta.filter_fields: + # defined filter_fields or a filterset_class in the + # DjangoObjectType Meta + if _type._meta.filter_fields or _type._meta.filterset_class: from .filter.fields import DjangoFilterConnectionField - return DjangoFilterConnectionField(_type) + return DjangoFilterConnectionField(_type, description=description) - return DjangoConnectionField(_type) + return DjangoConnectionField(_type, description=description) - return DjangoListField(_type) + return DjangoListField(_type, description=description) return Dynamic(dynamic_type) 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 f2ef096..af69715 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,9 +50,7 @@ def test_should_query_field(): """ expected = { "reporter": {"lastName": "ABA"}, - "__debug": { - "sql": [{"rawSql": str(Reporter.objects.order_by("pk")[:1].query)}] - }, + "_debug": {"sql": [{"rawSql": str(Reporter.objects.order_by("pk")[:1].query)}]}, } schema = graphene.Schema(query=Query) result = schema.execute( @@ -75,7 +73,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() @@ -85,7 +83,7 @@ def test_should_query_list(): allReporters { lastName } - __debug { + _debug { sql { rawSql } @@ -94,7 +92,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( @@ -117,7 +115,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() @@ -131,7 +129,7 @@ def test_should_query_connection(): } } } - __debug { + _debug { sql { rawSql } @@ -145,9 +143,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(): @@ -166,7 +164,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() @@ -180,7 +178,7 @@ def test_should_query_connectionfilter(): } } } - __debug { + _debug { sql { rawSql } @@ -194,6 +192,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 7261baf..948a520 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -1,10 +1,12 @@ from functools import partial from django.db.models.query import QuerySet -from graphene.relay import ConnectionField, PageInfo -from graphene.types import Field, List +from graphene import NonNull + from promise import Promise +from graphene.types import Field, List +from graphene.relay import ConnectionField, PageInfo from graphql_relay.connection.arrayconnection import connection_from_list_slice from .settings import graphene_settings @@ -44,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): @@ -66,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: @@ -138,8 +158,8 @@ class DjangoConnectionField(ConnectionField): "exceeds the limit of {max_limit} records.").format(**locals())) iterable = resolver(root, info, **kwargs) - on_resolve = partial(cls.resolve_connection, - connection, default_manager, kwargs) + queryset = cls.resolve_queryset(connection, default_manager, info, kwargs) + on_resolve = partial(cls.resolve_connection, connection, queryset, kwargs) if Promise.is_thenable(iterable): return Promise.resolve(iterable).then(on_resolve) @@ -150,7 +170,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/fields.py b/graphene_django/filter/fields.py index cb42543..62f4b1a 100644 --- a/graphene_django/filter/fields.py +++ b/graphene_django/filter/fields.py @@ -40,9 +40,10 @@ class DjangoFilterConnectionField(DjangoConnectionField): if self._extra_filter_meta: meta.update(self._extra_filter_meta) - self._filterset_class = get_filterset_class( - self._provided_filterset_class, **meta + filterset_class = self._provided_filterset_class or ( + self.node_type._meta.filterset_class ) + self._filterset_class = get_filterset_class(filterset_class, **meta) return self._filterset_class 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/filter/tests/test_fields.py b/graphene_django/filter/tests/test_fields.py index f5d2fb7..0dbfd26 100644 --- a/graphene_django/filter/tests/test_fields.py +++ b/graphene_django/filter/tests/test_fields.py @@ -228,6 +228,74 @@ def test_filter_filterset_information_on_meta_related(): assert_not_orderable(articles_field) +def test_filter_filterset_class_filter_fields_exception(): + with pytest.raises(Exception): + + class ReporterFilter(FilterSet): + class Meta: + model = Reporter + fields = ["first_name", "articles"] + + class ReporterFilterNode(DjangoObjectType): + class Meta: + model = Reporter + interfaces = (Node,) + filterset_class = ReporterFilter + filter_fields = ["first_name", "articles"] + + +def test_filter_filterset_class_information_on_meta(): + class ReporterFilter(FilterSet): + class Meta: + model = Reporter + fields = ["first_name", "articles"] + + class ReporterFilterNode(DjangoObjectType): + class Meta: + model = Reporter + interfaces = (Node,) + filterset_class = ReporterFilter + + field = DjangoFilterConnectionField(ReporterFilterNode) + assert_arguments(field, "first_name", "articles") + assert_not_orderable(field) + + +def test_filter_filterset_class_information_on_meta_related(): + class ReporterFilter(FilterSet): + class Meta: + model = Reporter + fields = ["first_name", "articles"] + + class ArticleFilter(FilterSet): + class Meta: + model = Article + fields = ["headline", "reporter"] + + class ReporterFilterNode(DjangoObjectType): + class Meta: + model = Reporter + interfaces = (Node,) + filterset_class = ReporterFilter + + class ArticleFilterNode(DjangoObjectType): + class Meta: + model = Article + interfaces = (Node,) + filterset_class = ArticleFilter + + class Query(ObjectType): + all_reporters = DjangoFilterConnectionField(ReporterFilterNode) + all_articles = DjangoFilterConnectionField(ArticleFilterNode) + reporter = Field(ReporterFilterNode) + article = Field(ArticleFilterNode) + + schema = Schema(query=Query) + articles_field = ReporterFilterNode._meta.fields["articles"].get_type() + assert_arguments(articles_field, "headline", "reporter") + assert_not_orderable(articles_field) + + def test_filter_filterset_related_results(): class ReporterFilterNode(DjangoObjectType): class Meta: 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..1e8baf6 100644 --- a/graphene_django/management/commands/graphql_schema.py +++ b/graphene_django/management/commands/graphql_schema.py @@ -1,7 +1,9 @@ import importlib import json +import functools from django.core.management.base import BaseCommand, CommandError +from django.utils import autoreload from graphene_django.settings import graphene_settings @@ -32,6 +34,14 @@ class CommandArguments(BaseCommand): help="Output file indent (default: None)", ) + parser.add_argument( + "--watch", + dest="watch", + default=False, + action="store_true", + help="Updates the schema on file changes (default: False)", + ) + class Command(CommandArguments): help = "Dump Graphene schema JSON to file" @@ -39,7 +49,19 @@ 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 get_schema(self, schema, out, indent): + schema_dict = {"data": schema.introspect()} + if out == "-": + self.stdout.write(json.dumps(schema_dict, indent=indent, sort_keys=True)) + else: + self.save_file(out, schema_dict, indent) + + style = getattr(self, "style", None) + success = getattr(style, "SUCCESS", lambda x: x) + + self.stdout.write(success("Successfully dumped GraphQL schema to %s" % out)) def handle(self, *args, **options): options_schema = options.get("schema") @@ -63,13 +85,10 @@ class Command(CommandArguments): ) indent = options.get("indent") - schema_dict = {"data": schema.introspect()} - if out == '-': - self.stdout.write(json.dumps(schema_dict, indent=indent)) + watch = options.get("watch") + if watch: + autoreload.run_with_reloader( + functools.partial(self.get_schema, schema, out, indent) + ) else: - self.save_file(out, schema_dict, indent) - - style = getattr(self, "style", None) - success = getattr(style, "SUCCESS", lambda x: x) - - self.stdout.write(success("Successfully dumped GraphQL schema to %s" % out)) + self.get_schema(schema, out, indent) diff --git a/graphene_django/rest_framework/models.py b/graphene_django/rest_framework/models.py index 848837b..06d9b60 100644 --- a/graphene_django/rest_framework/models.py +++ b/graphene_django/rest_framework/models.py @@ -4,3 +4,8 @@ from django.db import models class MyFakeModel(models.Model): cool_name = models.CharField(max_length=50) created = models.DateTimeField(auto_now_add=True) + + +class MyFakeModelWithPassword(models.Model): + cool_name = models.CharField(max_length=50) + password = models.CharField(max_length=50) diff --git a/graphene_django/rest_framework/mutation.py b/graphene_django/rest_framework/mutation.py index 5e343aa..0fe9a02 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): @@ -27,6 +27,8 @@ def fields_for_serializer(serializer, only_fields, exclude_fields, is_input=Fals name in exclude_fields # or # name in already_created_fields + ) or ( + field.write_only and not is_input # don't show write_only fields in Query ) if is_not_in_only or is_excluded: @@ -138,6 +140,7 @@ class SerializerMutation(ClientIDMutation): kwargs = {} for f, field in serializer.fields.items(): - kwargs[f] = field.get_attribute(obj) + if not field.write_only: + kwargs[f] = field.get_attribute(obj) return cls(errors=None, **kwargs) diff --git a/graphene_django/rest_framework/tests/test_mutation.py b/graphene_django/rest_framework/tests/test_mutation.py index 4dccc18..9621ee3 100644 --- a/graphene_django/rest_framework/tests/test_mutation.py +++ b/graphene_django/rest_framework/tests/test_mutation.py @@ -7,7 +7,7 @@ from py.test import mark from rest_framework import serializers from ...types import DjangoObjectType -from ..models import MyFakeModel +from ..models import MyFakeModel, MyFakeModelWithPassword from ..mutation import SerializerMutation @@ -86,6 +86,51 @@ def test_exclude_fields(): assert "created" not in MyMutation.Input._meta.fields +@mark.django_db +def test_write_only_field(): + class WriteOnlyFieldModelSerializer(serializers.ModelSerializer): + password = serializers.CharField(write_only=True) + + class Meta: + model = MyFakeModelWithPassword + fields = ["cool_name", "password"] + + class MyMutation(SerializerMutation): + class Meta: + serializer_class = WriteOnlyFieldModelSerializer + + result = MyMutation.mutate_and_get_payload( + None, mock_info(), **{"cool_name": "New Narf", "password": "admin"} + ) + + assert hasattr(result, "cool_name") + assert not hasattr( + result, "password" + ), "'password' is write_only field and shouldn't be visible" + + +@mark.django_db +def test_write_only_field_using_extra_kwargs(): + class WriteOnlyFieldModelSerializer(serializers.ModelSerializer): + class Meta: + model = MyFakeModelWithPassword + fields = ["cool_name", "password"] + extra_kwargs = {"password": {"write_only": True}} + + class MyMutation(SerializerMutation): + class Meta: + serializer_class = WriteOnlyFieldModelSerializer + + result = MyMutation.mutate_and_get_payload( + None, mock_info(), **{"cool_name": "New Narf", "password": "admin"} + ) + + assert hasattr(result, "cool_name") + assert not hasattr( + result, "password" + ), "'password' is write_only field and shouldn't be visible" + + def test_nested_model(): class MyFakeModelGrapheneType(DjangoObjectType): class Meta: 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/models.py b/graphene_django/tests/models.py index 4fe546d..b4eb3ce 100644 --- a/graphene_django/tests/models.py +++ b/graphene_django/tests/models.py @@ -65,6 +65,11 @@ class Reporter(models.Model): self.__class__ = CNNReporter +class CNNReporterManager(models.Manager): + def get_queryset(self): + return super(CNNReporterManager, self).get_queryset().filter(reporter_type=2) + + class CNNReporter(Reporter): """ This class is a proxy model for Reporter, used for testing @@ -74,6 +79,8 @@ class CNNReporter(Reporter): class Meta: proxy = True + objects = CNNReporterManager() + class Article(models.Model): headline = models.CharField(max_length=100) 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 172946c..f4f48f9 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -1,19 +1,23 @@ +import base64 import datetime -import graphene import pytest from django.db import models -from django.db.models import Q from django.utils.functional import SimpleLazyObject -from graphene.relay import Node from py.test import raises -from ..compat import JSONField, MissingType -from ..fields import DjangoConnectionField -from ..settings import graphene_settings -from ..types import DjangoObjectType +from django.db.models import Q + +from graphql_relay import to_global_id +import graphene +from graphene.relay import Node + from ..utils import DJANGO_FILTER_INSTALLED -from .models import Article, CNNReporter, Film, FilmDetails, Reporter +from ..compat import MissingType, JSONField +from ..fields import DjangoConnectionField +from ..types import DjangoObjectType +from ..settings import graphene_settings +from .models import Article, CNNReporter, Reporter, Film, FilmDetails pytestmark = pytest.mark.django_db @@ -224,6 +228,62 @@ def test_should_node(): assert result.data == expected +def test_should_query_onetoone_fields(): + film = Film(id=1) + film_details = FilmDetails(id=1, film=film) + + class FilmNode(DjangoObjectType): + class Meta: + model = Film + interfaces = (Node,) + + class FilmDetailsNode(DjangoObjectType): + class Meta: + model = FilmDetails + interfaces = (Node,) + + class Query(graphene.ObjectType): + film = graphene.Field(FilmNode) + film_details = graphene.Field(FilmDetailsNode) + + def resolve_film(root, info): + return film + + def resolve_film_details(root, info): + return film_details + + query = """ + query FilmQuery { + filmDetails { + id + film { + id + } + } + film { + id + details { + id + } + } + } + """ + expected = { + "filmDetails": { + "id": "RmlsbURldGFpbHNOb2RlOjE=", + "film": {"id": "RmlsbU5vZGU6MQ=="}, + }, + "film": { + "id": "RmlsbU5vZGU6MQ==", + "details": {"id": "RmlsbURldGFpbHNOb2RlOjE="}, + }, + } + schema = graphene.Schema(query=Query) + result = schema.execute(query) + assert not result.errors + assert result.data == expected + + def test_should_query_connectionfields(): class ReporterType(DjangoObjectType): class Meta: @@ -1061,8 +1121,7 @@ def test_should_handle_inherited_choices(): def test_proxy_model_support(): """ - This test asserts that we can query for all Reporters, - even if some are of a proxy model type at runtime. + This test asserts that we can query for all Reporters and proxied Reporters. """ class ReporterType(DjangoObjectType): @@ -1071,11 +1130,17 @@ def test_proxy_model_support(): interfaces = (Node,) use_connection = True - reporter_1 = Reporter.objects.create( + class CNNReporterType(DjangoObjectType): + class Meta: + model = CNNReporter + interfaces = (Node,) + use_connection = True + + reporter = Reporter.objects.create( first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1 ) - reporter_2 = CNNReporter.objects.create( + cnn_reporter = CNNReporter.objects.create( first_name="Some", last_name="Guy", email="someguy@cnn.com", @@ -1085,6 +1150,7 @@ def test_proxy_model_support(): class Query(graphene.ObjectType): all_reporters = DjangoConnectionField(ReporterType) + cnn_reporters = DjangoConnectionField(CNNReporterType) schema = graphene.Schema(query=Query) query = """ @@ -1096,16 +1162,28 @@ def test_proxy_model_support(): } } } + cnnReporters { + edges { + node { + id + } + } + } } """ expected = { "allReporters": { "edges": [ - {"node": {"id": "UmVwb3J0ZXJUeXBlOjE="}}, - {"node": {"id": "UmVwb3J0ZXJUeXBlOjI="}}, + {"node": {"id": to_global_id("ReporterType", reporter.id)}}, + {"node": {"id": to_global_id("ReporterType", cnn_reporter.id)}}, ] - } + }, + "cnnReporters": { + "edges": [ + {"node": {"id": to_global_id("CNNReporterType", cnn_reporter.id)}} + ] + }, } result = schema.execute(query) @@ -1113,31 +1191,10 @@ def test_proxy_model_support(): assert result.data == expected -def test_proxy_model_fails(): - """ - This test asserts that if you try to query for a proxy model, - that query will fail with: - GraphQLError('Expected value of type "CNNReporterType" but got: - CNNReporter.',) - - This is because a proxy model has the identical model definition - to its superclass, and defines its behavior at runtime, rather than - at the database level. Currently, filtering objects of the proxy models' - type isn't supported. It would require a field on the model that would - represent the type, and it doesn't seem like there is a clear way to - enforce this pattern across all projects - """ - - class CNNReporterType(DjangoObjectType): - class Meta: - model = CNNReporter - interfaces = (Node,) - use_connection = True - +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", @@ -1146,13 +1203,22 @@ def test_proxy_model_fails(): 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(CNNReporterType) + all_reporters = DjangoConnectionField(ReporterType) schema = graphene.Schema(query=Query) query = """ - query ProxyModelQuery { - allReporters { + query ReporterPromiseConnectionQuery { + allReporters(first: 1) { edges { node { id @@ -1162,14 +1228,8 @@ def test_proxy_model_fails(): } """ - expected = { - "allReporters": { - "edges": [ - {"node": {"id": "UmVwb3J0ZXJUeXBlOjE="}}, - {"node": {"id": "UmVwb3J0ZXJUeXBlOjI="}}, - ] - } - } + expected = {"allReporters": {"edges": [{"node": {"id": "UmVwb3J0ZXJUeXBlOjI="}}]}} result = schema.execute(query) - assert result.errors + assert not result.errors + assert result.data == expected diff --git a/graphene_django/types.py b/graphene_django/types.py index aa8b5a3..a1e17b3 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -1,6 +1,9 @@ +import six 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 @@ -11,6 +14,10 @@ from .registry import Registry, get_global_registry from .utils import DJANGO_FILTER_INSTALLED, get_model_fields, is_valid_django_model +if six.PY3: + from typing import Type + + def construct_fields(model, registry, only_fields, exclude_fields): _model_fields = get_model_fields(model) @@ -38,6 +45,7 @@ class DjangoObjectTypeOptions(ObjectTypeOptions): connection = None # type: Type[Connection] filter_fields = () + filterset_class = None class DjangoObjectType(ObjectType): @@ -50,6 +58,7 @@ class DjangoObjectType(ObjectType): only_fields=(), exclude_fields=(), filter_fields=None, + filterset_class=None, connection=None, connection_class=None, use_connection=None, @@ -69,8 +78,16 @@ class DjangoObjectType(ObjectType): 'Registry, received "{}".' ).format(cls.__name__, registry) - if not DJANGO_FILTER_INSTALLED and filter_fields: - raise Exception("Can only set filter_fields if Django-Filter is installed") + if filter_fields and filterset_class: + raise Exception("Can't set both filter_fields and filterset_class") + + if not DJANGO_FILTER_INSTALLED and (filter_fields or filterset_class): + raise Exception( + ( + "Can only set filter_fields or filterset_class if " + "Django-Filter is installed" + ) + ) django_fields = yank_fields_from_attrs( construct_fields(model, registry, only_fields, exclude_fields), _as=Field @@ -101,6 +118,7 @@ class DjangoObjectType(ObjectType): _meta.model = model _meta.registry = registry _meta.filter_fields = filter_fields + _meta.filterset_class = filterset_class _meta.fields = django_fields _meta.connection = connection @@ -124,12 +142,26 @@ class DjangoObjectType(ObjectType): if not is_valid_django_model(type(root)): raise Exception(('Received incompatible instance "{}".').format(root)) - model = root._meta.model._meta.concrete_model + if cls._meta.model._meta.proxy: + model = root._meta.model + else: + 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..db3e9f4 --- /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() + + 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 93% rename from graphene_django/utils.py rename to graphene_django/utils/utils.py index 560f604..b8aaba0 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,7 +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' + # "rel" for FK and M2M relations and "related" for O2O Relations related = getattr(attr, "rel", None) or getattr(attr, "related", None) if isinstance(related, models.ManyToOneRel): yield (name, related) 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", diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..58f283a --- /dev/null +++ b/tox.ini @@ -0,0 +1,39 @@ +[tox] +envlist = + py{27,35,36,37}-django{111,20,21,22,master}, + black,flake8 + +[travis:env] +DJANGO = + 1.11: django111 + 2.0: django20 + 2.1: django21 + 2.2: django22 + master: djangomaster + +[testenv] +passenv = * +usedevelop = True +setenv = + DJANGO_SETTINGS_MODULE=django_test_settings +deps = + -e.[test] + psycopg2 + django111: Django>=1.11,<2.0 + django20: Django>=2.0,<2.1 + django21: Django>=2.1,<2.2 + django22: Django>=2.2,<3.0 + djangomaster: https://github.com/django/django/archive/master.zip +commands = {posargs:py.test --cov=graphene_django graphene_django examples} + +[testenv:black] +basepython = python3.7 +deps = black +commands = + black --exclude "/migrations/" graphene_django examples --check + +[testenv:flake8] +basepython = python3.7 +deps = flake8 +commands = + flake8 graphene_django examples