From 894ea92bd0e907d33639b22825669a6db14cae0b Mon Sep 17 00:00:00 2001 From: Justinas Kuizinas Date: Wed, 29 Jul 2020 10:24:08 +0300 Subject: [PATCH 1/5] Added global settings. Fixed relation without related_name ObjectType creation. Added possibility to turn off auto related-name generation for mutations --- .gitignore | 3 + graphene_django_plus/mutations.py | 15 +++- graphene_django_plus/settings.py | 124 +++++++++++++++++++++++++++ tests/models.py | 14 +++ tests/schema.py | 9 ++ tests/test_mutations.py | 137 ++++++++++++++++++++++++++++++ tests/test_settings.py | 20 +++++ 7 files changed, 318 insertions(+), 4 deletions(-) create mode 100644 graphene_django_plus/settings.py create mode 100644 tests/test_settings.py diff --git a/.gitignore b/.gitignore index aa89c93..0a3da70 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,9 @@ wheels/ *.egg MANIFEST +# ide files +.idea/* + # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. diff --git a/graphene_django_plus/mutations.py b/graphene_django_plus/mutations.py index 5fec634..55778eb 100644 --- a/graphene_django_plus/mutations.py +++ b/graphene_django_plus/mutations.py @@ -36,6 +36,7 @@ check_perms, check_authenticated, ) +from .settings import graphene_django_plus_settings from .utils import ( get_node, get_nodes, @@ -94,7 +95,7 @@ def _get_fields(model, only_fields, exclude_fields, required_fields): # can be set to null, otherwise updates won't work. fields.extend( [ - (field.related_name, field) + (field.related_name or field.name + "_set", field) for field in sorted( list(model._meta.related_objects), key=lambda field: field.name, @@ -102,6 +103,7 @@ def _get_fields(model, only_fields, exclude_fields, required_fields): if not isinstance(field, ManyToOneRel) or field.remote_field.null ], ) + print(fields) ret = collections.OrderedDict() for name, field in fields: @@ -133,6 +135,11 @@ def _get_fields(model, only_fields, exclude_fields, required_fields): description=field.help_text, ) elif isinstance(field, (ManyToOneRel, ManyToManyRel)): + reverse_relations_include = graphene_django_plus_settings.MUTATIONS_INCLUDE_REVERSE_RELATIONS + # Explicitly checking whether it was globally configured to not include reverse relations + if isinstance(field, ManyToOneRel) and not reverse_relations_include and not only_fields: + continue + ret[name] = graphene.List( graphene.ID, description='Set list of {0}'.format( @@ -573,7 +580,7 @@ def perform_mutation(cls, root, info, **data): cls.clean_instance(instance, cleaned_input) cls.save(info, instance, cleaned_input) - # save m2m data + # save m2m and related object's data for f in itertools.chain( instance._meta.many_to_many, instance._meta.related_objects, @@ -581,9 +588,9 @@ def perform_mutation(cls, root, info, **data): ): if isinstance(f, (ManyToOneRel, ManyToManyRel)): # Handle reverse side relationships. - d = cleaned_input.get(f.related_name, None) + d = cleaned_input.get(f.related_name or f.name + "_set", None) if d is not None: - target_field = getattr(instance, f.related_name) + target_field = getattr(instance, f.related_name or f.name + "_set") target_field.set(d) elif hasattr(f, 'save_form_data'): d = cleaned_input.get(f.name, None) diff --git a/graphene_django_plus/settings.py b/graphene_django_plus/settings.py new file mode 100644 index 0000000..afa20ac --- /dev/null +++ b/graphene_django_plus/settings.py @@ -0,0 +1,124 @@ +""" +Settings for graphene-django-plus are all namespaced in the GRAPHENE_DJANGO_PLUS setting. +For example your project's `settings.py` file might look like this: +GRAPHENE_DJANGO_PLUS = { + 'MUTATIONS_INCLUDE_REVERSE_RELATIONS': False +} +This module provides the `graphene_django_plus_settings` object, that is used to access +graphene-django-plus settings, checking for user settings first, then falling +back to the defaults. +""" +from __future__ import unicode_literals + +import six +from django.conf import settings +from django.test.signals import setting_changed + +import importlib + + +# Copied shamelessly from Django REST Framework and graphene-django + +DEFAULTS = { + "MUTATIONS_INCLUDE_REVERSE_RELATIONS": True, +} + +# List of settings that may be in string import notation. +IMPORT_STRINGS = [] + + +def perform_import(val, setting_name): + """ + If the given setting is a string import notation, + then perform the necessary import or imports. + """ + if val is None: + return None + elif isinstance(val, six.string_types): + return import_from_string(val, setting_name) + elif isinstance(val, (list, tuple)): + return [import_from_string(item, setting_name) for item in val] + return val + + +def import_from_string(val, setting_name): + """ + Attempt to import a class from a string representation. + """ + try: + # Nod to tastypie's use of importlib. + parts = val.split(".") + module_path, class_name = ".".join(parts[:-1]), parts[-1] + module = importlib.import_module(module_path) + return getattr(module, class_name) + except (ImportError, AttributeError) as e: + msg = "Could not import '%s' for graphene-django-plus setting '%s'. %s: %s." % ( + val, + setting_name, + e.__class__.__name__, + e, + ) + raise ImportError(msg) + + +class GrapheneDjangoPlusSettings(object): + """ + A settings object, that allows API settings to be accessed as properties. + For example: + from graphene_django_plus.settings import settings + print(settings.MUTATIONS_INCLUDE_REVERSE_RELATIONS) + Any setting with string import paths will be automatically resolved + and return the class, rather than the string literal. + """ + + def __init__(self, user_settings=None, defaults=None, import_strings=None): + if user_settings: + self._user_settings = user_settings + self.defaults = defaults or DEFAULTS + self.import_strings = import_strings or IMPORT_STRINGS + self._cached_attrs = set() + + @property + def user_settings(self): + if not hasattr(self, "_user_settings"): + self._user_settings = getattr(settings, "GRAPHENE_DJANGO_PLUS", {}) + return self._user_settings + + def reload(self): + for attr in self._cached_attrs: + delattr(self, attr) + self._cached_attrs.clear() + if hasattr(self, '_user_settings'): + delattr(self, '_user_settings') + + def __getattr__(self, attr): + if attr not in self.defaults: + raise AttributeError("Invalid graphene-django-plus setting: '%s'" % attr) + + try: + # Check if present in user settings + val = self.user_settings[attr] + except KeyError: + # Fall back to defaults + val = self.defaults[attr] + + # Coerce import strings into classes + if attr in self.import_strings: + val = perform_import(val, attr) + + # Cache the result + self._cached_attrs.add(attr) + setattr(self, attr, val) + return val + + +graphene_django_plus_settings = GrapheneDjangoPlusSettings(None, DEFAULTS, IMPORT_STRINGS) + + +def reload_graphene_django_plus_settings(*args, **kwargs): + setting = kwargs["setting"] + if setting == "GRAPHENE_DJANGO_PLUS": + graphene_django_plus_settings.reload() + + +setting_changed.connect(reload_graphene_django_plus_settings) diff --git a/tests/models.py b/tests/models.py index 2ae07b1..1fbe5e4 100644 --- a/tests/models.py +++ b/tests/models.py @@ -56,3 +56,17 @@ class Meta: default=None, on_delete=models.SET_NULL, ) + + +class MilestoneComment(models.Model): + + text = models.CharField( + max_length=255, + ) + milestone = models.ForeignKey( + Milestone, + null=True, + blank=True, + default=None, + on_delete=models.SET_NULL, + ) diff --git a/tests/schema.py b/tests/schema.py index c86138f..c098005 100644 --- a/tests/schema.py +++ b/tests/schema.py @@ -16,6 +16,7 @@ Project, Milestone, Issue, + MilestoneComment, ) # Types @@ -48,6 +49,14 @@ class Meta: filter_fields = {} +class MilestoneCommentType(ModelType): + class Meta: + model = MilestoneComment + connection_class = CountableConnection + interfaces = [relay.Node] + filter_fields = {} + + # Queries diff --git a/tests/test_mutations.py b/tests/test_mutations.py index 86c1863..080376e 100644 --- a/tests/test_mutations.py +++ b/tests/test_mutations.py @@ -1,11 +1,16 @@ import base64 import json +from django.test.utils import override_settings +import graphene +from graphene_django_plus.mutations import ModelCreateMutation + from .base import BaseTestCase from .models import ( Project, Milestone, Issue, + MilestoneComment, ) @@ -397,3 +402,135 @@ def test_remove_all_milestone_issues(self): } } ) + + +class TestMutationRelatedObjectsWithOverrideSettings(BaseTestCase): + """Tests for creating and updating reverse side of FK and M2M relationships.""" + + @override_settings(GRAPHENE_DJANGO_PLUS={'MUTATIONS_INCLUDE_REVERSE_RELATIONS': False}) + def test_create_milestone_issues_turned_off_related_setting(self): + """Test that a milestone can be created with a list of issues.""" + + milestone = 'release_1A' + self.assertIsNone(Milestone.objects.filter(name=milestone).first()) + + project_id = base64.b64encode('ProjectType:{}'.format( + self.project.id, + ).encode()).decode() + issue_id = base64.b64encode('IssueType:{}'.format( + self.issues[0].id, + ).encode()).decode() + + # Creating schema inside test run so that settings would be already modified + # since schema is generated on server start, not no execution + class MilestoneCreateMutation(ModelCreateMutation): + class Meta: + model = Milestone + + class Mutation(graphene.ObjectType): + milestone_create = MilestoneCreateMutation.Field() + + schema = graphene.Schema( + mutation=Mutation, + ) + query = """ + mutation milestoneCreate { + milestoneCreate (input: { + name: "%s", + project: "%s", + issues: ["%s"] + }) { + milestone { + name + issues { + edges { + node { + name + } + } + } + } + } + } + """ % (milestone, project_id, issue_id) + result = schema.execute(query) + self.assertIsNone(Milestone.objects.filter(name=milestone).first()) + self.assertTrue('Unknown field.' in result.errors[0].message) + + def test_create_milestone_issues_with_comments_without_related_name(self): + comment = MilestoneComment.objects.create( + text="Milestone Comment", + milestone=self.milestone_1, + ) + + milestone = 'release_1A' + self.assertIsNone(Milestone.objects.filter(name=milestone).first()) + + project_id = base64.b64encode('ProjectType:{}'.format( + self.project.id, + ).encode()).decode() + issue_id = base64.b64encode('IssueType:{}'.format( + self.issues[0].id, + ).encode()).decode() + + comment_id = base64.b64encode('MilestoneCommentType:{}'.format( + comment.id, + ).encode()).decode() + + r = self.query( + """ + mutation milestoneCreate { + milestoneCreate (input: { + name: "%s", + project: "%s", + issues: ["%s"], + milestonecommentSet: ["%s"], + }) { + milestone { + name + issues { + edges { + node { + name + } + } + } + milestonecommentSet { + edges { + node { + text + } + } + } + } + } + } + """ % (milestone, project_id, issue_id, comment_id), + op_name='milestoneCreate', + ) + self.assertIsNotNone(Milestone.objects.filter(name=milestone).first()) + self.assertEqual( + json.loads(r.content), + {'data': { + 'milestoneCreate': { + 'milestone': { + 'name': 'release_1A', + 'issues': { + 'edges': [{ + 'node': { + 'name': 'Issue 1' + }, + }] + }, + 'milestonecommentSet': { + 'edges': [{ + 'node': { + 'text': 'Milestone Comment' + }, + }] + }, + } + } + } + } + ) diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..8357b99 --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,20 @@ +from django.test import TestCase, override_settings + +from graphene_django_plus.settings import graphene_django_plus_settings + + +class TestSettings(TestCase): + + def test_compatibility_with_override_settings(self): + self.assertTrue( + graphene_django_plus_settings.MUTATIONS_INCLUDE_REVERSE_RELATIONS + ) + + with override_settings(GRAPHENE_DJANGO_PLUS={'MUTATIONS_INCLUDE_REVERSE_RELATIONS': False}): + self.assertFalse( + graphene_django_plus_settings.MUTATIONS_INCLUDE_REVERSE_RELATIONS + ) # Setting should have been updated + + self.assertTrue( + graphene_django_plus_settings.MUTATIONS_INCLUDE_REVERSE_RELATIONS + ) # Setting should have been restored From a65106e1cf6cd9b72968ca6efaac32499eff78dd Mon Sep 17 00:00:00 2001 From: Justinas Kuizinas Date: Wed, 29 Jul 2020 10:28:16 +0300 Subject: [PATCH 2/5] Flake updates --- graphene_django_plus/mutations.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/graphene_django_plus/mutations.py b/graphene_django_plus/mutations.py index 55778eb..640af64 100644 --- a/graphene_django_plus/mutations.py +++ b/graphene_django_plus/mutations.py @@ -103,7 +103,6 @@ def _get_fields(model, only_fields, exclude_fields, required_fields): if not isinstance(field, ManyToOneRel) or field.remote_field.null ], ) - print(fields) ret = collections.OrderedDict() for name, field in fields: @@ -135,9 +134,9 @@ def _get_fields(model, only_fields, exclude_fields, required_fields): description=field.help_text, ) elif isinstance(field, (ManyToOneRel, ManyToManyRel)): - reverse_relations_include = graphene_django_plus_settings.MUTATIONS_INCLUDE_REVERSE_RELATIONS - # Explicitly checking whether it was globally configured to not include reverse relations - if isinstance(field, ManyToOneRel) and not reverse_relations_include and not only_fields: + reverse_rel_include = graphene_django_plus_settings.MUTATIONS_INCLUDE_REVERSE_RELATIONS + # Checking whether it was globally configured to not include reverse relations + if isinstance(field, ManyToOneRel) and not reverse_rel_include and not only_fields: continue ret[name] = graphene.List( From 09b0ef67550c4f981c032556edad1cd2e47e8ec7 Mon Sep 17 00:00:00 2001 From: Justinas Kuizinas Date: Wed, 29 Jul 2020 10:32:27 +0300 Subject: [PATCH 3/5] Added entry to README --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 224af9b..67709be 100644 --- a/README.md +++ b/README.md @@ -292,6 +292,13 @@ mutation { Any validation errors will be presented in the `errors` return value. +To turn off auto related relations addition to the mutation input - set global `MUTATIONS_INCLUDE_REVERSE_RELATIONS` parameter to `False` in your `settings.py`: +```py +GRAPHENE_DJANGO_PLUS = { + 'MUTATIONS_INCLUDE_REVERSE_RELATIONS': False +} +``` + ## License This project is licensed under MIT licence (see `LICENSE` for more info) From c97b9a2687f9420273517858b6df2ab489c0afb5 Mon Sep 17 00:00:00 2001 From: Justinas Kuizinas Date: Wed, 29 Jul 2020 12:22:44 +0300 Subject: [PATCH 4/5] PR updates --- README.md | 2 +- tests/models.py | 1 - tests/test_mutations.py | 33 ++++++++++++--------------------- 3 files changed, 13 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 67709be..14ffa24 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ query { Here is an example describing how to use those: -```py +``` import graphene from graphene import relay diff --git a/tests/models.py b/tests/models.py index 1fbe5e4..9df0b19 100644 --- a/tests/models.py +++ b/tests/models.py @@ -67,6 +67,5 @@ class MilestoneComment(models.Model): Milestone, null=True, blank=True, - default=None, on_delete=models.SET_NULL, ) diff --git a/tests/test_mutations.py b/tests/test_mutations.py index 080376e..cc9eff9 100644 --- a/tests/test_mutations.py +++ b/tests/test_mutations.py @@ -3,7 +3,9 @@ from django.test.utils import override_settings import graphene +from graphql_relay import to_global_id from graphene_django_plus.mutations import ModelCreateMutation +from tests.schema import MilestoneCommentType, IssueType, ProjectType from .base import BaseTestCase from .models import ( @@ -412,17 +414,13 @@ def test_create_milestone_issues_turned_off_related_setting(self): """Test that a milestone can be created with a list of issues.""" milestone = 'release_1A' - self.assertIsNone(Milestone.objects.filter(name=milestone).first()) + self.assertIsNone(Milestone.objects.filter(name=milestone).exists()) - project_id = base64.b64encode('ProjectType:{}'.format( - self.project.id, - ).encode()).decode() - issue_id = base64.b64encode('IssueType:{}'.format( - self.issues[0].id, - ).encode()).decode() + project_id = to_global_id(ProjectType.__name__, self.project.id) + issue_id = to_global_id(IssueType.__name__, self.issues[0].id) # Creating schema inside test run so that settings would be already modified - # since schema is generated on server start, not no execution + # since schema is generated on server start, not on execution class MilestoneCreateMutation(ModelCreateMutation): class Meta: model = Milestone @@ -454,7 +452,7 @@ class Mutation(graphene.ObjectType): } """ % (milestone, project_id, issue_id) result = schema.execute(query) - self.assertIsNone(Milestone.objects.filter(name=milestone).first()) + self.assertIsNone(Milestone.objects.filter(name=milestone).exists()) self.assertTrue('Unknown field.' in result.errors[0].message) def test_create_milestone_issues_with_comments_without_related_name(self): @@ -464,18 +462,11 @@ def test_create_milestone_issues_with_comments_without_related_name(self): ) milestone = 'release_1A' - self.assertIsNone(Milestone.objects.filter(name=milestone).first()) + self.assertIsNone(Milestone.objects.filter(name=milestone).exists()) - project_id = base64.b64encode('ProjectType:{}'.format( - self.project.id, - ).encode()).decode() - issue_id = base64.b64encode('IssueType:{}'.format( - self.issues[0].id, - ).encode()).decode() - - comment_id = base64.b64encode('MilestoneCommentType:{}'.format( - comment.id, - ).encode()).decode() + project_id = to_global_id(ProjectType.__name__, self.project.id) + issue_id = to_global_id(IssueType.__name__, self.issues[0].id) + comment_id = to_global_id(MilestoneCommentType.__name__, comment.id) r = self.query( """ @@ -508,7 +499,7 @@ def test_create_milestone_issues_with_comments_without_related_name(self): """ % (milestone, project_id, issue_id, comment_id), op_name='milestoneCreate', ) - self.assertIsNotNone(Milestone.objects.filter(name=milestone).first()) + self.assertIsNotNone(Milestone.objects.filter(name=milestone).exists()) self.assertEqual( json.loads(r.content), {'data': { From 608f8e8ae4aedbec53181937b7c45972e6d7c4b0 Mon Sep 17 00:00:00 2001 From: Justinas Kuizinas Date: Wed, 29 Jul 2020 12:25:39 +0300 Subject: [PATCH 5/5] Readme update --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 14ffa24..84407e3 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ query { Here is an example describing how to use those: -``` +```py import graphene from graphene import relay @@ -293,12 +293,14 @@ mutation { Any validation errors will be presented in the `errors` return value. To turn off auto related relations addition to the mutation input - set global `MUTATIONS_INCLUDE_REVERSE_RELATIONS` parameter to `False` in your `settings.py`: -```py +``` GRAPHENE_DJANGO_PLUS = { 'MUTATIONS_INCLUDE_REVERSE_RELATIONS': False } ``` +Note: in case reverse relation does not have `related_name` attribute set - mutation input will be generated as Django itself is generating by appending `_set` to the lower cased model name - `modelname_set` + ## License This project is licensed under MIT licence (see `LICENSE` for more info)