diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 000000000..3682573d8 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,18 @@ +# .readthedocs.yaml +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +version: 2 +build: + os: ubuntu-22.04 + tools: + python: "3.12" + +# Build documentation in the "docs/" directory with Sphinx +sphinx: + configuration: docs/conf.py + +# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html +python: + install: + - requirements: docs/requirements.txt diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6a226ab3a..ec4f2bd86 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,7 +33,7 @@ make tests ## Opening Pull Requests -Please fork the project and open a pull request against the master branch. +Please fork the project and open a pull request against the `main` branch. This will trigger a series of test and lint checks. diff --git a/README.md b/README.md index 2e3531f6f..686f5ec0d 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Graphene-Django is an open-source library that provides seamless integration bet To install Graphene-Django, run the following command: -``` +```sh pip install graphene-django ``` @@ -114,11 +114,11 @@ class MyModelAPITestCase(GraphQLTestCase): ## Contributing -Contributions to Graphene-Django are always welcome! To get started, check the repository's [issue tracker](https://github.com/graphql-python/graphene-django/issues) and [contribution guidelines](https://github.com/graphql-python/graphene-django/blob/master/CONTRIBUTING.md). +Contributions to Graphene-Django are always welcome! To get started, check the repository's [issue tracker](https://github.com/graphql-python/graphene-django/issues) and [contribution guidelines](https://github.com/graphql-python/graphene-django/blob/main/CONTRIBUTING.md). ## License -Graphene-Django is released under the [MIT License](https://github.com/graphql-python/graphene-django/blob/master/LICENSE). +Graphene-Django is released under the [MIT License](https://github.com/graphql-python/graphene-django/blob/main/LICENSE). ## Resources diff --git a/docs/tutorial-plain.rst b/docs/tutorial-plain.rst index 9073c82e4..12237df95 100644 --- a/docs/tutorial-plain.rst +++ b/docs/tutorial-plain.rst @@ -104,7 +104,7 @@ Load some test data Now is a good time to load up some test data. The easiest option will be to `download the -ingredients.json `__ +ingredients.json `__ fixture and place it in ``cookbook/ingredients/fixtures/ingredients.json``. You can then run the following: diff --git a/docs/tutorial-relay.rst b/docs/tutorial-relay.rst index bf932993c..8ea69da28 100644 --- a/docs/tutorial-relay.rst +++ b/docs/tutorial-relay.rst @@ -7,7 +7,7 @@ Graphene has a number of additional features that are designed to make working with Django *really simple*. Note: The code in this quickstart is pulled from the `cookbook example -app `__. +app `__. A good idea is to check the following things first: @@ -87,7 +87,7 @@ Load some test data Now is a good time to load up some test data. The easiest option will be to `download the -ingredients.json `__ +ingredients.json `__ fixture and place it in ``cookbook/ingredients/fixtures/ingredients.json``. You can then run the following: diff --git a/examples/cookbook/requirements.txt b/examples/cookbook/requirements.txt index a5b0b9655..74baf1219 100644 --- a/examples/cookbook/requirements.txt +++ b/examples/cookbook/requirements.txt @@ -1,5 +1,5 @@ graphene>=2.1,<3 graphene-django>=2.1,<3 graphql-core>=2.1,<3 -django==3.1.14 +django==3.2.24 django-filter>=2 diff --git a/examples/starwars/models.py b/examples/starwars/models.py index fb76b0395..c49206a4f 100644 --- a/examples/starwars/models.py +++ b/examples/starwars/models.py @@ -24,6 +24,9 @@ def __str__(self): class Ship(models.Model): + class Meta: + ordering = ["pk"] + name = models.CharField(max_length=50) faction = models.ForeignKey(Faction, on_delete=models.CASCADE, related_name="ships") diff --git a/graphene_django/__init__.py b/graphene_django/__init__.py index 7b41edb47..b0e7abd8b 100644 --- a/graphene_django/__init__.py +++ b/graphene_django/__init__.py @@ -2,7 +2,7 @@ from .types import DjangoObjectType from .utils import bypass_get_queryset -__version__ = "3.2.0" +__version__ = "3.2.1" __all__ = [ "__version__", diff --git a/graphene_django/converter.py b/graphene_django/converter.py index 121c1de10..7eba22a1d 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -1,5 +1,5 @@ import inspect -from collections import OrderedDict +from collections.abc import Callable from functools import partial, singledispatch, wraps from django.db import models @@ -72,8 +72,15 @@ def convert_choice_name(name): def get_choices(choices): converted_names = [] - if isinstance(choices, OrderedDict): + if isinstance(choices, Callable): + choices = choices() + + # In restframework==3.15.0, choices are not passed + # as OrderedDict anymore, so it's safer to check + # for a dict + if isinstance(choices, dict): choices = choices.items() + for value, help_text in choices: if isinstance(help_text, (tuple, list)): yield from get_choices(help_text) diff --git a/graphene_django/fields.py b/graphene_django/fields.py index 35bd3f028..a1b9a2ccb 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -20,17 +20,20 @@ class DjangoListField(Field): def __init__(self, _type, *args, **kwargs): - from .types import DjangoObjectType - if isinstance(_type, NonNull): _type = _type.of_type # Django would never return a Set of None vvvvvvv super().__init__(List(NonNull(_type)), *args, **kwargs) + @property + def type(self): + from .types import DjangoObjectType + assert issubclass( self._underlying_type, DjangoObjectType - ), "DjangoListField only accepts DjangoObjectType types" + ), "DjangoListField only accepts DjangoObjectType types as underlying type" + return super().type @property def _underlying_type(self): @@ -98,13 +101,19 @@ def type(self): non_null = True assert issubclass( _type, DjangoObjectType - ), "DjangoConnectionField only accepts DjangoObjectType types" + ), "DjangoConnectionField only accepts DjangoObjectType types as underlying type" assert _type._meta.connection, "The type {} doesn't have a connection".format( _type.__name__ ) connection_type = _type._meta.connection if non_null: return NonNull(connection_type) + # Since Relay Connections require to have a predictible ordering for pagination, + # check on init that the Django model provided has a default ordering declared. + model = connection_type._meta.node._meta.model + assert ( + len(getattr(model._meta, "ordering", [])) > 0 + ), f"Django model {model._meta.app_label}.{model.__name__} has to have a default ordering to be used in a Connection." return connection_type @property diff --git a/graphene_django/filter/tests/conftest.py b/graphene_django/filter/tests/conftest.py index a4097b183..8824042e9 100644 --- a/graphene_django/filter/tests/conftest.py +++ b/graphene_django/filter/tests/conftest.py @@ -26,6 +26,9 @@ class Event(models.Model): + class Meta: + ordering = ["pk"] + name = models.CharField(max_length=50) tags = ArrayField(models.CharField(max_length=50)) tag_ids = ArrayField(models.IntegerField()) diff --git a/graphene_django/tests/models.py b/graphene_django/tests/models.py index 67e266731..821b3701f 100644 --- a/graphene_django/tests/models.py +++ b/graphene_django/tests/models.py @@ -5,10 +5,19 @@ class Person(models.Model): + class Meta: + ordering = ["pk"] + name = models.CharField(max_length=30) + parent = models.ForeignKey( + "self", on_delete=models.CASCADE, null=True, blank=True, related_name="children" + ) class Pet(models.Model): + class Meta: + ordering = ["pk"] + name = models.CharField(max_length=30) age = models.PositiveIntegerField() owner = models.ForeignKey( @@ -28,6 +37,9 @@ class FilmDetails(models.Model): class Film(models.Model): + class Meta: + ordering = ["pk"] + genre = models.CharField( max_length=2, help_text="Genre", @@ -43,6 +55,9 @@ def get_queryset(self): class Reporter(models.Model): + class Meta: + ordering = ["pk"] + first_name = models.CharField(max_length=30) last_name = models.CharField(max_length=30) email = models.EmailField() diff --git a/graphene_django/tests/test_converter.py b/graphene_django/tests/test_converter.py index e8c09208c..2f8b1d515 100644 --- a/graphene_django/tests/test_converter.py +++ b/graphene_django/tests/test_converter.py @@ -180,6 +180,26 @@ class Meta: assert graphene_type._meta.enum.__members__["EN"].description == "English" +def test_field_with_callable_choices_convert_enum(): + def get_choices(): + return ("es", "Spanish"), ("en", "English") + + field = models.CharField(help_text="Language", choices=get_choices) + + class TranslatedModel(models.Model): + language = field + + class Meta: + app_label = "test" + + graphene_type = convert_django_field_with_choices(field).type.of_type + assert graphene_type._meta.name == "TestTranslatedModelLanguageChoices" + assert graphene_type._meta.enum.__members__["ES"].value == "es" + assert graphene_type._meta.enum.__members__["ES"].description == "Spanish" + assert graphene_type._meta.enum.__members__["EN"].value == "en" + assert graphene_type._meta.enum.__members__["EN"].description == "English" + + def test_field_with_grouped_choices(): field = models.CharField( help_text="Language", diff --git a/graphene_django/tests/test_fields.py b/graphene_django/tests/test_fields.py index d1c119cc4..caaa6ddf7 100644 --- a/graphene_django/tests/test_fields.py +++ b/graphene_django/tests/test_fields.py @@ -2,27 +2,34 @@ import re import pytest -from django.db.models import Count, Prefetch +from django.db.models import Count, Model, Prefetch from graphene import List, NonNull, ObjectType, Schema, String +from graphene.relay import Node -from ..fields import DjangoListField +from ..fields import DjangoConnectionField, DjangoListField from ..types import DjangoObjectType from .models import ( Article as ArticleModel, Film as FilmModel, FilmDetails as FilmDetailsModel, + Person as PersonModel, Reporter as ReporterModel, ) class TestDjangoListField: def test_only_django_object_types(self): - class TestType(ObjectType): - foo = String() + class Query(ObjectType): + something = DjangoListField(String) + + with pytest.raises(TypeError) as excinfo: + Schema(query=Query) - with pytest.raises(AssertionError): - DjangoListField(TestType) + assert ( + "Query fields cannot be resolved. DjangoListField only accepts DjangoObjectType types as underlying type" + in str(excinfo.value) + ) def test_only_import_paths(self): list_field = DjangoListField("graphene_django.tests.schema.Human") @@ -262,6 +269,69 @@ class Query(ObjectType): ] } + def test_same_type_nested_list_field(self): + class Person(DjangoObjectType): + class Meta: + model = PersonModel + fields = ("name", "parent") + + children = DjangoListField(lambda: Person) + + class Query(ObjectType): + persons = DjangoListField(Person) + + schema = Schema(query=Query) + + query = """ + query { + persons { + name + children { + name + } + } + } + """ + + p1 = PersonModel.objects.create(name="Tara") + PersonModel.objects.create(name="Debra") + + PersonModel.objects.create( + name="Toto", + parent=p1, + ) + PersonModel.objects.create( + name="Tata", + parent=p1, + ) + + result = schema.execute(query) + + assert not result.errors + assert result.data == { + "persons": [ + { + "name": "Tara", + "children": [ + {"name": "Toto"}, + {"name": "Tata"}, + ], + }, + { + "name": "Debra", + "children": [], + }, + { + "name": "Toto", + "children": [], + }, + { + "name": "Tata", + "children": [], + }, + ] + } + def test_get_queryset_filter(self): class Reporter(DjangoObjectType): class Meta: @@ -647,3 +717,34 @@ def resolve_articles(root, info): r'SELECT .* FROM "tests_film" INNER JOIN "tests_film_reporters" .* LEFT OUTER JOIN "tests_filmdetails"', captured.captured_queries[1]["sql"], ) + + +class TestDjangoConnectionField: + def test_model_ordering_assertion(self): + class Chaos(Model): + class Meta: + app_label = "test" + + class ChaosType(DjangoObjectType): + class Meta: + model = Chaos + interfaces = (Node,) + + class Query(ObjectType): + chaos = DjangoConnectionField(ChaosType) + + with pytest.raises( + TypeError, + match=r"Django model test\.Chaos has to have a default ordering to be used in a Connection\.", + ): + Schema(query=Query) + + def test_only_django_object_types(self): + class Query(ObjectType): + something = DjangoConnectionField(String) + + with pytest.raises( + TypeError, + match="DjangoConnectionField only accepts DjangoObjectType types as underlying type", + ): + Schema(query=Query) diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py index 5c36bb92e..72514d23b 100644 --- a/graphene_django/tests/test_types.py +++ b/graphene_django/tests/test_types.py @@ -1,3 +1,4 @@ +import warnings from collections import OrderedDict, defaultdict from textwrap import dedent from unittest.mock import patch @@ -399,7 +400,7 @@ class Meta: with pytest.warns( UserWarning, match=r"Field name .* matches an attribute on Django model .* but it's not a model field", - ) as record: + ): class Reporter2(DjangoObjectType): class Meta: @@ -407,7 +408,8 @@ class Meta: fields = ["first_name", "some_method", "email"] # Don't warn if selecting a custom field - with pytest.warns(None) as record: + with warnings.catch_warnings(): + warnings.simplefilter("error") class Reporter3(DjangoObjectType): custom_field = String() @@ -416,8 +418,6 @@ class Meta: model = ReporterModel fields = ["first_name", "custom_field", "email"] - assert len(record) == 0 - @with_local_registry def test_django_objecttype_exclude_fields_exist_on_model(): @@ -445,15 +445,14 @@ class Meta: exclude = ["custom_field"] # Don't warn on exclude fields - with pytest.warns(None) as record: + with warnings.catch_warnings(): + warnings.simplefilter("error") class Reporter4(DjangoObjectType): class Meta: model = ReporterModel exclude = ["email", "first_name"] - assert len(record) == 0 - @with_local_registry def test_django_objecttype_neither_fields_nor_exclude(): @@ -467,24 +466,22 @@ class Reporter(DjangoObjectType): class Meta: model = ReporterModel - with pytest.warns(None) as record: + with warnings.catch_warnings(): + warnings.simplefilter("error") class Reporter2(DjangoObjectType): class Meta: model = ReporterModel fields = ["email"] - assert len(record) == 0 - - with pytest.warns(None) as record: + with warnings.catch_warnings(): + warnings.simplefilter("error") class Reporter3(DjangoObjectType): class Meta: model = ReporterModel exclude = ["email"] - assert len(record) == 0 - def custom_enum_name(field): return f"CustomEnum{field.name.title()}"