diff --git a/.github/stale.yml b/.github/stale.yml index dc90e5a..d066ca6 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -1,11 +1,15 @@ # Number of days of inactivity before an issue becomes stale -daysUntilStale: 60 +daysUntilStale: 120 # Number of days of inactivity before a stale issue is closed -daysUntilClose: 7 +daysUntilClose: 30 # Issues with these labels will never be considered stale exemptLabels: - pinned - security + - 🐛bug + - 📖 documentation + - help wanted + - ✨enhancement # 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 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..1cd1011 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,26 @@ +name: 🚀 Deploy to PyPI + +on: + push: + tags: + - 'v*' + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v1 + - name: Set up Python 3.8 + uses: actions/setup-python@v1 + with: + python-version: 3.8 + - name: Build wheel and source tarball + run: | + pip install wheel + python setup.py sdist bdist_wheel + - name: Publish a Python distribution to PyPI + uses: pypa/gh-action-pypi-publish@v1.1.0 + with: + user: __token__ + password: ${{ secrets.pypi_password }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..20cf7fb --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,22 @@ +name: Lint + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v1 + - name: Set up Python 3.8 + uses: actions/setup-python@v1 + with: + python-version: 3.8 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install tox + - name: Run lint 💅 + run: tox + env: + TOXENV: flake8 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..270b24e --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,28 @@ +name: Tests + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + max-parallel: 4 + matrix: + django: ["2.2", "3.0"] + python-version: ["3.6", "3.7", "3.8"] + + steps: + - uses: actions/checkout@v1 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install tox tox-gh-actions + - name: Test with tox + run: tox + env: + DJANGO: ${{ matrix.django }} + TOXENV: ${{ matrix.toxenv }} diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 871d4e3..0000000 --- a/.travis.yml +++ /dev/null @@ -1,65 +0,0 @@ -language: python -cache: pip -dist: xenial - -install: - - pip install tox tox-travis - -script: - - tox - -after_success: - - pip install coveralls - - coveralls - -matrix: - fast_finish: true - include: - - 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 - on: - tags: true - password: - secure: kymIFCEPUbkgRqe2NAXkWfxMmGRfWvWBOP6LIXdVdkOOkm91fU7bndPGrAjos+/7gN0Org609ZmHSlVXNMJUWcsL2or/x5LcADJ4cZDe+79qynuoRb9xs1Ri4O4SBAuVMZxuVJvs8oUzT2R11ql5vASSMtXgbX+ZDGpmPRVZStkCuXgOc4LBhbPKyl3OFy7UQFPgAEmy3Yjh4ZSKzlXheK+S6mmr60+DCIjpaA0BWPxYK9FUE0qm7JJbHLUbwsUP/QMp5MmGjwFisXCNsIe686B7QKRaiOw62eJc2R7He8AuEC8T9OM4kRwDlecSn8mMpkoSB7QWtlJ+6XdLrJFPNvtrOfgfzS9/96Qrw9WlOslk68hMlhJeRb0s2YUD8tiV3UUkvbL1mfFoS4SI9U+rojS55KhUEJWHg1w7DjoOPoZmaIL2ChRupmvrFYNAGae1cxwG3Urh+t3wYlN3gpKsRDe5GOT7Wm2tr0ad3McCpDGUwSChX59BAJXe/MoLxkKScTrMyR8yMxHOF0b4zpVn5l7xB/o2Ik4zavx5q/0rGBMK2D+5d+gpQogKShoquTPsZUwO7sB5hYeH2hqGqpeGzZtb76E2zZYd18pJ0FsBudm5+KWjYdZ+vbtGrLxdTXJ1EEtzVXm0lscykTpqUucbXSa51dhStJvW2xEEz6p3rHo= - distributions: "sdist bdist_wheel" diff --git a/README.md b/README.md index 33f71f3..2490209 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,24 @@ -Please read [UPGRADE-v2.0.md](https://github.com/graphql-python/graphene/blob/master/UPGRADE-v2.0.md) to learn how to upgrade to Graphene `2.0`. - ---- - -# ![Graphene Logo](http://graphene-python.org/favicon.png) Graphene-Django [![Build Status](https://travis-ci.org/graphql-python/graphene-django.svg?branch=master)](https://travis-ci.org/graphql-python/graphene-django) [![PyPI version](https://badge.fury.io/py/graphene-django.svg)](https://badge.fury.io/py/graphene-django) [![Coverage Status](https://coveralls.io/repos/graphql-python/graphene-django/badge.svg?branch=master&service=github)](https://coveralls.io/github/graphql-python/graphene-django?branch=master) +# ![Graphene Logo](http://graphene-python.org/favicon.png) Graphene-Django A [Django](https://www.djangoproject.com/) integration for [Graphene](http://graphene-python.org/). +[![travis][travis-image]][travis-url] +[![pypi][pypi-image]][pypi-url] +[![Anaconda-Server Badge][conda-image]][conda-url] +[![coveralls][coveralls-image]][coveralls-url] + +[travis-image]: https://travis-ci.org/graphql-python/graphene-django.svg?branch=master&style=flat +[travis-url]: https://travis-ci.org/graphql-python/graphene-django +[pypi-image]: https://img.shields.io/pypi/v/graphene-django.svg?style=flat +[pypi-url]: https://pypi.org/project/graphene-django/ +[coveralls-image]: https://coveralls.io/repos/github/graphql-python/graphene-django/badge.svg?branch=master +[coveralls-url]: https://coveralls.io/github/graphql-python/graphene-django?branch=master +[conda-image]: https://img.shields.io/conda/vn/conda-forge/graphene-django.svg +[conda-url]: https://anaconda.org/conda-forge/graphene-django + +[💬 Join the community on Slack](https://join.slack.com/t/graphenetools/shared_invite/enQtOTE2MDQ1NTg4MDM1LTA4Nzk0MGU0NGEwNzUxZGNjNDQ4ZjAwNDJjMjY0OGE1ZDgxZTg4YjM2ZTc4MjE2ZTAzZjE2ZThhZTQzZTkyMmM) + ## Documentation [Visit the documentation to get started!](https://docs.graphene-python.org/projects/django/en/latest/) @@ -16,7 +28,7 @@ A [Django](https://www.djangoproject.com/) integration for [Graphene](http://gra For installing graphene, just run this command in your shell ```bash -pip install "graphene-django>=2.0" +pip install "graphene-django>=3" ``` ### Settings @@ -78,7 +90,7 @@ class Query(graphene.ObjectType): schema = graphene.Schema(query=Query) ``` -Then you can simply query the schema: +Then you can query the schema: ```python query = ''' @@ -101,3 +113,7 @@ To learn more check out the following [examples](examples/): ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) + +## Release Notes + +* See [Releases page on github](https://github.com/graphql-python/graphene-django/releases) diff --git a/README.rst b/README.rst index 44feaee..4ac7dda 100644 --- a/README.rst +++ b/README.rst @@ -23,7 +23,7 @@ For installing graphene, just run this command in your shell .. code:: bash - pip install "graphene-django>=2.0" + pip install "graphene-django>=3" Settings ~~~~~~~~ diff --git a/docs/authorization.rst b/docs/authorization.rst index ebc9795..8ef05b4 100644 --- a/docs/authorization.rst +++ b/docs/authorization.rst @@ -166,16 +166,7 @@ To restrict users from accessing the GraphQL API page the standard Django LoginR After this, you can use the new ``PrivateGraphQLView`` in the project's URL Configuration file ``url.py``: -For Django 1.9 and below: - -.. code:: python - - urlpatterns = [ - # some other urls - url(r'^graphql$', PrivateGraphQLView.as_view(graphiql=True, schema=schema)), - ] - -For Django 2.0 and above: +For Django 2.2 and above: .. code:: python @@ -184,4 +175,4 @@ For Django 2.0 and above: path('graphql', PrivateGraphQLView.as_view(graphiql=True, schema=schema)), ] -.. _LoginRequiredMixin: https://docs.djangoproject.com/en/1.10/topics/auth/default/#the-loginrequired-mixin +.. _LoginRequiredMixin: https://docs.djangoproject.com/en/dev/topics/auth/default/#the-loginrequired-mixin diff --git a/docs/conf.py b/docs/conf.py index a485d5b..b83e0f0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -60,18 +60,18 @@ source_suffix = ".rst" master_doc = "index" # General information about the project. -project = u"Graphene Django" -copyright = u"Graphene 2017" -author = u"Syrus Akbary" +project = "Graphene Django" +copyright = "Graphene 2017" +author = "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 = "1.0" # The full version, including alpha/beta/rc tags. -release = u"1.0.dev" +release = "1.0.dev" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -276,7 +276,7 @@ latex_elements = { # (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", "Graphene Documentation", "Syrus Akbary", "manual") ] # The name of an image file (relative to this directory) to place at the top of @@ -317,7 +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", "Graphene Django Documentation", [author], 1) ] # If true, show URL addresses after external links. @@ -334,7 +334,7 @@ texinfo_documents = [ ( master_doc, "Graphene-Django", - u"Graphene Django Documentation", + "Graphene Django Documentation", author, "Graphene Django", "One line description of project.", diff --git a/docs/extra-types.rst b/docs/extra-types.rst new file mode 100644 index 0000000..22927d6 --- /dev/null +++ b/docs/extra-types.rst @@ -0,0 +1,12 @@ +Extra Types +=========== + +Here are some libraries that provide common types for Django specific fields. + + +GeoDjango +--------- + +Use the graphene-gis_ library to add GeoDjango types to your Schema. + +.. _graphene-gis: https://github.com/EverWinter23/graphene-gis diff --git a/docs/fields.rst b/docs/fields.rst new file mode 100644 index 0000000..1a8afc3 --- /dev/null +++ b/docs/fields.rst @@ -0,0 +1,83 @@ +Fields +====== + +Graphene-Django provides some useful fields to help integrate Django with your GraphQL +Schema. + +DjangoListField +--------------- + +``DjangoListField`` allows you to define a list of :ref:`DjangoObjectType`'s. By default it will resolve the default queryset of the Django model. + +.. code:: python + + from graphene import ObjectType, Schema + from graphene_django import DjangoListField + + class RecipeType(DjangoObjectType): + class Meta: + model = Recipe + fields = ("title", "instructions") + + class Query(ObjectType): + recipes = DjangoListField(RecipeType) + + schema = Schema(query=Query) + +The above code results in the following schema definition: + +.. code:: + + schema { + query: Query + } + + type Query { + recipes: [RecipeType!] + } + + type RecipeType { + title: String! + instructions: String! + } + +Custom resolvers +**************** + +If your ``DjangoObjectType`` has defined a custom +:ref:`get_queryset` method, when resolving a +``DjangoListField`` it will be called with either the return of the field +resolver (if one is defined) or the default queryeset from the Django model. + +For example the following schema will only resolve recipes which have been +published and have a title: + +.. code:: python + + from graphene import ObjectType, Schema + from graphene_django import DjangoListField + + class RecipeType(DjangoObjectType): + class Meta: + model = Recipe + fields = ("title", "instructions") + + @classmethod + def get_queryset(cls, queryset, info): + # Filter out recipes that have no title + return queryset.exclude(title__exact="") + + class Query(ObjectType): + recipes = DjangoListField(RecipeType) + + def resolve_recipes(parent, info): + # Only get recipes that have been published + return Recipe.objects.filter(published=True) + + schema = Schema(query=Query) + + +DjangoConnectionField +--------------------- + +*TODO* diff --git a/docs/filtering.rst b/docs/filtering.rst index 6fe7cab..a511c64 100644 --- a/docs/filtering.rst +++ b/docs/filtering.rst @@ -2,9 +2,8 @@ Filtering ========= Graphene integrates with -`django-filter `__ (2.x for -Python 3 or 1.x for Python 2) to provide filtering of results. See the `usage -documentation `__ +`django-filter `__ to provide filtering of results. +See the `usage documentation `__ for details on the format for ``filter_fields``. This filtering is automatically available when implementing a ``relay.Node``. @@ -14,8 +13,17 @@ You will need to install it manually, which can be done as follows: .. code:: bash - # You'll need to django-filter + # You'll need to install django-filter pip install django-filter>=2 + +After installing ``django-filter`` you'll need to add the application in the ``settings.py`` file: + +.. code:: python + + INSTALLED_APPS = [ + # ... + "django_filters", + ] Note: The techniques below are demoed in the `cookbook example app `__. @@ -115,6 +123,15 @@ create your own ``FilterSet``. You can pass it directly as follows: class AnimalFilter(django_filters.FilterSet): # Do case-insensitive lookups on 'name' name = django_filters.CharFilter(lookup_expr=['iexact']) + # Allow multiple genera to be selected at once + genera = django_filters.MultipleChoiceFilter( + field_name='genus', + choices=( + ('Canis', 'Canis'), + ('Panthera', 'Panthera'), + ('Seahorse', 'Seahorse') + ) + ) class Meta: model = Animal @@ -127,6 +144,22 @@ create your own ``FilterSet``. You can pass it directly as follows: all_animals = DjangoFilterConnectionField(AnimalNode, filterset_class=AnimalFilter) + +If you were interested in selecting all dogs and cats, you might query as follows: + +.. code:: + + query { + allAnimals(genera: ["Canis", "Panthera"]) { + edges { + node { + id, + name + } + } + } + } + You can also specify the ``FilterSet`` class using the ``filterset_class`` parameter when defining your ``DjangoObjectType``, however, this can't be used in unison with the ``filter_fields`` parameter: @@ -154,6 +187,7 @@ in unison with the ``filter_fields`` parameter: 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 diff --git a/docs/index.rst b/docs/index.rst index 602f8dd..f4f718c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -25,6 +25,8 @@ For more advanced use, check out the Relay tutorial. tutorial-relay schema queries + fields + extra-types mutations filtering authorization diff --git a/docs/installation.rst b/docs/installation.rst index a2dc665..573032e 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -8,7 +8,7 @@ Requirements Graphene-Django currently supports the following versions of Django: -* Django 2.X +* >= Django 2.2 Installation ------------ @@ -32,6 +32,8 @@ Add ``graphene_django`` to the ``INSTALLED_APPS`` in the ``settings.py`` file of We need to add a ``graphql`` URL to the ``urls.py`` of your Django project: +For Django 2.2 and above: + .. code:: python from django.urls import path @@ -66,4 +68,26 @@ The most basic ``schema.py`` looks like this: 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 +To learn how to extend the schema object for your project, read the basic tutorial. + +CSRF exempt +----------- + +If have enabled `CSRF protection `_ in your Django app +you will find that it prevents your API clients from POSTing to the ``graphql`` endpoint. You can either +update your API client to pass the CSRF token with each request (the Django docs have a guide on how to do that: https://docs.djangoproject.com/en/3.0/ref/csrf/#ajax) or you can exempt your Graphql endpoint from CSRF protection by wrapping the ``GraphQLView`` with the ``csrf_exempt`` +decorator: + +.. code:: python + + # urls.py + + from django.urls import path + from django.views.decorators.csrf import csrf_exempt + + from graphene_django.views import GraphQLView + + urlpatterns = [ + # ... + path("graphql", csrf_exempt(GraphQLView.as_view(graphiql=True))), + ] diff --git a/docs/introspection.rst b/docs/introspection.rst index c1d6ede..2097c30 100644 --- a/docs/introspection.rst +++ b/docs/introspection.rst @@ -29,6 +29,20 @@ you're ready to use Relay with Graphene GraphQL implementation. The schema file is sorted to create a reproducible canonical representation. +GraphQL SDL Representation +-------------------------- + +The schema can also be exported as a GraphQL SDL file by changing the file +extension : + +.. code:: bash + + ./manage.py graphql_schema --schema tutorial.quickstart.schema --out schema.graphql + +When exporting the schema as a ``.graphql`` file the ``--indent`` option is +ignored. + + Advanced Usage -------------- @@ -44,7 +58,8 @@ specify the parameters in your settings.py: GRAPHENE = { 'SCHEMA': 'tutorial.quickstart.schema', - 'SCHEMA_OUTPUT': 'data/schema.json' # defaults to schema.json + 'SCHEMA_OUTPUT': 'data/schema.json', # defaults to schema.json, + 'SCHEMA_INDENT': 2, # Defaults to None (displays all data on a single line) } diff --git a/docs/queries.rst b/docs/queries.rst index 67ebb06..4b3f718 100644 --- a/docs/queries.rst +++ b/docs/queries.rst @@ -1,3 +1,5 @@ +.. _queries-objecttypes: + Queries & ObjectTypes ===================== @@ -205,6 +207,8 @@ need to create the most basic class for this to work: class Meta: model = Category +.. _django-objecttype-get-queryset: + Default QuerySet ----------------- @@ -282,6 +286,13 @@ of Django's ``HTTPRequest`` in your resolve methods, such as checking for authen return Question.objects.none() +DjangoObjectTypes +~~~~~~~~~~~~~~~~~ + +A Resolver that maps to a defined `DjangoObjectType` should only use methods that return a queryset. +Queryset methods like `values` will return dictionaries, use `defer` instead. + + Plain ObjectTypes ----------------- diff --git a/docs/schema.py b/docs/schema.py new file mode 100644 index 0000000..3d9b2fa --- /dev/null +++ b/docs/schema.py @@ -0,0 +1,58 @@ + import graphene + + from graphene_django.types import DjangoObjectType + + from cookbook.ingredients.models import Category, Ingredient + + + class CategoryType(DjangoObjectType): + class Meta: + model = Category + + + class IngredientType(DjangoObjectType): + class Meta: + model = Ingredient + + + class Query(object): + category = graphene.Field(CategoryType, + id=graphene.Int(), + name=graphene.String()) + all_categories = graphene.List(CategoryType) + + + ingredient = graphene.Field(IngredientType, + id=graphene.Int(), + name=graphene.String()) + all_ingredients = graphene.List(IngredientType) + + def resolve_all_categories(self, info, **kwargs): + return Category.objects.all() + + def resolve_all_ingredients(self, info, **kwargs): + return Ingredient.objects.all() + + def resolve_category(self, info, **kwargs): + id = kwargs.get('id') + name = kwargs.get('name') + + if id is not None: + return Category.objects.get(pk=id) + + if name is not None: + return Category.objects.get(name=name) + + return None + + def resolve_ingredient(self, info, **kwargs): + id = kwargs.get('id') + name = kwargs.get('name') + + if id is not None: + return Ingredient.objects.get(pk=id) + + if name is not None: + return Ingredient.objects.get(name=name) + + return None \ No newline at end of file diff --git a/docs/settings.rst b/docs/settings.rst index 4776ce0..5a7e4c9 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -140,3 +140,33 @@ Default: ``False`` # 'messages': ['This field is required.'], # } # ] + + +``DJANGO_CHOICE_FIELD_ENUM_V3_NAMING`` +-------------------------------------- + +Set to ``True`` to use the new naming format for the auto generated Enum types from Django choice fields. The new format looks like this: ``{app_label}{object_name}{field_name}Choices`` + +Default: ``False`` + + +``DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME`` +-------------------------------------- + +Define the path of a function that takes the Django choice field and returns a string to completely customise the naming for the Enum type. + +If set to a function then the ``DJANGO_CHOICE_FIELD_ENUM_V3_NAMING`` setting is ignored. + +Default: ``None`` + +.. code:: python + + # myapp.utils + def enum_naming(field): + if isinstance(field.model, User): + return f"CustomUserEnum{field.name.title()}" + return f"CustomEnum{field.name.title()}" + + GRAPHENE = { + 'DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME': "myapp.utils.enum_naming" + } diff --git a/docs/testing.rst b/docs/testing.rst index 031cf6b..473a9ba 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -3,6 +3,8 @@ Testing API calls with django If you want to unittest your API calls derive your test case from the class `GraphQLTestCase`. +Your endpoint is set through the `GRAPHQL_URL` attribute on `GraphQLTestCase`. The default endpoint is `GRAPHQL_URL = "/graphql/"`. + Usage: .. code:: python diff --git a/docs/tutorial-plain.rst b/docs/tutorial-plain.rst index 29df56e..e80f9ab 100644 --- a/docs/tutorial-plain.rst +++ b/docs/tutorial-plain.rst @@ -286,7 +286,7 @@ from the command line. $ python ./manage.py runserver Performing system checks... - Django version 1.9, using settings 'cookbook.settings' + Django version 1.11, using settings 'cookbook.settings' Starting development server at http://127.0.0.1:8000/ Quit the server with CONTROL-C. @@ -417,67 +417,10 @@ Getting single objects So far, we have been able to fetch list of objects and follow relation. But what about single objects? We can update our schema to support that, by adding new query for ``ingredient`` and ``category`` and adding arguments, so we can query for specific objects. +Add the **Highlighted** lines to ``cookbook/ingredients/schema.py`` -.. code:: python - - import graphene - - from graphene_django.types import DjangoObjectType - - from cookbook.ingredients.models import Category, Ingredient - - - class CategoryType(DjangoObjectType): - class Meta: - model = Category - - - class IngredientType(DjangoObjectType): - class Meta: - model = Ingredient - - - class Query(object): - category = graphene.Field(CategoryType, - id=graphene.Int(), - name=graphene.String()) - all_categories = graphene.List(CategoryType) - - - ingredient = graphene.Field(IngredientType, - id=graphene.Int(), - name=graphene.String()) - all_ingredients = graphene.List(IngredientType) - - def resolve_all_categories(self, info, **kwargs): - return Category.objects.all() - - def resolve_all_ingredients(self, info, **kwargs): - return Ingredient.objects.all() - - def resolve_category(self, info, **kwargs): - id = kwargs.get('id') - name = kwargs.get('name') - - if id is not None: - return Category.objects.get(pk=id) - - if name is not None: - return Category.objects.get(name=name) - - return None - - def resolve_ingredient(self, info, **kwargs): - id = kwargs.get('id') - name = kwargs.get('name') - - if id is not None: - return Ingredient.objects.get(pk=id) - - if name is not None: - return Ingredient.objects.get(name=name) - - return None +.. literalinclude:: schema.py + :emphasize-lines: 19-21,25-27,36-58 Now, with the code in place, we can query for single objects. diff --git a/docs/tutorial-relay.rst b/docs/tutorial-relay.rst index 5f8bd64..e900ea1 100644 --- a/docs/tutorial-relay.rst +++ b/docs/tutorial-relay.rst @@ -277,7 +277,7 @@ from the command line. $ python ./manage.py runserver Performing system checks... - Django version 1.9, using settings 'cookbook.settings' + Django version 1.11, using settings 'cookbook.settings' Starting development server at http://127.0.0.1:8000/ Quit the server with CONTROL-C. diff --git a/examples/cookbook-plain/requirements.txt b/examples/cookbook-plain/requirements.txt index 802aa37..480f757 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==2.2.4 +graphene>=2.1,<3 +graphene-django>=2.1,<3 +graphql-core>=2.1,<3 +django==3.0.3 diff --git a/examples/cookbook/cookbook/settings.py b/examples/cookbook/cookbook/settings.py index 7eb9d56..3e941b8 100644 --- a/examples/cookbook/cookbook/settings.py +++ b/examples/cookbook/cookbook/settings.py @@ -41,6 +41,7 @@ INSTALLED_APPS = [ "graphene_django", "cookbook.ingredients.apps.IngredientsConfig", "cookbook.recipes.apps.RecipesConfig", + "django_filters", ] MIDDLEWARE = [ diff --git a/examples/cookbook/requirements.txt b/examples/cookbook/requirements.txt index 0537103..4375fcc 100644 --- a/examples/cookbook/requirements.txt +++ b/examples/cookbook/requirements.txt @@ -1,5 +1,5 @@ -graphene -graphene-django -graphql-core>=2.1rc1 -django==2.2.4 +graphene>=2.1,<3 +graphene-django>=2.1,<3 +graphql-core>=2.1,<3 +django==3.0.3 django-filter>=2 diff --git a/graphene_django/__init__.py b/graphene_django/__init__.py index 7650dd2..eeafafa 100644 --- a/graphene_django/__init__.py +++ b/graphene_django/__init__.py @@ -1,6 +1,11 @@ +from .fields import DjangoConnectionField, DjangoListField from .types import DjangoObjectType -from .fields import DjangoConnectionField -__version__ = "2.6.0" +__version__ = "3.0.0b1" -__all__ = ["__version__", "DjangoObjectType", "DjangoConnectionField"] +__all__ = [ + "__version__", + "DjangoObjectType", + "DjangoListField", + "DjangoConnectionField", +] diff --git a/graphene_django/conftest.py b/graphene_django/conftest.py new file mode 100644 index 0000000..509a84c --- /dev/null +++ b/graphene_django/conftest.py @@ -0,0 +1,18 @@ +import pytest + +from graphene_django.settings import graphene_settings as gsettings + +from .registry import reset_global_registry + + +@pytest.fixture(autouse=True) +def reset_registry_fixture(db): + yield None + reset_global_registry() + + +@pytest.fixture() +def graphene_settings(): + settings = dict(gsettings.__dict__) + yield gsettings + gsettings.__dict__ = settings diff --git a/graphene_django/converter.py b/graphene_django/converter.py index b59c906..b6106bd 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -1,10 +1,16 @@ from collections import OrderedDict -from django.db import models -from django.utils.encoding import force_text +from functools import singledispatch +from django.db import models +from django.utils.encoding import force_str +from django.utils.functional import Promise +from django.utils.module_loading import import_string from graphene import ( ID, + UUID, Boolean, + Date, + DateTime, Dynamic, Enum, Field, @@ -13,27 +19,23 @@ from graphene import ( List, NonNull, String, - UUID, - DateTime, - Date, Time, ) from graphene.types.json import JSONString from graphene.utils.str_converters import to_camel_case, to_const -from graphql import assert_valid_name +from graphql import GraphQLError, assert_valid_name +from graphql.pyutils import register_description from .compat import ArrayField, HStoreField, JSONField, RangeField -from .fields import DjangoListField, DjangoConnectionField -from .utils import import_single_dispatch - -singledispatch = import_single_dispatch() +from .fields import DjangoConnectionField, DjangoListField +from .settings import graphene_settings def convert_choice_name(name): - name = to_const(force_text(name)) + name = to_const(force_str(name)) try: assert_valid_name(name) - except AssertionError: + except GraphQLError: name = "A_%s" % name return name @@ -51,7 +53,9 @@ def get_choices(choices): while name in converted_names: name += "_" + str(len(converted_names)) converted_names.append(name) - description = help_text + description = str( + help_text + ) # TODO: translatable description: https://github.com/graphql-python/graphql-core-next/issues/58 yield name, value, description @@ -63,11 +67,36 @@ def convert_choices_to_named_enum_with_descriptions(name, choices): class EnumWithDescriptionsType(object): @property def description(self): - return named_choices_descriptions[self.name] + return str(named_choices_descriptions[self.name]) return Enum(name, list(named_choices), type=EnumWithDescriptionsType) +def generate_enum_name(django_model_meta, field): + if graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME: + # Try and import custom function + custom_func = import_string( + graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME + ) + name = custom_func(field) + elif graphene_settings.DJANGO_CHOICE_FIELD_ENUM_V3_NAMING is True: + name = "{app_label}{object_name}{field_name}Choices".format( + app_label=to_camel_case(django_model_meta.app_label.title()), + object_name=django_model_meta.object_name, + field_name=to_camel_case(field.name.title()), + ) + else: + name = to_camel_case("{}_{}".format(django_model_meta.object_name, field.name)) + return name + + +def convert_choice_field_to_enum(field, name=None): + if name is None: + name = generate_enum_name(field.model._meta, field) + choices = field.choices + return convert_choices_to_named_enum_with_descriptions(name, choices) + + def convert_django_field_with_choices( field, registry=None, convert_choices_to_enum=True ): @@ -77,9 +106,7 @@ def convert_django_field_with_choices( return converted choices = getattr(field, "choices", None) if choices and convert_choices_to_enum: - meta = field.model._meta - name = to_camel_case("{}_{}".format(meta.object_name, field.name)) - enum = convert_choices_to_named_enum_with_descriptions(name, choices) + enum = convert_choice_field_to_enum(field) required = not (field.blank or field.null) converted = enum(description=field.help_text, required=required) else: @@ -127,13 +154,9 @@ def convert_field_to_int(field, registry=None): return Int(description=field.help_text, required=not field.null) +@convert_django_field.register(models.NullBooleanField) @convert_django_field.register(models.BooleanField) def convert_field_to_boolean(field, registry=None): - return NonNull(Boolean, description=field.help_text) - - -@convert_django_field.register(models.NullBooleanField) -def convert_field_to_nullboolean(field, registry=None): return Boolean(description=field.help_text, required=not field.null) @@ -252,3 +275,8 @@ def convert_postgres_range_to_string(field, registry=None): if not isinstance(inner_type, (List, NonNull)): inner_type = type(inner_type) return List(inner_type, description=field.help_text, required=not field.null) + + +# Register Django lazy()-wrapped values as GraphQL description/help_text. +# This is needed for using lazy translations, see https://github.com/graphql-python/graphql-core-next/issues/58. +register_description(Promise) diff --git a/graphene_django/debug/middleware.py b/graphene_django/debug/middleware.py index 0fe3fe3..8621b55 100644 --- a/graphene_django/debug/middleware.py +++ b/graphene_django/debug/middleware.py @@ -17,7 +17,7 @@ class DjangoDebugContext(object): if not self.debug_promise: self.debug_promise = Promise.all(self.promises) self.promises = [] - return self.debug_promise.then(self.on_resolve_all_promises) + return self.debug_promise.then(self.on_resolve_all_promises).get() def on_resolve_all_promises(self, values): if self.promises: diff --git a/graphene_django/debug/sql/tracking.py b/graphene_django/debug/sql/tracking.py index f96583b..aacd1a0 100644 --- a/graphene_django/debug/sql/tracking.py +++ b/graphene_django/debug/sql/tracking.py @@ -5,8 +5,7 @@ import json from threading import local from time import time -from django.utils import six -from django.utils.encoding import force_text +from django.utils.encoding import force_str from .types import DjangoDebugSQL @@ -77,8 +76,8 @@ class NormalCursorWrapper(object): self.logger = logger def _quote_expr(self, element): - if isinstance(element, six.string_types): - return "'%s'" % force_text(element).replace("'", "''") + if isinstance(element, str): + return "'%s'" % force_str(element).replace("'", "''") else: return repr(element) @@ -91,7 +90,7 @@ class NormalCursorWrapper(object): def _decode(self, param): try: - return force_text(param, strings_only=True) + return force_str(param, strings_only=True) except UnicodeDecodeError: return "(encoded string)" diff --git a/graphene_django/debug/tests/test_query.py b/graphene_django/debug/tests/test_query.py index db8f275..7255ec6 100644 --- a/graphene_django/debug/tests/test_query.py +++ b/graphene_django/debug/tests/test_query.py @@ -1,5 +1,3 @@ -import pytest - import graphene from graphene.relay import Node from graphene_django import DjangoConnectionField, DjangoObjectType @@ -13,11 +11,6 @@ class context(object): pass -# from examples.starwars_django.models import Character - -pytestmark = pytest.mark.django_db - - def test_should_query_field(): r1 = Reporter(last_name="ABA") r1.save() @@ -75,7 +68,7 @@ def test_should_query_nested_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() @@ -89,7 +82,7 @@ def test_should_query_nested_field(): pets { edges { node { lastName } } } } } } } - __debug { + _debug { sql { rawSql } @@ -117,12 +110,12 @@ def test_should_query_nested_field(): ) assert not result.errors query = str(Reporter.objects.order_by("pk")[:1].query) - assert result.data["__debug"]["sql"][0]["rawSql"] == query - assert "COUNT" in result.data["__debug"]["sql"][1]["rawSql"] - assert "tests_reporter_pets" in result.data["__debug"]["sql"][2]["rawSql"] - assert "COUNT" in result.data["__debug"]["sql"][3]["rawSql"] - assert "tests_reporter_pets" in result.data["__debug"]["sql"][4]["rawSql"] - assert len(result.data["__debug"]["sql"]) == 5 + assert result.data["_debug"]["sql"][0]["rawSql"] == query + assert "COUNT" in result.data["_debug"]["sql"][1]["rawSql"] + assert "tests_reporter_pets" in result.data["_debug"]["sql"][2]["rawSql"] + assert "COUNT" in result.data["_debug"]["sql"][3]["rawSql"] + assert "tests_reporter_pets" in result.data["_debug"]["sql"][4]["rawSql"] + assert len(result.data["_debug"]["sql"]) == 5 assert result.data["reporter"] == expected["reporter"] diff --git a/graphene_django/fields.py b/graphene_django/fields.py index e6daa88..7ad2040 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -1,11 +1,12 @@ from functools import partial from django.db.models.query import QuerySet -from graphql_relay.connection.arrayconnection import connection_from_list_slice +from graphql_relay.connection.arrayconnection import connection_from_array_slice from promise import Promise from graphene import NonNull -from graphene.relay import ConnectionField, PageInfo +from graphene.relay import ConnectionField +from graphene.relay.connection import connection_adapter, page_info_adapter from graphene.types import Field, List from .settings import graphene_settings @@ -19,30 +20,39 @@ class DjangoListField(Field): if isinstance(_type, NonNull): _type = _type.of_type - assert issubclass( - _type, DjangoObjectType - ), "DjangoListField only accepts DjangoObjectType types" - # Django would never return a Set of None vvvvvvv super(DjangoListField, self).__init__(List(NonNull(_type)), *args, **kwargs) + assert issubclass( + self._underlying_type, DjangoObjectType + ), "DjangoListField only accepts DjangoObjectType types" + + @property + def _underlying_type(self): + _type = self._type + while hasattr(_type, "of_type"): + _type = _type.of_type + return _type + @property def model(self): - _type = self.type.of_type - if isinstance(_type, NonNull): - _type = _type.of_type - return _type._meta.model + return self._underlying_type._meta.model + + def get_default_queryset(self): + return self.model._default_manager.get_queryset() @staticmethod - def list_resolver(django_object_type, resolver, root, info, **args): + def list_resolver( + django_object_type, resolver, default_queryset, root, info, **args + ): queryset = maybe_queryset(resolver(root, info, **args)) if queryset is None: - # Default to Django Model queryset - # N.B. This happens if DjangoListField is used in the top level Query object - model = django_object_type._meta.model - queryset = maybe_queryset( - django_object_type.get_queryset(model.objects, info) - ) + queryset = default_queryset + + if isinstance(queryset, QuerySet): + # Pass queryset to the DjangoObjectType get_queryset method + queryset = maybe_queryset(django_object_type.get_queryset(queryset, info)) + return queryset def get_resolver(self, parent_resolver): @@ -50,7 +60,12 @@ class DjangoListField(Field): if isinstance(_type, NonNull): _type = _type.of_type django_object_type = _type.of_type.of_type - return partial(self.list_resolver, django_object_type, parent_resolver) + return partial( + self.list_resolver, + django_object_type, + parent_resolver, + self.get_default_queryset(), + ) class DjangoConnectionField(ConnectionField): @@ -108,37 +123,25 @@ class DjangoConnectionField(ConnectionField): @classmethod def resolve_queryset(cls, connection, queryset, info, args): + # queryset is the resolved iterable from ObjectType 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: - queryset = queryset.distinct() - elif queryset.query.distinct and not default_queryset.query.distinct: - default_queryset = default_queryset.distinct() - return queryset & default_queryset - - @classmethod - def resolve_connection(cls, connection, default_manager, args, iterable): - if iterable is None: - iterable = default_manager + def resolve_connection(cls, connection, args, iterable): iterable = maybe_queryset(iterable) if isinstance(iterable, QuerySet): - if iterable.model.objects is not default_manager: - default_queryset = maybe_queryset(default_manager) - iterable = cls.merge_querysets(default_queryset, iterable) _len = iterable.count() else: _len = len(iterable) - connection = connection_from_list_slice( + connection = connection_from_array_slice( iterable, args, slice_start=0, - list_length=_len, - list_slice_length=_len, - connection_type=connection, + array_length=_len, + array_slice_length=_len, + connection_type=partial(connection_adapter, connection), edge_type=connection.Edge, - pageinfo_type=PageInfo, + page_info_type=page_info_adapter, ) connection.iterable = iterable connection.length = _len @@ -150,6 +153,7 @@ class DjangoConnectionField(ConnectionField): resolver, connection, default_manager, + queryset_resolver, max_limit, enforce_first_or_last, root, @@ -177,9 +181,15 @@ class DjangoConnectionField(ConnectionField): ).format(last, info.field_name, max_limit) args["last"] = min(last, max_limit) + # eventually leads to DjangoObjectType's get_queryset (accepts queryset) + # or a resolve_foo (does not accept queryset) iterable = resolver(root, info, **args) - queryset = cls.resolve_queryset(connection, default_manager, info, args) - on_resolve = partial(cls.resolve_connection, connection, queryset, args) + if iterable is None: + iterable = default_manager + # thus the iterable gets refiltered by resolve_queryset + # but iterable might be promise + iterable = queryset_resolver(connection, iterable, info, args) + on_resolve = partial(cls.resolve_connection, connection, args) if Promise.is_thenable(iterable): return Promise.resolve(iterable).then(on_resolve) @@ -192,6 +202,10 @@ class DjangoConnectionField(ConnectionField): parent_resolver, self.connection_type, self.get_manager(), + self.get_queryset_resolver(), self.max_limit, self.enforce_first_or_last, ) + + def get_queryset_resolver(self): + return self.resolve_queryset diff --git a/graphene_django/filter/fields.py b/graphene_django/filter/fields.py index 338becb..a46a4b7 100644 --- a/graphene_django/filter/fields.py +++ b/graphene_django/filter/fields.py @@ -52,69 +52,18 @@ class DjangoFilterConnectionField(DjangoConnectionField): return get_filtering_args_from_filterset(self.filterset_class, self.node_type) @classmethod - def merge_querysets(cls, default_queryset, queryset): - # There could be the case where the default queryset (returned from the filterclass) - # and the resolver queryset have some limits on it. - # We only would be able to apply one of those, but not both - # at the same time. - - # See related PR: https://github.com/graphql-python/graphene-django/pull/126 - - assert not ( - default_queryset.query.low_mark and queryset.query.low_mark - ), "Received two sliced querysets (low mark) in the connection, please slice only in one." - assert not ( - default_queryset.query.high_mark and queryset.query.high_mark - ), "Received two sliced querysets (high mark) in the connection, please slice only in one." - low = default_queryset.query.low_mark or queryset.query.low_mark - high = default_queryset.query.high_mark or queryset.query.high_mark - default_queryset.query.clear_limits() - queryset = super(DjangoFilterConnectionField, cls).merge_querysets( - default_queryset, queryset - ) - queryset.query.set_limits(low, high) - return queryset - - @classmethod - def connection_resolver( - cls, - resolver, - connection, - default_manager, - max_limit, - enforce_first_or_last, - filterset_class, - filtering_args, - root, - info, - **args + def resolve_queryset( + cls, connection, iterable, info, args, filtering_args, filterset_class ): + qs = super(DjangoFilterConnectionField, cls).resolve_queryset( + connection, iterable, info, args + ) filter_kwargs = {k: v for k, v in args.items() if k in filtering_args} - qs = filterset_class( - data=filter_kwargs, - queryset=default_manager.get_queryset(), - request=info.context, - ).qs + return filterset_class(data=filter_kwargs, queryset=qs, request=info.context).qs - return super(DjangoFilterConnectionField, cls).connection_resolver( - resolver, - connection, - qs, - max_limit, - enforce_first_or_last, - root, - info, - **args - ) - - def get_resolver(self, parent_resolver): + def get_queryset_resolver(self): return partial( - self.connection_resolver, - parent_resolver, - self.connection_type, - self.get_manager(), - self.max_limit, - self.enforce_first_or_last, - self.filterset_class, - self.filtering_args, + self.resolve_queryset, + filterset_class=self.filterset_class, + filtering_args=self.filtering_args, ) diff --git a/graphene_django/filter/filterset.py b/graphene_django/filter/filterset.py index 7676ea8..34108ae 100644 --- a/graphene_django/filter/filterset.py +++ b/graphene_django/filter/filterset.py @@ -1,7 +1,7 @@ import itertools from django.db import models -from django_filters import Filter, MultipleChoiceFilter, VERSION +from django_filters import Filter, MultipleChoiceFilter from django_filters.filterset import BaseFilterSet, FilterSet from django_filters.filterset import FILTER_FOR_DBFIELD_DEFAULTS @@ -50,36 +50,6 @@ class GrapheneFilterSetMixin(BaseFilterSet): ) -# To support a Django 1.11 + Python 2.7 combination django-filter must be -# < 2.x.x. To support the earlier version of django-filter, the -# filter_for_reverse_field method must be present on GrapheneFilterSetMixin and -# must not be present for later versions of django-filter. -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 - We override the default implementation so that we can handle - Global IDs (the default implementation expects database - primary keys) - """ - try: - rel = f.field.remote_field - except AttributeError: - rel = f.field.rel - default = {"name": name, "label": capfirst(rel.related_name)} - if rel.multiple: - # For to-many relationships - return GlobalIDMultipleChoiceFilter(**default) - else: - # For to-one relationships - return GlobalIDFilter(**default) - - GrapheneFilterSetMixin = GrapheneFilterSetMixinPython2 - - def setup_filterset(filterset_class): """ Wrap a provided filterset in Graphene-specific functionality """ diff --git a/graphene_django/filter/tests/test_fields.py b/graphene_django/filter/tests/test_fields.py index 1ffa0f4..59cc30b 100644 --- a/graphene_django/filter/tests/test_fields.py +++ b/graphene_django/filter/tests/test_fields.py @@ -35,9 +35,6 @@ else: ) ) -pytestmark.append(pytest.mark.django_db) - - if DJANGO_FILTER_INSTALLED: class ArticleNode(DjangoObjectType): @@ -180,7 +177,7 @@ def test_filter_shortcut_filterset_context(): } """ schema = Schema(query=Query) - result = schema.execute(query, context=context()) + result = schema.execute(query, context_value=context()) assert not result.errors assert len(result.data["contextArticles"]["edges"]) == 1 @@ -608,58 +605,6 @@ def test_should_query_filter_node_limit(): assert result.data == expected -def test_should_query_filter_node_double_limit_raises(): - class ReporterFilter(FilterSet): - limit = NumberFilter(method="filter_limit") - - def filter_limit(self, queryset, name, value): - return queryset[:value] - - class Meta: - model = Reporter - fields = ["first_name"] - - class ReporterType(DjangoObjectType): - class Meta: - model = Reporter - interfaces = (Node,) - - class Query(ObjectType): - all_reporters = DjangoFilterConnectionField( - ReporterType, filterset_class=ReporterFilter - ) - - def resolve_all_reporters(self, info, **args): - return Reporter.objects.order_by("a_choice")[:2] - - Reporter.objects.create( - first_name="Bob", last_name="Doe", email="bobdoe@example.com", a_choice=2 - ) - Reporter.objects.create( - first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1 - ) - - schema = Schema(query=Query) - query = """ - query NodeFilteringQuery { - allReporters(limit: 1) { - edges { - node { - id - firstName - } - } - } - } - """ - - result = schema.execute(query) - assert len(result.errors) == 1 - assert str(result.errors[0]) == ( - "Received two sliced querysets (high mark) in the connection, please slice only in one." - ) - - def test_order_by_is_perserved(): class ReporterType(DjangoObjectType): class Meta: @@ -721,7 +666,7 @@ def test_order_by_is_perserved(): assert reverse_result.data == reverse_expected -def test_annotation_is_perserved(): +def test_annotation_is_preserved(): class ReporterType(DjangoObjectType): full_name = String() @@ -766,6 +711,86 @@ def test_annotation_is_perserved(): assert result.data == expected +def test_annotation_with_only(): + class ReporterType(DjangoObjectType): + full_name = String() + + class Meta: + model = Reporter + interfaces = (Node,) + filter_fields = () + + class Query(ObjectType): + all_reporters = DjangoFilterConnectionField(ReporterType) + + def resolve_all_reporters(self, info, **args): + return Reporter.objects.only("first_name", "last_name").annotate( + full_name=Concat( + "first_name", Value(" "), "last_name", output_field=TextField() + ) + ) + + Reporter.objects.create(first_name="John", last_name="Doe") + + schema = Schema(query=Query) + + query = """ + query NodeFilteringQuery { + allReporters(first: 1) { + edges { + node { + fullName + } + } + } + } + """ + expected = {"allReporters": {"edges": [{"node": {"fullName": "John Doe"}}]}} + + result = schema.execute(query) + + assert not result.errors + assert result.data == expected + + +def test_node_get_queryset_is_called(): + class ReporterType(DjangoObjectType): + class Meta: + model = Reporter + interfaces = (Node,) + filter_fields = () + + @classmethod + def get_queryset(cls, queryset, info): + return queryset.filter(first_name="b") + + class Query(ObjectType): + all_reporters = DjangoFilterConnectionField( + ReporterType, reverse_order=Boolean() + ) + + Reporter.objects.create(first_name="b") + Reporter.objects.create(first_name="a") + + schema = Schema(query=Query) + query = """ + query NodeFilteringQuery { + allReporters(first: 10) { + edges { + node { + firstName + } + } + } + } + """ + expected = {"allReporters": {"edges": [{"node": {"firstName": "b"}}]}} + + result = schema.execute(query) + assert not result.errors + assert result.data == expected + + def test_integer_field_filter_type(): class PetType(DjangoObjectType): class Meta: @@ -781,38 +806,56 @@ def test_integer_field_filter_type(): assert str(schema) == dedent( """\ - schema { - query: Query - } - - interface Node { - id: ID! - } - - type PageInfo { - hasNextPage: Boolean! - hasPreviousPage: Boolean! - startCursor: String - endCursor: String - } - - type PetType implements Node { - age: Int! - id: ID! + type Query { + pets(before: String = null, after: String = null, first: Int = null, last: Int = null, age: Int = null): PetTypeConnection } type PetTypeConnection { + \"""Pagination data for this connection.\""" pageInfo: PageInfo! + + \"""Contains the nodes in this connection.\""" edges: [PetTypeEdge]! } + \""" + The Relay compliant `PageInfo` type, containing data necessary to paginate this connection. + \""" + type PageInfo { + \"""When paginating forwards, are there more items?\""" + hasNextPage: Boolean! + + \"""When paginating backwards, are there more items?\""" + hasPreviousPage: Boolean! + + \"""When paginating backwards, the cursor to continue.\""" + startCursor: String + + \"""When paginating forwards, the cursor to continue.\""" + endCursor: String + } + + \"""A Relay edge containing a `PetType` and its cursor.\""" type PetTypeEdge { + \"""The item at the end of the edge\""" node: PetType + + \"""A cursor for use in pagination\""" cursor: String! } - - type Query { - pets(before: String, after: String, first: Int, last: Int, age: Int): PetTypeConnection + + type PetType implements Node { + \"""\""" + age: Int! + + \"""The ID of the object\""" + id: ID! + } + + \"""An object with an ID\""" + interface Node { + \"""The ID of the object\""" + id: ID! } """ ) @@ -833,40 +876,58 @@ def test_other_filter_types(): assert str(schema) == dedent( """\ - schema { - query: Query - } - - interface Node { - id: ID! - } - - type PageInfo { - hasNextPage: Boolean! - hasPreviousPage: Boolean! - startCursor: String - endCursor: String - } - - type PetType implements Node { - age: Int! - id: ID! + type Query { + pets(before: String = null, after: String = null, first: Int = null, last: Int = null, age: Int = null, age_Isnull: Boolean = null, age_Lt: Int = null): PetTypeConnection } type PetTypeConnection { + \"""Pagination data for this connection.\""" pageInfo: PageInfo! + + \"""Contains the nodes in this connection.\""" edges: [PetTypeEdge]! } + \""" + The Relay compliant `PageInfo` type, containing data necessary to paginate this connection. + \""" + type PageInfo { + \"""When paginating forwards, are there more items?\""" + hasNextPage: Boolean! + + \"""When paginating backwards, are there more items?\""" + hasPreviousPage: Boolean! + + \"""When paginating backwards, the cursor to continue.\""" + startCursor: String + + \"""When paginating forwards, the cursor to continue.\""" + endCursor: String + } + + \"""A Relay edge containing a `PetType` and its cursor.\""" type PetTypeEdge { + \"""The item at the end of the edge\""" node: PetType + + \"""A cursor for use in pagination\""" cursor: String! } - type Query { - pets(before: String, after: String, first: Int, last: Int, age: Int, age_Isnull: Boolean, age_Lt: Int): PetTypeConnection + type PetType implements Node { + \"""\""" + age: Int! + + \"""The ID of the object\""" + id: ID! } - """ + + \"""An object with an ID\""" + interface Node { + \"""The ID of the object\""" + id: ID! + } + """ ) diff --git a/graphene_django/filter/utils.py b/graphene_django/filter/utils.py index abb03a9..ea4f8dc 100644 --- a/graphene_django/filter/utils.py +++ b/graphene_django/filter/utils.py @@ -1,5 +1,4 @@ -import six - +from django_filters.utils import get_model_field from .filterset import custom_filterset_factory, setup_filterset @@ -12,28 +11,18 @@ def get_filtering_args_from_filterset(filterset_class, type): args = {} model = filterset_class._meta.model - for name, filter_field in six.iteritems(filterset_class.base_filters): + for name, filter_field in filterset_class.base_filters.items(): form_field = None if name in filterset_class.declared_filters: form_field = filter_field.field else: - try: - field_name, filter_type = name.rsplit("__", 1) - except ValueError: - field_name = name - filter_type = None - - # If the filter type is `isnull` then use the filter provided by - # DjangoFilter (a BooleanFilter). - # Otherwise try and get a filter based on the actual model field - if filter_type != "isnull" and hasattr(model, field_name): - model_field = model._meta.get_field(field_name) - - if hasattr(model_field, "formfield"): - form_field = model_field.formfield( - required=filter_field.extra.get("required", False) - ) + model_field = get_model_field(model, filter_field.field_name) + filter_type = filter_field.lookup_expr + if filter_type != "isnull" and hasattr(model_field, "formfield"): + form_field = model_field.formfield( + required=filter_field.extra.get("required", False) + ) # Fallback to field defined on filter if we can't get it from the # model field diff --git a/graphene_django/forms/converter.py b/graphene_django/forms/converter.py index 8916456..077e984 100644 --- a/graphene_django/forms/converter.py +++ b/graphene_django/forms/converter.py @@ -1,13 +1,11 @@ +from functools import singledispatch + from django import forms from django.core.exceptions import ImproperlyConfigured from graphene import ID, Boolean, Float, Int, List, String, UUID, Date, DateTime, Time from .forms import GlobalIDFormField, GlobalIDMultipleChoiceField -from ..utils import import_single_dispatch - - -singledispatch = import_single_dispatch() @singledispatch @@ -57,9 +55,14 @@ def convert_form_field_to_float(field): return Float(description=field.help_text, required=field.required) +@convert_form_field.register(forms.MultipleChoiceField) +def convert_form_field_to_string_list(field): + return List(String, description=field.help_text, required=field.required) + + @convert_form_field.register(forms.ModelMultipleChoiceField) @convert_form_field.register(GlobalIDMultipleChoiceField) -def convert_form_field_to_list(field): +def convert_form_field_to_id_list(field): return List(ID, required=field.required) diff --git a/graphene_django/forms/forms.py b/graphene_django/forms/forms.py index 14e68c8..4b81859 100644 --- a/graphene_django/forms/forms.py +++ b/graphene_django/forms/forms.py @@ -2,7 +2,7 @@ import binascii from django.core.exceptions import ValidationError from django.forms import CharField, Field, MultipleChoiceField -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from graphql_relay import from_global_id diff --git a/graphene_django/forms/mutation.py b/graphene_django/forms/mutation.py index f5921e8..692f8d5 100644 --- a/graphene_django/forms/mutation.py +++ b/graphene_django/forms/mutation.py @@ -47,7 +47,7 @@ class BaseDjangoFormMutation(ClientIDMutation): else: errors = ErrorType.from_errors(form.errors) - return cls(errors=errors) + return cls(errors=errors, **form.data) @classmethod def get_form(cls, root, info, **input): @@ -66,28 +66,6 @@ class BaseDjangoFormMutation(ClientIDMutation): return kwargs -# class DjangoFormInputObjectTypeOptions(InputObjectTypeOptions): -# form_class = None - - -# class DjangoFormInputObjectType(InputObjectType): -# class Meta: -# abstract = True - -# @classmethod -# def __init_subclass_with_meta__(cls, form_class=None, -# only_fields=(), exclude_fields=(), _meta=None, **options): -# if not _meta: -# _meta = DjangoFormInputObjectTypeOptions(cls) -# assert isinstance(form_class, forms.Form), ( -# 'form_class must be an instance of django.forms.Form' -# ) -# _meta.form_class = form_class -# form = form_class() -# fields = fields_for_form(form, only_fields, exclude_fields) -# super(DjangoFormInputObjectType, cls).__init_subclass_with_meta__(_meta=_meta, fields=fields, **options) - - class DjangoFormMutationOptions(MutationOptions): form_class = None @@ -122,7 +100,7 @@ class DjangoFormMutation(BaseDjangoFormMutation): @classmethod def perform_mutate(cls, form, info): form.save() - return cls(errors=[]) + return cls(errors=[], **form.cleaned_data) class DjangoModelDjangoFormMutationOptions(DjangoFormMutationOptions): @@ -163,7 +141,9 @@ class DjangoModelFormMutation(BaseDjangoFormMutation): registry = get_global_registry() model_type = registry.get_type_for_model(model) - return_field_name = return_field_name + if not model_type: + raise Exception("No type registered for model: {}".format(model.__name__)) + if not return_field_name: model_name = model.__name__ return_field_name = model_name[:1].lower() + model_name[1:] @@ -182,6 +162,17 @@ class DjangoModelFormMutation(BaseDjangoFormMutation): _meta=_meta, input_fields=input_fields, **options ) + @classmethod + def mutate_and_get_payload(cls, root, info, **input): + form = cls.get_form(root, info, **input) + + if form.is_valid(): + return cls.perform_mutate(form, info) + else: + errors = ErrorType.from_errors(form.errors) + + return cls(errors=errors) + @classmethod def perform_mutate(cls, form, info): obj = form.save() diff --git a/graphene_django/forms/tests/test_converter.py b/graphene_django/forms/tests/test_converter.py index 955b952..29a4419 100644 --- a/graphene_django/forms/tests/test_converter.py +++ b/graphene_django/forms/tests/test_converter.py @@ -66,6 +66,10 @@ def test_should_choice_convert_string(): assert_conversion(forms.ChoiceField, String) +def test_should_multiple_choice_convert_list(): + assert_conversion(forms.MultipleChoiceField, List) + + def test_should_base_field_convert_string(): assert_conversion(forms.Field, String) diff --git a/graphene_django/forms/tests/test_mutation.py b/graphene_django/forms/tests/test_mutation.py index 2b3232f..543136e 100644 --- a/graphene_django/forms/tests/test_mutation.py +++ b/graphene_django/forms/tests/test_mutation.py @@ -1,22 +1,49 @@ +import pytest from django import forms -from django.test import TestCase +from django.core.exceptions import ValidationError from py.test import raises -from graphene_django.tests.models import Film, FilmDetails, Pet +from graphene import Field, ObjectType, Schema, String +from graphene_django import DjangoObjectType +from graphene_django.tests.models import Pet -from ...settings import graphene_settings from ..mutation import DjangoFormMutation, DjangoModelFormMutation +@pytest.fixture() +def pet_type(): + class PetType(DjangoObjectType): + class Meta: + model = Pet + fields = "__all__" + + return PetType + + class MyForm(forms.Form): text = forms.CharField() + def clean_text(self): + text = self.cleaned_data["text"] + if text == "INVALID_INPUT": + raise ValidationError("Invalid input") + return text + + def save(self): + pass + class PetForm(forms.ModelForm): class Meta: model = Pet fields = "__all__" + def clean_age(self): + age = self.cleaned_data["age"] + if age >= 99: + raise ValidationError("Too old") + return age + def test_needs_form_class(): with raises(Exception) as exc: @@ -43,7 +70,7 @@ def test_has_input_fields(): assert "text" in MyMutation.Input._meta.fields -def test_mutation_error_camelcased(): +def test_mutation_error_camelcased(pet_type, graphene_settings): class ExtraPetForm(PetForm): test_field = forms.CharField(required=True) @@ -56,105 +83,237 @@ def test_mutation_error_camelcased(): graphene_settings.CAMELCASE_ERRORS = False result = PetMutation.mutate_and_get_payload(None, None) assert {f.field for f in result.errors} == {"name", "age", "test_field"} - graphene_settings.CAMELCASE_ERRORS = True -class ModelFormMutationTests(TestCase): - def test_default_meta_fields(self): - class PetMutation(DjangoModelFormMutation): - class Meta: - form_class = PetForm +class MockQuery(ObjectType): + a = String() - self.assertEqual(PetMutation._meta.model, Pet) - self.assertEqual(PetMutation._meta.return_field_name, "pet") - self.assertIn("pet", PetMutation._meta.fields) - def test_default_input_meta_fields(self): - class PetMutation(DjangoModelFormMutation): - class Meta: - form_class = PetForm +def test_form_invalid_form(): + class MyMutation(DjangoFormMutation): + class Meta: + form_class = MyForm - self.assertEqual(PetMutation._meta.model, Pet) - self.assertEqual(PetMutation._meta.return_field_name, "pet") - self.assertIn("name", PetMutation.Input._meta.fields) - self.assertIn("client_mutation_id", PetMutation.Input._meta.fields) - self.assertIn("id", PetMutation.Input._meta.fields) + class Mutation(ObjectType): + my_mutation = MyMutation.Field() - def test_exclude_fields_input_meta_fields(self): - class PetMutation(DjangoModelFormMutation): - class Meta: - form_class = PetForm - exclude_fields = ["id"] + schema = Schema(query=MockQuery, mutation=Mutation) - self.assertEqual(PetMutation._meta.model, Pet) - self.assertEqual(PetMutation._meta.return_field_name, "pet") - self.assertIn("name", PetMutation.Input._meta.fields) - self.assertIn("age", PetMutation.Input._meta.fields) - self.assertIn("client_mutation_id", PetMutation.Input._meta.fields) - self.assertNotIn("id", PetMutation.Input._meta.fields) + result = schema.execute( + """ mutation MyMutation { + myMutation(input: { text: "INVALID_INPUT" }) { + errors { + field + messages + } + text + } + } + """ + ) - def test_return_field_name_is_camelcased(self): - class PetMutation(DjangoModelFormMutation): - class Meta: - form_class = PetForm - model = FilmDetails + assert result.errors is None + assert result.data["myMutation"]["errors"] == [ + {"field": "text", "messages": ["Invalid input"]} + ] - self.assertEqual(PetMutation._meta.model, FilmDetails) - self.assertEqual(PetMutation._meta.return_field_name, "filmDetails") - def test_custom_return_field_name(self): - class PetMutation(DjangoModelFormMutation): - class Meta: - form_class = PetForm - model = Film - return_field_name = "animal" +def test_form_valid_input(): + class MyMutation(DjangoFormMutation): + class Meta: + form_class = MyForm - self.assertEqual(PetMutation._meta.model, Film) - self.assertEqual(PetMutation._meta.return_field_name, "animal") - self.assertIn("animal", PetMutation._meta.fields) + class Mutation(ObjectType): + my_mutation = MyMutation.Field() - def test_model_form_mutation_mutate(self): - class PetMutation(DjangoModelFormMutation): - class Meta: - form_class = PetForm + schema = Schema(query=MockQuery, mutation=Mutation) - pet = Pet.objects.create(name="Axel", age=10) + result = schema.execute( + """ mutation MyMutation { + myMutation(input: { text: "VALID_INPUT" }) { + errors { + field + messages + } + text + } + } + """ + ) - result = PetMutation.mutate_and_get_payload( - None, None, id=pet.pk, name="Mia", age=10 - ) + assert result.errors is None + assert result.data["myMutation"]["errors"] == [] + assert result.data["myMutation"]["text"] == "VALID_INPUT" - self.assertEqual(Pet.objects.count(), 1) - pet.refresh_from_db() - self.assertEqual(pet.name, "Mia") - self.assertEqual(result.errors, []) - def test_model_form_mutation_updates_existing_(self): - class PetMutation(DjangoModelFormMutation): - class Meta: - form_class = PetForm +def test_default_meta_fields(pet_type): + class PetMutation(DjangoModelFormMutation): + class Meta: + form_class = PetForm - result = PetMutation.mutate_and_get_payload(None, None, name="Mia", age=10) + assert PetMutation._meta.model is Pet + assert PetMutation._meta.return_field_name == "pet" + assert "pet" in PetMutation._meta.fields - self.assertEqual(Pet.objects.count(), 1) - pet = Pet.objects.get() - self.assertEqual(pet.name, "Mia") - self.assertEqual(pet.age, 10) - self.assertEqual(result.errors, []) - def test_model_form_mutation_mutate_invalid_form(self): - class PetMutation(DjangoModelFormMutation): - class Meta: - form_class = PetForm +def test_default_input_meta_fields(pet_type): + class PetMutation(DjangoModelFormMutation): + class Meta: + form_class = PetForm - result = PetMutation.mutate_and_get_payload(None, None) + assert PetMutation._meta.model is Pet + assert PetMutation._meta.return_field_name == "pet" + assert "name" in PetMutation.Input._meta.fields + assert "client_mutation_id" in PetMutation.Input._meta.fields + assert "id" in PetMutation.Input._meta.fields - # 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) - self.assertEqual(result.errors[0].messages, ["This field is required."]) - self.assertIn("age", fields_w_error) - self.assertEqual(result.errors[1].messages, ["This field is required."]) +def test_exclude_fields_input_meta_fields(pet_type): + class PetMutation(DjangoModelFormMutation): + class Meta: + form_class = PetForm + exclude_fields = ["id"] + + assert PetMutation._meta.model is Pet + assert PetMutation._meta.return_field_name == "pet" + assert "name" in PetMutation.Input._meta.fields + assert "age" in PetMutation.Input._meta.fields + assert "client_mutation_id" in PetMutation.Input._meta.fields + assert "id" not in PetMutation.Input._meta.fields + + +def test_custom_return_field_name(pet_type): + class PetMutation(DjangoModelFormMutation): + class Meta: + form_class = PetForm + model = Pet + return_field_name = "animal" + + assert PetMutation._meta.model is Pet + assert PetMutation._meta.return_field_name == "animal" + assert "animal" in PetMutation._meta.fields + + +def test_model_form_mutation_mutate_existing(pet_type): + class PetMutation(DjangoModelFormMutation): + pet = Field(pet_type) + + class Meta: + form_class = PetForm + + class Mutation(ObjectType): + pet_mutation = PetMutation.Field() + + schema = Schema(query=MockQuery, mutation=Mutation) + + pet = Pet.objects.create(name="Axel", age=10) + + result = schema.execute( + """ mutation PetMutation($pk: ID!) { + petMutation(input: { id: $pk, name: "Mia", age: 10 }) { + pet { + name + age + } + } + } + """, + variable_values={"pk": pet.pk}, + ) + + assert result.errors is None + assert result.data["petMutation"]["pet"] == {"name": "Mia", "age": 10} + + assert Pet.objects.count() == 1 + pet.refresh_from_db() + assert pet.name == "Mia" + + +def test_model_form_mutation_creates_new(pet_type): + class PetMutation(DjangoModelFormMutation): + pet = Field(pet_type) + + class Meta: + form_class = PetForm + + class Mutation(ObjectType): + pet_mutation = PetMutation.Field() + + schema = Schema(query=MockQuery, mutation=Mutation) + + result = schema.execute( + """ mutation PetMutation { + petMutation(input: { name: "Mia", age: 10 }) { + pet { + name + age + } + errors { + field + messages + } + } + } + """ + ) + assert result.errors is None + assert result.data["petMutation"]["pet"] == {"name": "Mia", "age": 10} + + assert Pet.objects.count() == 1 + pet = Pet.objects.get() + assert pet.name == "Mia" + assert pet.age == 10 + + +def test_model_form_mutation_invalid_input(pet_type): + class PetMutation(DjangoModelFormMutation): + pet = Field(pet_type) + + class Meta: + form_class = PetForm + + class Mutation(ObjectType): + pet_mutation = PetMutation.Field() + + schema = Schema(query=MockQuery, mutation=Mutation) + + result = schema.execute( + """ mutation PetMutation { + petMutation(input: { name: "Mia", age: 99 }) { + pet { + name + age + } + errors { + field + messages + } + } + } + """ + ) + assert result.errors is None + assert result.data["petMutation"]["pet"] is None + assert result.data["petMutation"]["errors"] == [ + {"field": "age", "messages": ["Too old"]} + ] + + assert Pet.objects.count() == 0 + + +def test_model_form_mutation_mutate_invalid_form(pet_type): + class PetMutation(DjangoModelFormMutation): + class Meta: + form_class = PetForm + + result = PetMutation.mutate_and_get_payload(None, None) + + # A pet was not created + Pet.objects.count() == 0 + + fields_w_error = [e.field for e in result.errors] + assert len(result.errors) == 2 + assert result.errors[0].messages == ["This field is required."] + assert result.errors[1].messages == ["This field is required."] + assert "age" in fields_w_error + assert "name" in fields_w_error \ No newline at end of file diff --git a/graphene_django/management/commands/graphql_schema.py b/graphene_django/management/commands/graphql_schema.py index 1e8baf6..9cf55ca 100644 --- a/graphene_django/management/commands/graphql_schema.py +++ b/graphene_django/management/commands/graphql_schema.py @@ -1,3 +1,4 @@ +import os import importlib import json import functools @@ -5,6 +6,7 @@ import functools from django.core.management.base import BaseCommand, CommandError from django.utils import autoreload +from graphql import print_schema from graphene_django.settings import graphene_settings @@ -44,24 +46,41 @@ class CommandArguments(BaseCommand): class Command(CommandArguments): - help = "Dump Graphene schema JSON to file" + help = "Dump Graphene schema as a JSON or GraphQL file" can_import_settings = True + requires_system_checks = False - def save_file(self, out, schema_dict, indent): + def save_json_file(self, out, schema_dict, indent): with open(out, "w") as outfile: json.dump(schema_dict, outfile, indent=indent, sort_keys=True) + def save_graphql_file(self, out, schema): + with open(out, "w") as outfile: + outfile.write(print_schema(schema.graphql_schema)) + 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) + # Determine format + _, file_extension = os.path.splitext(out) + + if file_extension == ".graphql": + self.save_graphql_file(out, schema) + elif file_extension == ".json": + self.save_json_file(out, schema_dict, indent) + else: + raise CommandError( + 'Unrecognised file format "{}"'.format(file_extension) + ) style = getattr(self, "style", None) success = getattr(style, "SUCCESS", lambda x: x) - self.stdout.write(success("Successfully dumped GraphQL schema to %s" % out)) + self.stdout.write( + success("Successfully dumped GraphQL schema to {}".format(out)) + ) def handle(self, *args, **options): options_schema = options.get("schema") diff --git a/graphene_django/rest_framework/models.py b/graphene_django/rest_framework/models.py index 06d9b60..bd84ce5 100644 --- a/graphene_django/rest_framework/models.py +++ b/graphene_django/rest_framework/models.py @@ -9,3 +9,8 @@ class MyFakeModel(models.Model): class MyFakeModelWithPassword(models.Model): cool_name = models.CharField(max_length=50) password = models.CharField(max_length=50) + + +class MyFakeModelWithDate(models.Model): + cool_name = models.CharField(max_length=50) + last_edited = models.DateField() diff --git a/graphene_django/rest_framework/mutation.py b/graphene_django/rest_framework/mutation.py index d9c695e..592f8b3 100644 --- a/graphene_django/rest_framework/mutation.py +++ b/graphene_django/rest_framework/mutation.py @@ -1,6 +1,7 @@ from collections import OrderedDict from django.shortcuts import get_object_or_404 +from rest_framework import serializers import graphene from graphene.relay.mutation import ClientIDMutation @@ -19,22 +20,31 @@ class SerializerMutationOptions(MutationOptions): serializer_class = None -def fields_for_serializer(serializer, only_fields, exclude_fields, is_input=False): +def fields_for_serializer( + serializer, + only_fields, + exclude_fields, + is_input=False, + convert_choices_to_enum=True, +): fields = OrderedDict() for name, field in serializer.fields.items(): is_not_in_only = only_fields and name not in only_fields - is_excluded = ( - 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 + is_excluded = any( + [ + name in exclude_fields, + field.write_only + and not is_input, # don't show write_only fields in Query + field.read_only and is_input, # don't show read_only fields in Input + ] ) if is_not_in_only or is_excluded: continue - fields[name] = convert_serializer_field(field, is_input=is_input) + fields[name] = convert_serializer_field( + field, is_input=is_input, convert_choices_to_enum=convert_choices_to_enum + ) return fields @@ -55,6 +65,7 @@ class SerializerMutation(ClientIDMutation): model_operations=("create", "update"), only_fields=(), exclude_fields=(), + convert_choices_to_enum=True, **options ): @@ -74,10 +85,18 @@ class SerializerMutation(ClientIDMutation): lookup_field = model_class._meta.pk.name input_fields = fields_for_serializer( - serializer, only_fields, exclude_fields, is_input=True + serializer, + only_fields, + exclude_fields, + is_input=True, + convert_choices_to_enum=convert_choices_to_enum, ) output_fields = fields_for_serializer( - serializer, only_fields, exclude_fields, is_input=False + serializer, + only_fields, + exclude_fields, + is_input=False, + convert_choices_to_enum=convert_choices_to_enum, ) _meta = SerializerMutationOptions(cls) @@ -102,8 +121,10 @@ class SerializerMutation(ClientIDMutation): instance = get_object_or_404( model_class, **{lookup_field: input[lookup_field]} ) + partial = True elif "create" in cls._meta.model_operations: instance = None + partial = False else: raise Exception( 'Invalid update operation. Input parameter "{}" required.'.format( @@ -115,6 +136,7 @@ class SerializerMutation(ClientIDMutation): "instance": instance, "data": input, "context": {"request": info.context}, + "partial": partial, } return {"data": input, "context": {"request": info.context}} @@ -138,6 +160,9 @@ class SerializerMutation(ClientIDMutation): kwargs = {} for f, field in serializer.fields.items(): if not field.write_only: - kwargs[f] = field.get_attribute(obj) + if isinstance(field, serializers.SerializerMethodField): + kwargs[f] = field.to_representation(obj) + else: + kwargs[f] = field.get_attribute(obj) return cls(errors=None, **kwargs) diff --git a/graphene_django/rest_framework/serializer_converter.py b/graphene_django/rest_framework/serializer_converter.py index caeb7dd..b26e5e6 100644 --- a/graphene_django/rest_framework/serializer_converter.py +++ b/graphene_django/rest_framework/serializer_converter.py @@ -1,3 +1,5 @@ +from functools import singledispatch + from django.core.exceptions import ImproperlyConfigured from rest_framework import serializers @@ -5,11 +7,8 @@ import graphene from ..registry import get_global_registry from ..converter import convert_choices_to_named_enum_with_descriptions -from ..utils import import_single_dispatch from .types import DictType -singledispatch = import_single_dispatch() - @singledispatch def get_graphene_type_from_serializer_field(field): @@ -19,14 +18,17 @@ def get_graphene_type_from_serializer_field(field): ) -def convert_serializer_field(field, is_input=True): +def convert_serializer_field(field, is_input=True, convert_choices_to_enum=True): """ Converts a django rest frameworks field to a graphql field and marks the field as required if we are creating an input type and the field itself is required """ - graphql_type = get_graphene_type_from_serializer_field(field) + if isinstance(field, serializers.ChoiceField) and not convert_choices_to_enum: + graphql_type = graphene.String + else: + graphql_type = get_graphene_type_from_serializer_field(field) args = [] kwargs = {"description": field.help_text, "required": is_input and field.required} diff --git a/graphene_django/rest_framework/tests/test_field_converter.py b/graphene_django/rest_framework/tests/test_field_converter.py index 82f5b63..daa8349 100644 --- a/graphene_django/rest_framework/tests/test_field_converter.py +++ b/graphene_django/rest_framework/tests/test_field_converter.py @@ -10,7 +10,9 @@ from ..serializer_converter import convert_serializer_field from ..types import DictType -def _get_type(rest_framework_field, is_input=True, **kwargs): +def _get_type( + rest_framework_field, is_input=True, convert_choices_to_enum=True, **kwargs +): # prevents the following error: # AssertionError: The `source` argument is not meaningful when applied to a `child=` field. # Remove `source=` from the field declaration. @@ -21,7 +23,9 @@ def _get_type(rest_framework_field, is_input=True, **kwargs): field = rest_framework_field(**kwargs) - return convert_serializer_field(field, is_input=is_input) + return convert_serializer_field( + field, is_input=is_input, convert_choices_to_enum=convert_choices_to_enum + ) def assert_conversion(rest_framework_field, graphene_field, **kwargs): @@ -73,6 +77,16 @@ def test_should_choice_convert_enum(): assert field._meta.enum.__members__["W"].description == "World" +def test_should_choice_convert_string_if_enum_disabled(): + assert_conversion( + serializers.ChoiceField, + graphene.String, + choices=[("h", "Hello"), ("w", "World")], + source="word", + convert_choices_to_enum=False, + ) + + def test_should_base_field_convert_string(): assert_conversion(serializers.Field, graphene.String) diff --git a/graphene_django/rest_framework/tests/test_multiple_model_serializers.py b/graphene_django/rest_framework/tests/test_multiple_model_serializers.py index c1f4626..1676b62 100644 --- a/graphene_django/rest_framework/tests/test_multiple_model_serializers.py +++ b/graphene_django/rest_framework/tests/test_multiple_model_serializers.py @@ -1,14 +1,11 @@ -import graphene -import pytest from django.db import models -from graphene import Schema from rest_framework import serializers +import graphene +from graphene import Schema from graphene_django import DjangoObjectType from graphene_django.rest_framework.mutation import SerializerMutation -pytestmark = pytest.mark.django_db - class MyFakeChildModel(models.Model): name = models.CharField(max_length=50) diff --git a/graphene_django/rest_framework/tests/test_mutation.py b/graphene_django/rest_framework/tests/test_mutation.py index 9d8b950..1b31e36 100644 --- a/graphene_django/rest_framework/tests/test_mutation.py +++ b/graphene_django/rest_framework/tests/test_mutation.py @@ -1,14 +1,13 @@ import datetime -from py.test import mark, raises +from py.test import raises from rest_framework import serializers from graphene import Field, ResolveInfo from graphene.types.inputobjecttype import InputObjectType -from ...settings import graphene_settings from ...types import DjangoObjectType -from ..models import MyFakeModel, MyFakeModelWithPassword +from ..models import MyFakeModel, MyFakeModelWithDate, MyFakeModelWithPassword from ..mutation import SerializerMutation @@ -18,12 +17,14 @@ def mock_info(): None, None, None, + path=None, schema=None, fragments=None, root_value=None, operation=None, variable_values=None, context=None, + is_awaitable=None, ) @@ -33,6 +34,18 @@ class MyModelSerializer(serializers.ModelSerializer): fields = "__all__" +class MyModelSerializerWithMethod(serializers.ModelSerializer): + days_since_last_edit = serializers.SerializerMethodField() + + class Meta: + model = MyFakeModelWithDate + fields = "__all__" + + def get_days_since_last_edit(self, obj): + now = datetime.date(2020, 1, 8) + return (now - obj.last_edited).days + + class MyModelMutation(SerializerMutation): class Meta: serializer_class = MyModelSerializer @@ -87,7 +100,6 @@ 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) @@ -110,7 +122,6 @@ def test_write_only_field(): ), "'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: @@ -132,6 +143,24 @@ def test_write_only_field_using_extra_kwargs(): ), "'password' is write_only field and shouldn't be visible" +def test_read_only_fields(): + class ReadOnlyFieldModelSerializer(serializers.ModelSerializer): + cool_name = serializers.CharField(read_only=True) + + class Meta: + model = MyFakeModelWithPassword + fields = ["cool_name", "password"] + + class MyMutation(SerializerMutation): + class Meta: + serializer_class = ReadOnlyFieldModelSerializer + + assert "password" in MyMutation.Input._meta.fields + assert ( + "cool_name" not in MyMutation.Input._meta.fields + ), "'cool_name' is read_only field and shouldn't be on arguments" + + def test_nested_model(): class MyFakeModelGrapheneType(DjangoObjectType): class Meta: @@ -163,7 +192,6 @@ def test_mutate_and_get_payload_success(): assert result.errors is None -@mark.django_db def test_model_add_mutate_and_get_payload_success(): result = MyModelMutation.mutate_and_get_payload( None, mock_info(), **{"cool_name": "Narf"} @@ -173,7 +201,6 @@ def test_model_add_mutate_and_get_payload_success(): assert isinstance(result.created, datetime.datetime) -@mark.django_db def test_model_update_mutate_and_get_payload_success(): instance = MyFakeModel.objects.create(cool_name="Narf") result = MyModelMutation.mutate_and_get_payload( @@ -183,7 +210,15 @@ def test_model_update_mutate_and_get_payload_success(): assert result.cool_name == "New Narf" -@mark.django_db +def test_model_partial_update_mutate_and_get_payload_success(): + instance = MyFakeModel.objects.create(cool_name="Narf") + result = MyModelMutation.mutate_and_get_payload( + None, mock_info(), **{"id": instance.id} + ) + assert result.errors is None + assert result.cool_name == "Narf" + + def test_model_invalid_update_mutate_and_get_payload_success(): class InvalidModelMutation(SerializerMutation): class Meta: @@ -198,6 +233,22 @@ def test_model_invalid_update_mutate_and_get_payload_success(): assert '"id" required' in str(exc.value) +def test_perform_mutate_success(): + class MyMethodMutation(SerializerMutation): + class Meta: + serializer_class = MyModelSerializerWithMethod + + result = MyMethodMutation.mutate_and_get_payload( + None, + mock_info(), + **{"cool_name": "Narf", "last_edited": datetime.date(2020, 1, 4)} + ) + + assert result.errors is None + assert result.cool_name == "Narf" + assert result.days_since_last_edit == 4 + + def test_mutate_and_get_payload_error(): class MyMutation(SerializerMutation): class Meta: @@ -214,11 +265,10 @@ def test_model_mutate_and_get_payload_error(): assert len(result.errors) > 0 -def test_mutation_error_camelcased(): +def test_mutation_error_camelcased(graphene_settings): graphene_settings.CAMELCASE_ERRORS = True result = MyModelMutation.mutate_and_get_payload(None, mock_info(), **{}) assert result.errors[0].field == "coolName" - graphene_settings.CAMELCASE_ERRORS = False def test_invalid_serializer_operations(): diff --git a/graphene_django/settings.py b/graphene_django/settings.py index 8d67d68..f4f4db3 100644 --- a/graphene_django/settings.py +++ b/graphene_django/settings.py @@ -15,7 +15,6 @@ from __future__ import unicode_literals from django.conf import settings from django.test.signals import setting_changed -from django.utils import six try: import importlib # Available in Python 3.1+ @@ -36,6 +35,9 @@ DEFAULTS = { # Max items returned in ConnectionFields / FilterConnectionFields "RELAY_CONNECTION_MAX_LIMIT": 100, "CAMELCASE_ERRORS": True, + # Set to True to enable v3 naming convention for choice field Enum's + "DJANGO_CHOICE_FIELD_ENUM_V3_NAMING": False, + "DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME": None, } if settings.DEBUG: @@ -52,7 +54,7 @@ def perform_import(val, setting_name): """ if val is None: return None - elif isinstance(val, six.string_types): + elif isinstance(val, str): return import_from_string(val, setting_name) elif isinstance(val, (list, tuple)): return [import_from_string(item, setting_name) for item in val] diff --git a/graphene_django/static/graphene_django/graphiql.js b/graphene_django/static/graphene_django/graphiql.js index 2be7e3c..c939216 100644 --- a/graphene_django/static/graphene_django/graphiql.js +++ b/graphene_django/static/graphene_django/graphiql.js @@ -3,8 +3,11 @@ // Parse the cookie value for a CSRF token var csrftoken; var cookies = ('; ' + document.cookie).split('; csrftoken='); - if (cookies.length == 2) + if (cookies.length == 2) { csrftoken = cookies.pop().split(';').shift(); + } else { + csrftoken = document.querySelector("[name=csrfmiddlewaretoken]").value; + } // Collect the URL parameters var parameters = {}; @@ -94,6 +97,6 @@ // Render into the body. ReactDOM.render( React.createElement(GraphiQL, options), - document.body + document.getElementById("editor") ); })(); diff --git a/graphene_django/templates/graphene/graphiql.html b/graphene_django/templates/graphene/graphiql.html index d0fb5a8..d0546bd 100644 --- a/graphene_django/templates/graphene/graphiql.html +++ b/graphene_django/templates/graphene/graphiql.html @@ -10,7 +10,7 @@ add "&raw" to the end of the URL within a browser.