From 6a07f66cfc5f9215d192669a180d6858f6cadffa Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 8 Jun 2021 13:52:39 -0400 Subject: [PATCH 01/21] Initial work on GraphQL --- base_requirements.txt | 8 ++++ netbox/circuits/graphql/schema.py | 21 ++++++++++ netbox/circuits/graphql/types.py | 54 +++++++++++++++++++++++++ netbox/netbox/graphql/__init__.py | 11 ++++++ netbox/netbox/graphql/fields.py | 65 +++++++++++++++++++++++++++++++ netbox/netbox/graphql/schema.py | 13 +++++++ netbox/netbox/graphql/types.py | 41 +++++++++++++++++++ netbox/netbox/graphql/utils.py | 25 ++++++++++++ netbox/netbox/settings.py | 4 +- netbox/netbox/urls.py | 5 +++ netbox/project-static/volt | 1 + requirements.txt | 2 + 12 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 netbox/circuits/graphql/schema.py create mode 100644 netbox/circuits/graphql/types.py create mode 100644 netbox/netbox/graphql/__init__.py create mode 100644 netbox/netbox/graphql/fields.py create mode 100644 netbox/netbox/graphql/schema.py create mode 100644 netbox/netbox/graphql/types.py create mode 100644 netbox/netbox/graphql/utils.py create mode 160000 netbox/project-static/volt diff --git a/base_requirements.txt b/base_requirements.txt index bf03bf71e..80f08f894 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -18,6 +18,10 @@ django-debug-toolbar # https://github.com/carltongibson/django-filter django-filter +# Django debug toolbar extension with support for GraphiQL +# https://github.com/flavors/django-graphiql-debug-toolbar/ +django-graphiql-debug-toolbar + # Modified Preorder Tree Traversal (recursive nesting of objects) # https://github.com/django-mptt/django-mptt django-mptt @@ -54,6 +58,10 @@ djangorestframework # https://github.com/axnsan12/drf-yasg drf-yasg[validation] +# Django wrapper for Graphene (GraphQL support) +# https://github.com/graphql-python/graphene-django +graphene_django + # WSGI HTTP server # https://gunicorn.org/ gunicorn diff --git a/netbox/circuits/graphql/schema.py b/netbox/circuits/graphql/schema.py new file mode 100644 index 000000000..5a3f42270 --- /dev/null +++ b/netbox/circuits/graphql/schema.py @@ -0,0 +1,21 @@ +import graphene + +from netbox.graphql.fields import ObjectField, ObjectListField +from .types import * + + +class CircuitsQuery(graphene.ObjectType): + circuit = ObjectField(CircuitType) + circuits = ObjectListField(CircuitType) + + circuit_termination = ObjectField(CircuitTerminationType) + circuit_terminations = ObjectListField(CircuitTerminationType) + + circuit_type = ObjectField(CircuitTypeType) + circuit_types = ObjectListField(CircuitTypeType) + + provider = ObjectField(ProviderType) + providers = ObjectListField(ProviderType) + + provider_network = ObjectField(ProviderNetworkType) + provider_networks = ObjectListField(ProviderNetworkType) diff --git a/netbox/circuits/graphql/types.py b/netbox/circuits/graphql/types.py new file mode 100644 index 000000000..51ef8082b --- /dev/null +++ b/netbox/circuits/graphql/types.py @@ -0,0 +1,54 @@ +from circuits import filtersets, models +from netbox.graphql.types import * + +__all__ = ( + 'CircuitType', + 'CircuitTerminationType', + 'CircuitTypeType', + 'ProviderType', + 'ProviderNetworkType', +) + + +# +# Object types +# + +class ProviderType(TaggedObjectType): + + class Meta: + model = models.Provider + fields = '__all__' + filterset_class = filtersets.ProviderFilterSet + + +class ProviderNetworkType(TaggedObjectType): + + class Meta: + model = models.ProviderNetwork + fields = '__all__' + filterset_class = filtersets.ProviderNetworkFilterSet + + +class CircuitType(TaggedObjectType): + + class Meta: + model = models.Circuit + fields = '__all__' + filterset_class = filtersets.CircuitFilterSet + + +class CircuitTypeType(ObjectType): + + class Meta: + model = models.CircuitType + fields = '__all__' + filterset_class = filtersets.CircuitTypeFilterSet + + +class CircuitTerminationType(BaseObjectType): + + class Meta: + model = models.CircuitTermination + fields = '__all__' + filterset_class = filtersets.CircuitTerminationFilterSet diff --git a/netbox/netbox/graphql/__init__.py b/netbox/netbox/graphql/__init__.py new file mode 100644 index 000000000..6316a2390 --- /dev/null +++ b/netbox/netbox/graphql/__init__.py @@ -0,0 +1,11 @@ +import graphene +from graphene_django.converter import convert_django_field +from taggit.managers import TaggableManager + + +@convert_django_field.register(TaggableManager) +def convert_field_to_tags_list(field, registry=None): + """ + Register conversion handler for django-taggit's TaggableManager + """ + return graphene.List(graphene.String) diff --git a/netbox/netbox/graphql/fields.py b/netbox/netbox/graphql/fields.py new file mode 100644 index 000000000..e3ef39f4a --- /dev/null +++ b/netbox/netbox/graphql/fields.py @@ -0,0 +1,65 @@ +from functools import partial + +import graphene +from graphene_django import DjangoListField + +from .utils import get_graphene_type + +__all__ = ( + 'ObjectField', + 'ObjectListField', +) + + +class ObjectField(graphene.Field): + """ + Retrieve a single object, identified by its numeric ID. + """ + def __init__(self, *args, **kwargs): + + if 'id' not in kwargs: + kwargs['id'] = graphene.Int(required=True) + + super().__init__(*args, **kwargs) + + @staticmethod + def object_resolver(django_object_type, root, info, **args): + """ + Return an object given its numeric ID. + """ + manager = django_object_type._meta.model._default_manager + queryset = django_object_type.get_queryset(manager, info) + + return queryset.get(**args) + + def get_resolver(self, parent_resolver): + return partial(self.object_resolver, self._type) + + +class ObjectListField(DjangoListField): + """ + Retrieve a list of objects, optionally filtered by one or more FilterSet filters. + """ + def __init__(self, _type, *args, **kwargs): + + assert hasattr(_type._meta, 'filterset_class'), "DjangoFilterListField must define filterset_class under Meta" + filterset_class = _type._meta.filterset_class + + # Get FilterSet kwargs + filter_kwargs = {} + for filter_name, filter_field in filterset_class.get_filters().items(): + field_type = get_graphene_type(type(filter_field)) + filter_kwargs[filter_name] = graphene.Argument(field_type) + + super().__init__(_type, args=filter_kwargs, *args, **kwargs) + + @staticmethod + def list_resolver(django_object_type, resolver, default_manager, root, info, **args): + # Get the QuerySet from the object type + queryset = django_object_type.get_queryset(default_manager, info) + + # Instantiate and apply the FilterSet + filterset_class = django_object_type._meta.filterset_class + filterset = filterset_class(data=args, queryset=queryset, request=info.context) + + return filterset.qs diff --git a/netbox/netbox/graphql/schema.py b/netbox/netbox/graphql/schema.py new file mode 100644 index 000000000..04d836988 --- /dev/null +++ b/netbox/netbox/graphql/schema.py @@ -0,0 +1,13 @@ +import graphene + +from circuits.graphql.schema import CircuitsQuery + + +class Query( + CircuitsQuery, + graphene.ObjectType +): + pass + + +schema = graphene.Schema(query=Query, auto_camelcase=False) diff --git a/netbox/netbox/graphql/types.py b/netbox/netbox/graphql/types.py new file mode 100644 index 000000000..7ec152fac --- /dev/null +++ b/netbox/netbox/graphql/types.py @@ -0,0 +1,41 @@ +import graphene +from graphene_django import DjangoObjectType + +__all__ = ( + 'BaseObjectType', + 'ObjectType', + 'TaggedObjectType', +) + + +class BaseObjectType(DjangoObjectType): + """ + Base GraphQL object type for all NetBox objects + """ + class Meta: + abstract = True + + @classmethod + def get_queryset(cls, queryset, info): + # Enforce object permissions on the queryset + return queryset.restrict(info.context.user, 'view') + + +class ObjectType(BaseObjectType): + # TODO: Custom fields support + + class Meta: + abstract = True + + +class TaggedObjectType(ObjectType): + """ + Extends ObjectType with support for Tags + """ + tags = graphene.List(graphene.String) + + class Meta: + abstract = True + + def resolve_tags(self, info): + return self.tags.all() diff --git a/netbox/netbox/graphql/utils.py b/netbox/netbox/graphql/utils.py new file mode 100644 index 000000000..c71d49204 --- /dev/null +++ b/netbox/netbox/graphql/utils.py @@ -0,0 +1,25 @@ +import graphene +from django_filters import filters + + +def get_graphene_type(filter_cls): + """ + Return the appropriate Graphene scalar type for a django_filters Filter + """ + if issubclass(filter_cls, filters.BooleanFilter): + field_type = graphene.Boolean + elif issubclass(filter_cls, filters.NumberFilter): + # TODO: Floats? BigInts? + field_type = graphene.Int + elif issubclass(filter_cls, filters.DateFilter): + field_type = graphene.Date + elif issubclass(filter_cls, filters.DateTimeFilter): + field_type = graphene.DateTime + else: + field_type = graphene.String + + # Multi-value filters should be handled as lists + if issubclass(filter_cls, filters.MultipleChoiceFilter): + return graphene.List(field_type) + + return field_type diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index c3fa08e15..2552bb76f 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -282,9 +282,11 @@ INSTALLED_APPS = [ 'cacheops', 'corsheaders', 'debug_toolbar', + 'graphiql_debug_toolbar', 'django_filters', 'django_tables2', 'django_prometheus', + 'graphene_django', 'mptt', 'rest_framework', 'taggit', @@ -303,7 +305,7 @@ INSTALLED_APPS = [ # Middleware MIDDLEWARE = [ - 'debug_toolbar.middleware.DebugToolbarMiddleware', + 'graphiql_debug_toolbar.middleware.DebugToolbarMiddleware', 'django_prometheus.middleware.PrometheusBeforeMiddleware', 'corsheaders.middleware.CorsMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 54d002d5d..9257f12b9 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -4,9 +4,11 @@ from django.urls import path, re_path from django.views.static import serve from drf_yasg import openapi from drf_yasg.views import get_schema_view +from graphene_django.views import GraphQLView from extras.plugins.urls import plugin_admin_patterns, plugin_patterns, plugin_api_patterns from netbox.api.views import APIRootView, StatusView +from netbox.graphql.schema import schema from netbox.views import HomeView, StaticMediaFailureView, SearchView from users.views import LoginView, LogoutView from .admin import admin_site @@ -60,6 +62,9 @@ _patterns = [ path('api/redoc/', schema_view.with_ui('redoc'), name='api_redocs'), re_path(r'^api/swagger(?P.json|.yaml)$', schema_view.without_ui(), name='schema_swagger'), + # GraphQL + path('graphql/', GraphQLView.as_view(graphiql=True, schema=schema)), + # Serving static media in Django to pipe it through LoginRequiredMiddleware path('media/', serve, {'document_root': settings.MEDIA_ROOT}), path('media-failure/', StaticMediaFailureView.as_view(), name='media_failure'), diff --git a/netbox/project-static/volt b/netbox/project-static/volt new file mode 160000 index 000000000..942aa8c7b --- /dev/null +++ b/netbox/project-static/volt @@ -0,0 +1 @@ +Subproject commit 942aa8c7bd506fb88b7c669cab173bc319eca309 diff --git a/requirements.txt b/requirements.txt index 467a48542..48fe53543 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ django-cacheops==6.0 django-cors-headers==3.7.0 django-debug-toolbar==3.2.1 django-filter==2.4.0 +django-graphiql-debug-toolbar==0.1.4 django-mptt==0.12.0 django-pglocks==1.0.4 django-prometheus==2.1.0 @@ -12,6 +13,7 @@ django-taggit==1.4.0 django-timezone-field==4.1.2 djangorestframework==3.12.4 drf-yasg[validation]==1.20.0 +graphene_django==2.15.0 gunicorn==20.1.0 Jinja2==3.0.1 Markdown==3.3.4 From 930ca8d9a1d20019fd8618f8ce92b97beae5ed2b Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 21 Jun 2021 12:13:20 -0400 Subject: [PATCH 02/21] Add support for custom field data --- netbox/netbox/graphql/types.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/netbox/netbox/graphql/types.py b/netbox/netbox/graphql/types.py index 7ec152fac..5a4d16a8f 100644 --- a/netbox/netbox/graphql/types.py +++ b/netbox/netbox/graphql/types.py @@ -1,4 +1,5 @@ import graphene +from graphene.types.generic import GenericScalar from graphene_django import DjangoObjectType __all__ = ( @@ -22,11 +23,17 @@ class BaseObjectType(DjangoObjectType): class ObjectType(BaseObjectType): - # TODO: Custom fields support + """ + Extends BaseObjectType with support for custom field data. + """ + custom_fields = GenericScalar() class Meta: abstract = True + def resolve_custom_fields(self, info): + return self.custom_field_data + class TaggedObjectType(ObjectType): """ From 91d39cc0c00e85aa4a3992bdfa697aa0dba2baf6 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 21 Jun 2021 13:35:52 -0400 Subject: [PATCH 03/21] Added initial GraphQL tests --- netbox/circuits/tests/test_api.py | 10 +++---- netbox/netbox/tests/test_graphql.py | 27 +++++++++++++++++ netbox/netbox/urls.py | 2 +- netbox/utilities/testing/api.py | 45 +++++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 netbox/netbox/tests/test_graphql.py diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index 424b13d40..fd9e87412 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -15,7 +15,7 @@ class AppTest(APITestCase): self.assertEqual(response.status_code, 200) -class ProviderTest(APIViewTestCases.APIViewTestCase): +class ProviderTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = Provider brief_fields = ['circuit_count', 'display', 'id', 'name', 'slug', 'url'] create_data = [ @@ -47,7 +47,7 @@ class ProviderTest(APIViewTestCases.APIViewTestCase): Provider.objects.bulk_create(providers) -class CircuitTypeTest(APIViewTestCases.APIViewTestCase): +class CircuitTypeTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = CircuitType brief_fields = ['circuit_count', 'display', 'id', 'name', 'slug', 'url'] create_data = ( @@ -79,7 +79,7 @@ class CircuitTypeTest(APIViewTestCases.APIViewTestCase): CircuitType.objects.bulk_create(circuit_types) -class CircuitTest(APIViewTestCases.APIViewTestCase): +class CircuitTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = Circuit brief_fields = ['cid', 'display', 'id', 'url'] bulk_update_data = { @@ -127,7 +127,7 @@ class CircuitTest(APIViewTestCases.APIViewTestCase): ] -class CircuitTerminationTest(APIViewTestCases.APIViewTestCase): +class CircuitTerminationTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = CircuitTermination brief_fields = ['_occupied', 'cable', 'circuit', 'display', 'id', 'term_side', 'url'] @@ -180,7 +180,7 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase): } -class ProviderNetworkTest(APIViewTestCases.APIViewTestCase): +class ProviderNetworkTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = ProviderNetwork brief_fields = ['display', 'id', 'name', 'url'] diff --git a/netbox/netbox/tests/test_graphql.py b/netbox/netbox/tests/test_graphql.py new file mode 100644 index 000000000..dd43bbbdd --- /dev/null +++ b/netbox/netbox/tests/test_graphql.py @@ -0,0 +1,27 @@ +from django.test import override_settings +from django.urls import reverse + +from utilities.testing import disable_warnings, TestCase + + +class GraphQLTestCase(TestCase): + + @override_settings(LOGIN_REQUIRED=True) + def test_graphiql_interface(self): + """ + Test rendering of the GraphiQL interactive web interface + """ + url = reverse('graphql') + header = { + 'HTTP_ACCEPT': 'text/html', + } + + # Authenticated request + response = self.client.get(url, **header) + self.assertHttpStatus(response, 200) + + # Non-authenticated request + self.client.logout() + response = self.client.get(url, **header) + with disable_warnings('django.request'): + self.assertHttpStatus(response, 302) diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 9257f12b9..4f1ec38d2 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -63,7 +63,7 @@ _patterns = [ re_path(r'^api/swagger(?P.json|.yaml)$', schema_view.without_ui(), name='schema_swagger'), # GraphQL - path('graphql/', GraphQLView.as_view(graphiql=True, schema=schema)), + path('graphql/', GraphQLView.as_view(graphiql=True, schema=schema), name='graphql'), # Serving static media in Django to pipe it through LoginRequiredMiddleware path('media/', serve, {'document_root': settings.MEDIA_ROOT}), diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index b57c273fd..1a9414dc6 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -1,3 +1,5 @@ +import json + from django.conf import settings from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType @@ -421,6 +423,49 @@ class APIViewTestCases: self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(self._get_queryset().count(), initial_count - 3) + class GraphQLTestCase(APITestCase): + + def test_graphql_get_object(self): + url = reverse('graphql') + object_type = self.model._meta.verbose_name.replace(' ', '_') + object_id = self._get_queryset().first().pk + query = f""" + {{ + {object_type}(id:{object_id}) {{ + id + }} + }} + """ + + # Non-authenticated requests should fail + with disable_warnings('django.request'): + self.assertHttpStatus(self.client.post(url, data={'query': query}), status.HTTP_403_FORBIDDEN) + + response = self.client.post(url, data={'query': query}, **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + data = json.loads(response.content) + self.assertNotIn('errors', data) + + def test_graphql_list_objects(self): + url = reverse('graphql') + object_type = self.model._meta.verbose_name_plural.replace(' ', '_') + query = f""" + {{ + {object_type} {{ + id + }} + }} + """ + + # Non-authenticated requests should fail + with disable_warnings('django.request'): + self.assertHttpStatus(self.client.post(url, data={'query': query}), status.HTTP_403_FORBIDDEN) + + response = self.client.post(url, data={'query': query}, **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + data = json.loads(response.content) + self.assertNotIn('errors', data) + class APIViewTestCase( GetObjectViewTestCase, ListObjectsViewTestCase, From d5675a5d4ae2ce435dc972417020527764730ae6 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 25 Jun 2021 09:13:08 -0400 Subject: [PATCH 04/21] Add support for DRF token authentication --- netbox/netbox/graphql/views.py | 20 ++++++++++++++++++++ netbox/netbox/middleware.py | 3 ++- netbox/netbox/urls.py | 2 +- netbox/utilities/testing/api.py | 21 +++++++++++++++++++++ 4 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 netbox/netbox/graphql/views.py diff --git a/netbox/netbox/graphql/views.py b/netbox/netbox/graphql/views.py new file mode 100644 index 000000000..1cde56cd6 --- /dev/null +++ b/netbox/netbox/graphql/views.py @@ -0,0 +1,20 @@ +from graphene_django.views import GraphQLView as GraphQLView_ +from rest_framework.decorators import authentication_classes, permission_classes, api_view +from rest_framework.permissions import IsAuthenticated +from rest_framework.settings import api_settings + + +class GraphQLView(GraphQLView_): + """ + Extends grpahene_django's GraphQLView to support DRF's token-based authentication. + """ + @classmethod + def as_view(cls, *args, **kwargs): + view = super(GraphQLView, cls).as_view(*args, **kwargs) + + # Apply DRF permission and authentication classes + view = permission_classes((IsAuthenticated,))(view) + view = authentication_classes(api_settings.DEFAULT_AUTHENTICATION_CLASSES)(view) + view = api_view(['GET', 'POST'])(view) + + return view diff --git a/netbox/netbox/middleware.py b/netbox/netbox/middleware.py index d3b3dae40..ef50edc4a 100644 --- a/netbox/netbox/middleware.py +++ b/netbox/netbox/middleware.py @@ -24,7 +24,8 @@ class LoginRequiredMiddleware(object): if settings.LOGIN_REQUIRED and not request.user.is_authenticated: # Determine exempt paths exempt_paths = [ - reverse('api-root') + reverse('api-root'), + reverse('graphql'), ] if settings.METRICS_ENABLED: exempt_paths.append(reverse('prometheus-django-metrics')) diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 4f1ec38d2..06e1eee06 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -4,11 +4,11 @@ from django.urls import path, re_path from django.views.static import serve from drf_yasg import openapi from drf_yasg.views import get_schema_view -from graphene_django.views import GraphQLView from extras.plugins.urls import plugin_admin_patterns, plugin_patterns, plugin_api_patterns from netbox.api.views import APIRootView, StatusView from netbox.graphql.schema import schema +from netbox.graphql.views import GraphQLView from netbox.views import HomeView, StaticMediaFailureView, SearchView from users.views import LoginView, LogoutView from .admin import admin_site diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index 1a9414dc6..ad14c2fdc 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -425,6 +425,7 @@ class APIViewTestCases: class GraphQLTestCase(APITestCase): + @override_settings(LOGIN_REQUIRED=True) def test_graphql_get_object(self): url = reverse('graphql') object_type = self.model._meta.verbose_name.replace(' ', '_') @@ -441,11 +442,21 @@ class APIViewTestCases: with disable_warnings('django.request'): self.assertHttpStatus(self.client.post(url, data={'query': query}), status.HTTP_403_FORBIDDEN) + # Add object-level permission + obj_perm = ObjectPermission( + name='Test permission', + actions=['view'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + response = self.client.post(url, data={'query': query}, **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) data = json.loads(response.content) self.assertNotIn('errors', data) + @override_settings(LOGIN_REQUIRED=True) def test_graphql_list_objects(self): url = reverse('graphql') object_type = self.model._meta.verbose_name_plural.replace(' ', '_') @@ -461,10 +472,20 @@ class APIViewTestCases: with disable_warnings('django.request'): self.assertHttpStatus(self.client.post(url, data={'query': query}), status.HTTP_403_FORBIDDEN) + # Add object-level permission + obj_perm = ObjectPermission( + name='Test permission', + actions=['view'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + response = self.client.post(url, data={'query': query}, **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) data = json.loads(response.content) self.assertNotIn('errors', data) + self.assertGreater(len(data['data'][object_type]), 0) class APIViewTestCase( GetObjectViewTestCase, From 4c209ed74c341fd954bda7e18354d865b2e52011 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 25 Jun 2021 10:14:06 -0400 Subject: [PATCH 05/21] Clean up GraphQL tests --- netbox/circuits/graphql/__init__.py | 0 netbox/circuits/graphql/types.py | 36 +++++++++++++---------------- netbox/netbox/graphql/schema.py | 2 ++ netbox/utilities/testing/api.py | 4 ++-- 4 files changed, 20 insertions(+), 22 deletions(-) create mode 100644 netbox/circuits/graphql/__init__.py diff --git a/netbox/circuits/graphql/__init__.py b/netbox/circuits/graphql/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/circuits/graphql/types.py b/netbox/circuits/graphql/types.py index 51ef8082b..bde25a09f 100644 --- a/netbox/circuits/graphql/types.py +++ b/netbox/circuits/graphql/types.py @@ -1,33 +1,21 @@ from circuits import filtersets, models -from netbox.graphql.types import * +from netbox.graphql.types import BaseObjectType, ObjectType, TaggedObjectType __all__ = ( - 'CircuitType', 'CircuitTerminationType', + 'CircuitType', 'CircuitTypeType', 'ProviderType', 'ProviderNetworkType', ) -# -# Object types -# - -class ProviderType(TaggedObjectType): +class CircuitTerminationType(BaseObjectType): class Meta: - model = models.Provider + model = models.CircuitTermination fields = '__all__' - filterset_class = filtersets.ProviderFilterSet - - -class ProviderNetworkType(TaggedObjectType): - - class Meta: - model = models.ProviderNetwork - fields = '__all__' - filterset_class = filtersets.ProviderNetworkFilterSet + filterset_class = filtersets.CircuitTerminationFilterSet class CircuitType(TaggedObjectType): @@ -46,9 +34,17 @@ class CircuitTypeType(ObjectType): filterset_class = filtersets.CircuitTypeFilterSet -class CircuitTerminationType(BaseObjectType): +class ProviderType(TaggedObjectType): class Meta: - model = models.CircuitTermination + model = models.Provider fields = '__all__' - filterset_class = filtersets.CircuitTerminationFilterSet + filterset_class = filtersets.ProviderFilterSet + + +class ProviderNetworkType(TaggedObjectType): + + class Meta: + model = models.ProviderNetwork + fields = '__all__' + filterset_class = filtersets.ProviderNetworkFilterSet diff --git a/netbox/netbox/graphql/schema.py b/netbox/netbox/graphql/schema.py index 04d836988..ff64416bb 100644 --- a/netbox/netbox/graphql/schema.py +++ b/netbox/netbox/graphql/schema.py @@ -1,10 +1,12 @@ import graphene from circuits.graphql.schema import CircuitsQuery +from ipam.graphql.schema import IPAMQuery class Query( CircuitsQuery, + IPAMQuery, graphene.ObjectType ): pass diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index ad14c2fdc..eff5b9c05 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -428,7 +428,7 @@ class APIViewTestCases: @override_settings(LOGIN_REQUIRED=True) def test_graphql_get_object(self): url = reverse('graphql') - object_type = self.model._meta.verbose_name.replace(' ', '_') + object_type = self.model._meta.verbose_name.lower().replace(' ', '_') object_id = self._get_queryset().first().pk query = f""" {{ @@ -459,7 +459,7 @@ class APIViewTestCases: @override_settings(LOGIN_REQUIRED=True) def test_graphql_list_objects(self): url = reverse('graphql') - object_type = self.model._meta.verbose_name_plural.replace(' ', '_') + object_type = self.model._meta.verbose_name_plural.lower().replace(' ', '_') query = f""" {{ {object_type} {{ From 3e0bcd58d486c24ea2ce3a0cd569cb78d0450e48 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 25 Jun 2021 10:14:35 -0400 Subject: [PATCH 06/21] Add GraphQL for IPAM --- netbox/ipam/graphql/__init__.py | 11 ++++ netbox/ipam/graphql/schema.py | 36 +++++++++++++ netbox/ipam/graphql/types.py | 95 +++++++++++++++++++++++++++++++++ netbox/ipam/tests/test_api.py | 20 +++---- 4 files changed, 152 insertions(+), 10 deletions(-) create mode 100644 netbox/ipam/graphql/__init__.py create mode 100644 netbox/ipam/graphql/schema.py create mode 100644 netbox/ipam/graphql/types.py diff --git a/netbox/ipam/graphql/__init__.py b/netbox/ipam/graphql/__init__.py new file mode 100644 index 000000000..d36399cc0 --- /dev/null +++ b/netbox/ipam/graphql/__init__.py @@ -0,0 +1,11 @@ +import graphene +from graphene_django.converter import convert_django_field + +from ipam.fields import IPAddressField, IPNetworkField + + +@convert_django_field.register(IPAddressField) +@convert_django_field.register(IPNetworkField) +def convert_field_to_string(field, registry=None): + # TODO: Update to use get_django_field_description under django_graphene v3.0 + return graphene.String(description=field.help_text, required=not field.null) diff --git a/netbox/ipam/graphql/schema.py b/netbox/ipam/graphql/schema.py new file mode 100644 index 000000000..d0ba11fc7 --- /dev/null +++ b/netbox/ipam/graphql/schema.py @@ -0,0 +1,36 @@ +import graphene + +from netbox.graphql.fields import ObjectField, ObjectListField +from .types import * + + +class IPAMQuery(graphene.ObjectType): + aggregate = ObjectField(AggregateType) + aggregates = ObjectListField(AggregateType) + + ip_address = ObjectField(IPAddressType) + ip_addresses = ObjectListField(IPAddressType) + + prefix = ObjectField(PrefixType) + prefixes = ObjectListField(PrefixType) + + rir = ObjectField(RIRType) + rirs = ObjectListField(RIRType) + + role = ObjectField(RoleType) + roles = ObjectListField(RoleType) + + route_target = ObjectField(RouteTargetType) + route_targets = ObjectListField(RouteTargetType) + + service = ObjectField(ServiceType) + services = ObjectListField(ServiceType) + + vlan = ObjectField(VLANType) + vlans = ObjectListField(VLANType) + + vlan_group = ObjectField(VLANGroupType) + vlan_groups = ObjectListField(VLANGroupType) + + vrf = ObjectField(VRFType) + vrfs = ObjectListField(VRFType) diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py new file mode 100644 index 000000000..2fa10529e --- /dev/null +++ b/netbox/ipam/graphql/types.py @@ -0,0 +1,95 @@ +from ipam import filtersets, models +from netbox.graphql.types import ObjectType, TaggedObjectType + +__all__ = ( + 'AggregateType', + 'IPAddressType', + 'PrefixType', + 'RIRType', + 'RoleType', + 'RouteTargetType', + 'ServiceType', + 'VLANType', + 'VLANGroupType', + 'VRFType', +) + + +class AggregateType(TaggedObjectType): + + class Meta: + model = models.Aggregate + fields = '__all__' + filterset_class = filtersets.AggregateFilterSet + + +class IPAddressType(TaggedObjectType): + + class Meta: + model = models.IPAddress + fields = '__all__' + filterset_class = filtersets.IPAddressFilterSet + + +class PrefixType(TaggedObjectType): + + class Meta: + model = models.Prefix + fields = '__all__' + filterset_class = filtersets.PrefixFilterSet + + +class RIRType(ObjectType): + + class Meta: + model = models.RIR + fields = '__all__' + filterset_class = filtersets.RIRFilterSet + + +class RoleType(ObjectType): + + class Meta: + model = models.Role + fields = '__all__' + filterset_class = filtersets.RoleFilterSet + + +class RouteTargetType(TaggedObjectType): + + class Meta: + model = models.RouteTarget + fields = '__all__' + filterset_class = filtersets.RouteTargetFilterSet + + +class ServiceType(TaggedObjectType): + + class Meta: + model = models.Service + fields = '__all__' + filterset_class = filtersets.ServiceFilterSet + + +class VLANType(TaggedObjectType): + + class Meta: + model = models.VLAN + fields = '__all__' + filterset_class = filtersets.VLANFilterSet + + +class VLANGroupType(ObjectType): + + class Meta: + model = models.VLANGroup + fields = '__all__' + filterset_class = filtersets.VLANGroupFilterSet + + +class VRFType(TaggedObjectType): + + class Meta: + model = models.VRF + fields = '__all__' + filterset_class = filtersets.VRFFilterSet diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 38339fa14..6b86e2d26 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -20,7 +20,7 @@ class AppTest(APITestCase): self.assertEqual(response.status_code, 200) -class VRFTest(APIViewTestCases.APIViewTestCase): +class VRFTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = VRF brief_fields = ['display', 'id', 'name', 'prefix_count', 'rd', 'url'] create_data = [ @@ -52,7 +52,7 @@ class VRFTest(APIViewTestCases.APIViewTestCase): VRF.objects.bulk_create(vrfs) -class RouteTargetTest(APIViewTestCases.APIViewTestCase): +class RouteTargetTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = RouteTarget brief_fields = ['display', 'id', 'name', 'url'] create_data = [ @@ -81,7 +81,7 @@ class RouteTargetTest(APIViewTestCases.APIViewTestCase): RouteTarget.objects.bulk_create(route_targets) -class RIRTest(APIViewTestCases.APIViewTestCase): +class RIRTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = RIR brief_fields = ['aggregate_count', 'display', 'id', 'name', 'slug', 'url'] create_data = [ @@ -113,7 +113,7 @@ class RIRTest(APIViewTestCases.APIViewTestCase): RIR.objects.bulk_create(rirs) -class AggregateTest(APIViewTestCases.APIViewTestCase): +class AggregateTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = Aggregate brief_fields = ['display', 'family', 'id', 'prefix', 'url'] bulk_update_data = { @@ -152,7 +152,7 @@ class AggregateTest(APIViewTestCases.APIViewTestCase): ] -class RoleTest(APIViewTestCases.APIViewTestCase): +class RoleTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = Role brief_fields = ['display', 'id', 'name', 'prefix_count', 'slug', 'url', 'vlan_count'] create_data = [ @@ -184,7 +184,7 @@ class RoleTest(APIViewTestCases.APIViewTestCase): Role.objects.bulk_create(roles) -class PrefixTest(APIViewTestCases.APIViewTestCase): +class PrefixTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = Prefix brief_fields = ['_depth', 'display', 'family', 'id', 'prefix', 'url'] create_data = [ @@ -358,7 +358,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase): self.assertEqual(len(response.data), 8) -class IPAddressTest(APIViewTestCases.APIViewTestCase): +class IPAddressTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = IPAddress brief_fields = ['address', 'display', 'family', 'id', 'url'] create_data = [ @@ -387,7 +387,7 @@ class IPAddressTest(APIViewTestCases.APIViewTestCase): IPAddress.objects.bulk_create(ip_addresses) -class VLANGroupTest(APIViewTestCases.APIViewTestCase): +class VLANGroupTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = VLANGroup brief_fields = ['display', 'id', 'name', 'slug', 'url', 'vlan_count'] create_data = [ @@ -419,7 +419,7 @@ class VLANGroupTest(APIViewTestCases.APIViewTestCase): VLANGroup.objects.bulk_create(vlan_groups) -class VLANTest(APIViewTestCases.APIViewTestCase): +class VLANTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = VLAN brief_fields = ['display', 'id', 'name', 'url', 'vid'] bulk_update_data = { @@ -479,7 +479,7 @@ class VLANTest(APIViewTestCases.APIViewTestCase): self.assertTrue(content['detail'].startswith('Unable to delete object.')) -class ServiceTest(APIViewTestCases.APIViewTestCase): +class ServiceTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = Service brief_fields = ['display', 'id', 'name', 'ports', 'protocol', 'url'] bulk_update_data = { From 7a2a25c13a61c6341cdef929fd2487456ba3d441 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 25 Jun 2021 14:11:41 -0400 Subject: [PATCH 07/21] Use v3.0 enum naming --- netbox/netbox/settings.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 2552bb76f..b64618ea5 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -496,6 +496,17 @@ REST_FRAMEWORK = { } +# +# Graphene +# + +GRAPHENE = { + # Avoids naming collision on models with 'type' field; see + # https://github.com/graphql-python/graphene-django/issues/185 + 'DJANGO_CHOICE_FIELD_ENUM_V3_NAMING': True, +} + + # # drf_yasg (OpenAPI/Swagger) # From 7256c7050ad6dbba6b64a5038b2192e9cf4e1d96 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 25 Jun 2021 14:12:09 -0400 Subject: [PATCH 08/21] Add GraphQL for extras --- netbox/extras/graphql/__init__.py | 0 netbox/extras/graphql/schema.py | 30 ++++++++++++ netbox/extras/graphql/types.py | 77 +++++++++++++++++++++++++++++++ netbox/extras/tests/test_api.py | 17 +++---- netbox/netbox/graphql/schema.py | 2 + netbox/netbox/graphql/types.py | 6 +-- 6 files changed, 121 insertions(+), 11 deletions(-) create mode 100644 netbox/extras/graphql/__init__.py create mode 100644 netbox/extras/graphql/schema.py create mode 100644 netbox/extras/graphql/types.py diff --git a/netbox/extras/graphql/__init__.py b/netbox/extras/graphql/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/extras/graphql/schema.py b/netbox/extras/graphql/schema.py new file mode 100644 index 000000000..a0456e7cc --- /dev/null +++ b/netbox/extras/graphql/schema.py @@ -0,0 +1,30 @@ +import graphene + +from netbox.graphql.fields import ObjectField, ObjectListField +from .types import * + + +class ExtrasQuery(graphene.ObjectType): + config_context = ObjectField(ConfigContextType) + config_contexts = ObjectListField(ConfigContextType) + + custom_field = ObjectField(CustomFieldType) + custom_fields = ObjectListField(CustomFieldType) + + custom_link = ObjectField(CustomLinkType) + custom_links = ObjectListField(CustomLinkType) + + export_template = ObjectField(ExportTemplateType) + export_templates = ObjectListField(ExportTemplateType) + + image_attachment = ObjectField(ImageAttachmentType) + image_attachments = ObjectListField(ImageAttachmentType) + + journal_entry = ObjectField(JournalEntryType) + journal_entries = ObjectListField(JournalEntryType) + + tag = ObjectField(TagType) + tags = ObjectListField(TagType) + + webhook = ObjectField(WebhookType) + webhooks = ObjectListField(WebhookType) diff --git a/netbox/extras/graphql/types.py b/netbox/extras/graphql/types.py new file mode 100644 index 000000000..abaed4de7 --- /dev/null +++ b/netbox/extras/graphql/types.py @@ -0,0 +1,77 @@ +from extras import filtersets, models +from netbox.graphql.types import BaseObjectType + +__all__ = ( + 'ConfigContextType', + 'CustomFieldType', + 'CustomLinkType', + 'ExportTemplateType', + 'ImageAttachmentType', + 'JournalEntryType', + 'TagType', + 'WebhookType', +) + + +class ConfigContextType(BaseObjectType): + + class Meta: + model = models.ConfigContext + fields = '__all__' + filterset_class = filtersets.ConfigContextFilterSet + + +class CustomFieldType(BaseObjectType): + + class Meta: + model = models.CustomField + fields = '__all__' + filterset_class = filtersets.CustomFieldFilterSet + + +class CustomLinkType(BaseObjectType): + + class Meta: + model = models.CustomLink + fields = '__all__' + filterset_class = filtersets.CustomLinkFilterSet + + +class ExportTemplateType(BaseObjectType): + + class Meta: + model = models.ExportTemplate + fields = '__all__' + filterset_class = filtersets.ExportTemplateFilterSet + + +class ImageAttachmentType(BaseObjectType): + + class Meta: + model = models.ImageAttachment + fields = '__all__' + filterset_class = filtersets.ImageAttachmentFilterSet + + +class JournalEntryType(BaseObjectType): + + class Meta: + model = models.JournalEntry + fields = '__all__' + filterset_class = filtersets.JournalEntryFilterSet + + +class TagType(BaseObjectType): + + class Meta: + model = models.Tag + fields = '__all__' + filterset_class = filtersets.TagFilterSet + + +class WebhookType(BaseObjectType): + + class Meta: + model = models.Webhook + fields = '__all__' + filterset_class = filtersets.WebhookFilterSet diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index da0e6dbb2..76f8bfea3 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -31,7 +31,7 @@ class AppTest(APITestCase): self.assertEqual(response.status_code, 200) -class WebhookTest(APIViewTestCases.APIViewTestCase): +class WebhookTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = Webhook brief_fields = ['display', 'id', 'name', 'url'] create_data = [ @@ -85,7 +85,7 @@ class WebhookTest(APIViewTestCases.APIViewTestCase): webhook.content_types.add(site_ct, rack_ct) -class CustomFieldTest(APIViewTestCases.APIViewTestCase): +class CustomFieldTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = CustomField brief_fields = ['display', 'id', 'name', 'url'] create_data = [ @@ -132,7 +132,7 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase): cf.content_types.add(site_ct) -class CustomLinkTest(APIViewTestCases.APIViewTestCase): +class CustomLinkTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = CustomLink brief_fields = ['display', 'id', 'name', 'url'] create_data = [ @@ -186,7 +186,7 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase): CustomLink.objects.bulk_create(custom_links) -class ExportTemplateTest(APIViewTestCases.APIViewTestCase): +class ExportTemplateTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = ExportTemplate brief_fields = ['display', 'id', 'name', 'url'] create_data = [ @@ -234,7 +234,7 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase): ExportTemplate.objects.bulk_create(export_templates) -class TagTest(APIViewTestCases.APIViewTestCase): +class TagTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = Tag brief_fields = ['color', 'display', 'id', 'name', 'slug', 'url'] create_data = [ @@ -270,7 +270,8 @@ class TagTest(APIViewTestCases.APIViewTestCase): class ImageAttachmentTest( APIViewTestCases.GetObjectViewTestCase, APIViewTestCases.ListObjectsViewTestCase, - APIViewTestCases.DeleteObjectViewTestCase + APIViewTestCases.DeleteObjectViewTestCase, + APIViewTestCases.GraphQLTestCase ): model = ImageAttachment brief_fields = ['display', 'id', 'image', 'name', 'url'] @@ -310,7 +311,7 @@ class ImageAttachmentTest( ImageAttachment.objects.bulk_create(image_attachments) -class JournalEntryTest(APIViewTestCases.APIViewTestCase): +class JournalEntryTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = JournalEntry brief_fields = ['created', 'display', 'id', 'url'] bulk_update_data = { @@ -360,7 +361,7 @@ class JournalEntryTest(APIViewTestCases.APIViewTestCase): ] -class ConfigContextTest(APIViewTestCases.APIViewTestCase): +class ConfigContextTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = ConfigContext brief_fields = ['display', 'id', 'name', 'url'] create_data = [ diff --git a/netbox/netbox/graphql/schema.py b/netbox/netbox/graphql/schema.py index ff64416bb..0899bd564 100644 --- a/netbox/netbox/graphql/schema.py +++ b/netbox/netbox/graphql/schema.py @@ -1,11 +1,13 @@ import graphene from circuits.graphql.schema import CircuitsQuery +from extras.graphql.schema import ExtrasQuery from ipam.graphql.schema import IPAMQuery class Query( CircuitsQuery, + ExtrasQuery, IPAMQuery, graphene.ObjectType ): diff --git a/netbox/netbox/graphql/types.py b/netbox/netbox/graphql/types.py index 5a4d16a8f..f6d116f59 100644 --- a/netbox/netbox/graphql/types.py +++ b/netbox/netbox/graphql/types.py @@ -26,13 +26,13 @@ class ObjectType(BaseObjectType): """ Extends BaseObjectType with support for custom field data. """ - custom_fields = GenericScalar() + # custom_fields = GenericScalar() class Meta: abstract = True - def resolve_custom_fields(self, info): - return self.custom_field_data + # def resolve_custom_fields(self, info): + # return self.custom_field_data class TaggedObjectType(ObjectType): From 881b18f6d0361ff2a882d74933420ffa3b866dba Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 25 Jun 2021 14:19:20 -0400 Subject: [PATCH 09/21] Add GraphQL for tenancy --- netbox/netbox/graphql/schema.py | 2 ++ netbox/tenancy/graphql/__init__.py | 0 netbox/tenancy/graphql/schema.py | 12 ++++++++++++ netbox/tenancy/graphql/types.py | 23 +++++++++++++++++++++++ netbox/tenancy/tests/test_api.py | 4 ++-- 5 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 netbox/tenancy/graphql/__init__.py create mode 100644 netbox/tenancy/graphql/schema.py create mode 100644 netbox/tenancy/graphql/types.py diff --git a/netbox/netbox/graphql/schema.py b/netbox/netbox/graphql/schema.py index 0899bd564..dcb1107e1 100644 --- a/netbox/netbox/graphql/schema.py +++ b/netbox/netbox/graphql/schema.py @@ -3,12 +3,14 @@ import graphene from circuits.graphql.schema import CircuitsQuery from extras.graphql.schema import ExtrasQuery from ipam.graphql.schema import IPAMQuery +from tenancy.graphql.schema import TenancyQuery class Query( CircuitsQuery, ExtrasQuery, IPAMQuery, + TenancyQuery, graphene.ObjectType ): pass diff --git a/netbox/tenancy/graphql/__init__.py b/netbox/tenancy/graphql/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/tenancy/graphql/schema.py b/netbox/tenancy/graphql/schema.py new file mode 100644 index 000000000..5a2f7438b --- /dev/null +++ b/netbox/tenancy/graphql/schema.py @@ -0,0 +1,12 @@ +import graphene + +from netbox.graphql.fields import ObjectField, ObjectListField +from .types import * + + +class TenancyQuery(graphene.ObjectType): + tenant = ObjectField(TenantType) + tenants = ObjectListField(TenantType) + + tenant_group = ObjectField(TenantGroupType) + tenant_groups = ObjectListField(TenantGroupType) diff --git a/netbox/tenancy/graphql/types.py b/netbox/tenancy/graphql/types.py new file mode 100644 index 000000000..ca8e839d8 --- /dev/null +++ b/netbox/tenancy/graphql/types.py @@ -0,0 +1,23 @@ +from tenancy import filtersets, models +from netbox.graphql.types import ObjectType, TaggedObjectType + +__all__ = ( + 'TenantType', + 'TenantGroupType', +) + + +class TenantType(ObjectType): + + class Meta: + model = models.Tenant + fields = '__all__' + filterset_class = filtersets.TenantFilterSet + + +class TenantGroupType(TaggedObjectType): + + class Meta: + model = models.TenantGroup + fields = '__all__' + filterset_class = filtersets.TenantGroupFilterSet diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py index 5a3c2c1b0..1777f204a 100644 --- a/netbox/tenancy/tests/test_api.py +++ b/netbox/tenancy/tests/test_api.py @@ -14,7 +14,7 @@ class AppTest(APITestCase): self.assertEqual(response.status_code, 200) -class TenantGroupTest(APIViewTestCases.APIViewTestCase): +class TenantGroupTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = TenantGroup brief_fields = ['_depth', 'display', 'id', 'name', 'slug', 'tenant_count', 'url'] bulk_update_data = { @@ -52,7 +52,7 @@ class TenantGroupTest(APIViewTestCases.APIViewTestCase): ] -class TenantTest(APIViewTestCases.APIViewTestCase): +class TenantTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = Tenant brief_fields = ['display', 'id', 'name', 'slug', 'url'] bulk_update_data = { From 605b7c5b3eead23d2a4531878dc64278d693fb12 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 25 Jun 2021 15:31:43 -0400 Subject: [PATCH 10/21] Add GraphQL for virtualization --- netbox/ipam/graphql/__init__.py | 11 ----- netbox/netbox/graphql/__init__.py | 11 +++++ netbox/netbox/graphql/schema.py | 2 + netbox/utilities/testing/api.py | 10 ++++- netbox/virtualization/graphql/__init__.py | 0 netbox/virtualization/graphql/schema.py | 21 ++++++++++ netbox/virtualization/graphql/types.py | 50 +++++++++++++++++++++++ netbox/virtualization/tests/test_api.py | 13 +++--- 8 files changed, 100 insertions(+), 18 deletions(-) create mode 100644 netbox/virtualization/graphql/__init__.py create mode 100644 netbox/virtualization/graphql/schema.py create mode 100644 netbox/virtualization/graphql/types.py diff --git a/netbox/ipam/graphql/__init__.py b/netbox/ipam/graphql/__init__.py index d36399cc0..e69de29bb 100644 --- a/netbox/ipam/graphql/__init__.py +++ b/netbox/ipam/graphql/__init__.py @@ -1,11 +0,0 @@ -import graphene -from graphene_django.converter import convert_django_field - -from ipam.fields import IPAddressField, IPNetworkField - - -@convert_django_field.register(IPAddressField) -@convert_django_field.register(IPNetworkField) -def convert_field_to_string(field, registry=None): - # TODO: Update to use get_django_field_description under django_graphene v3.0 - return graphene.String(description=field.help_text, required=not field.null) diff --git a/netbox/netbox/graphql/__init__.py b/netbox/netbox/graphql/__init__.py index 6316a2390..069f6a9c8 100644 --- a/netbox/netbox/graphql/__init__.py +++ b/netbox/netbox/graphql/__init__.py @@ -2,6 +2,9 @@ import graphene from graphene_django.converter import convert_django_field from taggit.managers import TaggableManager +from dcim.fields import MACAddressField +from ipam.fields import IPAddressField, IPNetworkField + @convert_django_field.register(TaggableManager) def convert_field_to_tags_list(field, registry=None): @@ -9,3 +12,11 @@ def convert_field_to_tags_list(field, registry=None): Register conversion handler for django-taggit's TaggableManager """ return graphene.List(graphene.String) + + +@convert_django_field.register(IPAddressField) +@convert_django_field.register(IPNetworkField) +@convert_django_field.register(MACAddressField) +def convert_field_to_string(field, registry=None): + # TODO: Update to use get_django_field_description under django_graphene v3.0 + return graphene.String(description=field.help_text, required=not field.null) diff --git a/netbox/netbox/graphql/schema.py b/netbox/netbox/graphql/schema.py index dcb1107e1..da16ea58a 100644 --- a/netbox/netbox/graphql/schema.py +++ b/netbox/netbox/graphql/schema.py @@ -4,6 +4,7 @@ from circuits.graphql.schema import CircuitsQuery from extras.graphql.schema import ExtrasQuery from ipam.graphql.schema import IPAMQuery from tenancy.graphql.schema import TenancyQuery +from virtualization.graphql.schema import VirtualizationQuery class Query( @@ -11,6 +12,7 @@ class Query( ExtrasQuery, IPAMQuery, TenancyQuery, + VirtualizationQuery, graphene.ObjectType ): pass diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index eff5b9c05..2549492c4 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -425,10 +425,16 @@ class APIViewTestCases: class GraphQLTestCase(APITestCase): + def _get_graphql_base_name(self, plural=False): + if plural: + return getattr(self, 'graphql_base_name_plural', + self.model._meta.verbose_name_plural.lower().replace(' ', '_')) + return getattr(self, 'graphql_base_name', self.model._meta.verbose_name.lower().replace(' ', '_')) + @override_settings(LOGIN_REQUIRED=True) def test_graphql_get_object(self): url = reverse('graphql') - object_type = self.model._meta.verbose_name.lower().replace(' ', '_') + object_type = self._get_graphql_base_name() object_id = self._get_queryset().first().pk query = f""" {{ @@ -459,7 +465,7 @@ class APIViewTestCases: @override_settings(LOGIN_REQUIRED=True) def test_graphql_list_objects(self): url = reverse('graphql') - object_type = self.model._meta.verbose_name_plural.lower().replace(' ', '_') + object_type = self._get_graphql_base_name(plural=True) query = f""" {{ {object_type} {{ diff --git a/netbox/virtualization/graphql/__init__.py b/netbox/virtualization/graphql/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/virtualization/graphql/schema.py b/netbox/virtualization/graphql/schema.py new file mode 100644 index 000000000..f7d9528f0 --- /dev/null +++ b/netbox/virtualization/graphql/schema.py @@ -0,0 +1,21 @@ +import graphene + +from netbox.graphql.fields import ObjectField, ObjectListField +from .types import * + + +class VirtualizationQuery(graphene.ObjectType): + cluster = ObjectField(ClusterType) + clusters = ObjectListField(ClusterType) + + cluster_group = ObjectField(ClusterGroupType) + cluster_groups = ObjectListField(ClusterGroupType) + + cluster_type = ObjectField(ClusterTypeType) + cluster_types = ObjectListField(ClusterTypeType) + + virtual_machine = ObjectField(VirtualMachineType) + virtual_machines = ObjectListField(VirtualMachineType) + + vm_interface = ObjectField(VMInterfaceType) + vm_interfaces = ObjectListField(VMInterfaceType) diff --git a/netbox/virtualization/graphql/types.py b/netbox/virtualization/graphql/types.py new file mode 100644 index 000000000..d7b6d344b --- /dev/null +++ b/netbox/virtualization/graphql/types.py @@ -0,0 +1,50 @@ +from virtualization import filtersets, models +from netbox.graphql.types import ObjectType, TaggedObjectType + +__all__ = ( + 'ClusterType', + 'ClusterGroupType', + 'ClusterTypeType', + 'VirtualMachineType', + 'VMInterfaceType', +) + + +class ClusterType(TaggedObjectType): + + class Meta: + model = models.Cluster + fields = '__all__' + filterset_class = filtersets.ClusterFilterSet + + +class ClusterGroupType(ObjectType): + + class Meta: + model = models.ClusterGroup + fields = '__all__' + filterset_class = filtersets.ClusterGroupFilterSet + + +class ClusterTypeType(ObjectType): + + class Meta: + model = models.ClusterType + fields = '__all__' + filterset_class = filtersets.ClusterTypeFilterSet + + +class VirtualMachineType(TaggedObjectType): + + class Meta: + model = models.VirtualMachine + fields = '__all__' + filterset_class = filtersets.VirtualMachineFilterSet + + +class VMInterfaceType(ObjectType): + + class Meta: + model = models.VMInterface + fields = '__all__' + filterset_class = filtersets.VMInterfaceFilterSet diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index bcf3195f2..1712e93b2 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -17,7 +17,7 @@ class AppTest(APITestCase): self.assertEqual(response.status_code, 200) -class ClusterTypeTest(APIViewTestCases.APIViewTestCase): +class ClusterTypeTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = ClusterType brief_fields = ['cluster_count', 'display', 'id', 'name', 'slug', 'url'] create_data = [ @@ -49,7 +49,7 @@ class ClusterTypeTest(APIViewTestCases.APIViewTestCase): ClusterType.objects.bulk_create(cluster_types) -class ClusterGroupTest(APIViewTestCases.APIViewTestCase): +class ClusterGroupTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = ClusterGroup brief_fields = ['cluster_count', 'display', 'id', 'name', 'slug', 'url'] create_data = [ @@ -81,7 +81,7 @@ class ClusterGroupTest(APIViewTestCases.APIViewTestCase): ClusterGroup.objects.bulk_create(cluster_Groups) -class ClusterTest(APIViewTestCases.APIViewTestCase): +class ClusterTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = Cluster brief_fields = ['display', 'id', 'name', 'url', 'virtualmachine_count'] bulk_update_data = { @@ -129,7 +129,7 @@ class ClusterTest(APIViewTestCases.APIViewTestCase): ] -class VirtualMachineTest(APIViewTestCases.APIViewTestCase): +class VirtualMachineTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = VirtualMachine brief_fields = ['display', 'id', 'name', 'url'] bulk_update_data = { @@ -205,13 +205,16 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase): self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) -class VMInterfaceTest(APIViewTestCases.APIViewTestCase): +class VMInterfaceTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = VMInterface brief_fields = ['display', 'id', 'name', 'url', 'virtual_machine'] bulk_update_data = { 'description': 'New description', } + graphql_base_name = 'vm_interface' + graphql_base_name_plural = 'vm_interfaces' + @classmethod def setUpTestData(cls): From 14e27363e0439fcf50fa8a46a8ec950409931fac Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 25 Jun 2021 16:17:36 -0400 Subject: [PATCH 11/21] Add GraphQL for DCIM --- netbox/dcim/graphql/__init__.py | 0 netbox/dcim/graphql/schema.py | 106 +++++++++++ netbox/dcim/graphql/types.py | 302 ++++++++++++++++++++++++++++++++ netbox/dcim/models/__init__.py | 2 +- netbox/dcim/tests/test_api.py | 58 +++--- netbox/netbox/graphql/schema.py | 2 + 6 files changed, 441 insertions(+), 29 deletions(-) create mode 100644 netbox/dcim/graphql/__init__.py create mode 100644 netbox/dcim/graphql/schema.py create mode 100644 netbox/dcim/graphql/types.py diff --git a/netbox/dcim/graphql/__init__.py b/netbox/dcim/graphql/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/dcim/graphql/schema.py b/netbox/dcim/graphql/schema.py new file mode 100644 index 000000000..d296c3ebc --- /dev/null +++ b/netbox/dcim/graphql/schema.py @@ -0,0 +1,106 @@ +import graphene + +from netbox.graphql.fields import ObjectField, ObjectListField +from .types import * + + +class DCIMQuery(graphene.ObjectType): + cable = ObjectField(CableType) + cables = ObjectListField(CableType) + + console_port = ObjectField(ConsolePortType) + console_ports = ObjectListField(ConsolePortType) + + console_port_template = ObjectField(ConsolePortTemplateType) + console_port_templates = ObjectListField(ConsolePortTemplateType) + + console_server_port = ObjectField(ConsoleServerPortType) + console_server_ports = ObjectListField(ConsoleServerPortType) + + console_server_port_template = ObjectField(ConsoleServerPortTemplateType) + console_server_port_templates = ObjectListField(ConsoleServerPortTemplateType) + + device = ObjectField(DeviceType) + devices = ObjectListField(DeviceType) + + device_bay = ObjectField(DeviceBayType) + device_bays = ObjectListField(DeviceBayType) + + device_bay_template = ObjectField(DeviceBayTemplateType) + device_bay_templates = ObjectListField(DeviceBayTemplateType) + + device_role = ObjectField(DeviceRoleType) + device_roles = ObjectListField(DeviceRoleType) + + device_type = ObjectField(DeviceTypeType) + device_types = ObjectListField(DeviceTypeType) + + front_port = ObjectField(FrontPortType) + front_ports = ObjectListField(FrontPortType) + + front_port_template = ObjectField(FrontPortTemplateType) + front_port_templates = ObjectListField(FrontPortTemplateType) + + interface = ObjectField(InterfaceType) + interfaces = ObjectListField(InterfaceType) + + interface_template = ObjectField(InterfaceTemplateType) + interface_templates = ObjectListField(InterfaceTemplateType) + + inventory_item = ObjectField(InventoryItemType) + inventory_items = ObjectListField(InventoryItemType) + + location = ObjectField(LocationType) + locations = ObjectListField(LocationType) + + manufacturer = ObjectField(ManufacturerType) + manufacturers = ObjectListField(ManufacturerType) + + platform = ObjectField(PlatformType) + platforms = ObjectListField(PlatformType) + + power_feed = ObjectField(PowerFeedType) + power_feeds = ObjectListField(PowerFeedType) + + power_outlet = ObjectField(PowerOutletType) + power_outlets = ObjectListField(PowerOutletType) + + power_outlet_template = ObjectField(PowerOutletTemplateType) + power_outlet_templates = ObjectListField(PowerOutletTemplateType) + + power_panel = ObjectField(PowerPanelType) + power_panels = ObjectListField(PowerPanelType) + + power_port = ObjectField(PowerPortType) + power_ports = ObjectListField(PowerPortType) + + power_port_template = ObjectField(PowerPortTemplateType) + power_port_templates = ObjectListField(PowerPortTemplateType) + + rack = ObjectField(RackType) + racks = ObjectListField(RackType) + + rack_reservation = ObjectField(RackReservationType) + rack_reservations = ObjectListField(RackReservationType) + + rack_role = ObjectField(RackRoleType) + rack_roles = ObjectListField(RackRoleType) + + rear_port = ObjectField(RearPortType) + rear_ports = ObjectListField(RearPortType) + + rear_port_template = ObjectField(RearPortTemplateType) + rear_port_templates = ObjectListField(RearPortTemplateType) + + region = ObjectField(RegionType) + regions = ObjectListField(RegionType) + + site = ObjectField(SiteType) + sites = ObjectListField(SiteType) + + site_group = ObjectField(SiteGroupType) + site_groups = ObjectListField(SiteGroupType) + + virtual_chassis = ObjectField(VirtualChassisType) + # TODO: Rectify list field name + virtual_chassis_list = ObjectListField(VirtualChassisType) diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py new file mode 100644 index 000000000..a11f2334a --- /dev/null +++ b/netbox/dcim/graphql/types.py @@ -0,0 +1,302 @@ +from dcim import filtersets, models +from netbox.graphql.types import BaseObjectType, ObjectType, TaggedObjectType + +__all__ = ( + 'CableType', + 'ConsolePortType', + 'ConsolePortTemplateType', + 'ConsoleServerPortType', + 'ConsoleServerPortTemplateType', + 'DeviceType', + 'DeviceBayType', + 'DeviceBayTemplateType', + 'DeviceRoleType', + 'DeviceTypeType', + 'FrontPortType', + 'FrontPortTemplateType', + 'InterfaceType', + 'InterfaceTemplateType', + 'InventoryItemType', + 'LocationType', + 'ManufacturerType', + 'PlatformType', + 'PowerFeedType', + 'PowerOutletType', + 'PowerOutletTemplateType', + 'PowerPanelType', + 'PowerPortType', + 'PowerPortTemplateType', + 'RackType', + 'RackReservationType', + 'RackRoleType', + 'RearPortType', + 'RearPortTemplateType', + 'RegionType', + 'SiteType', + 'SiteGroupType', + 'VirtualChassisType', +) + + +class CableType(TaggedObjectType): + + class Meta: + model = models.Cable + fields = '__all__' + filterset_class = filtersets.CableFilterSet + + +class ConsolePortType(TaggedObjectType): + + class Meta: + model = models.ConsolePort + fields = '__all__' + filterset_class = filtersets.ConsolePortFilterSet + + +class ConsolePortTemplateType(BaseObjectType): + + class Meta: + model = models.ConsolePortTemplate + fields = '__all__' + filterset_class = filtersets.ConsolePortTemplateFilterSet + + +class ConsoleServerPortType(TaggedObjectType): + + class Meta: + model = models.ConsoleServerPort + fields = '__all__' + filterset_class = filtersets.ConsoleServerPortFilterSet + + +class ConsoleServerPortTemplateType(BaseObjectType): + + class Meta: + model = models.ConsoleServerPortTemplate + fields = '__all__' + filterset_class = filtersets.ConsoleServerPortTemplateFilterSet + + +class DeviceType(TaggedObjectType): + + class Meta: + model = models.Device + fields = '__all__' + filterset_class = filtersets.DeviceFilterSet + + +class DeviceBayType(TaggedObjectType): + + class Meta: + model = models.DeviceBay + fields = '__all__' + filterset_class = filtersets.DeviceBayFilterSet + + +class DeviceBayTemplateType(BaseObjectType): + + class Meta: + model = models.DeviceBayTemplate + fields = '__all__' + filterset_class = filtersets.DeviceBayTemplateFilterSet + + +class DeviceRoleType(ObjectType): + + class Meta: + model = models.DeviceRole + fields = '__all__' + filterset_class = filtersets.DeviceRoleFilterSet + + +class DeviceTypeType(TaggedObjectType): + + class Meta: + model = models.DeviceType + fields = '__all__' + filterset_class = filtersets.DeviceTypeFilterSet + + +class FrontPortType(TaggedObjectType): + + class Meta: + model = models.FrontPort + fields = '__all__' + filterset_class = filtersets.FrontPortFilterSet + + +class FrontPortTemplateType(BaseObjectType): + + class Meta: + model = models.FrontPortTemplate + fields = '__all__' + filterset_class = filtersets.FrontPortTemplateFilterSet + + +class InterfaceType(TaggedObjectType): + + class Meta: + model = models.Interface + fields = '__all__' + filterset_class = filtersets.InterfaceFilterSet + + +class InterfaceTemplateType(BaseObjectType): + + class Meta: + model = models.InterfaceTemplate + fields = '__all__' + filterset_class = filtersets.InterfaceTemplateFilterSet + + +class InventoryItemType(ObjectType): + + class Meta: + model = models.InventoryItem + fields = '__all__' + filterset_class = filtersets.InventoryItemFilterSet + + +class LocationType(TaggedObjectType): + + class Meta: + model = models.Location + fields = '__all__' + filterset_class = filtersets.LocationFilterSet + + +class ManufacturerType(ObjectType): + + class Meta: + model = models.Manufacturer + fields = '__all__' + filterset_class = filtersets.ManufacturerFilterSet + + +class PlatformType(ObjectType): + + class Meta: + model = models.Platform + fields = '__all__' + filterset_class = filtersets.PlatformFilterSet + + +class PowerFeedType(TaggedObjectType): + + class Meta: + model = models.PowerFeed + fields = '__all__' + filterset_class = filtersets.PowerFeedFilterSet + + +class PowerOutletType(TaggedObjectType): + + class Meta: + model = models.PowerOutlet + fields = '__all__' + filterset_class = filtersets.PowerOutletFilterSet + + +class PowerOutletTemplateType(BaseObjectType): + + class Meta: + model = models.PowerOutletTemplate + fields = '__all__' + filterset_class = filtersets.PowerOutletTemplateFilterSet + + +class PowerPanelType(TaggedObjectType): + + class Meta: + model = models.PowerPanel + fields = '__all__' + filterset_class = filtersets.PowerPanelFilterSet + + +class PowerPortType(TaggedObjectType): + + class Meta: + model = models.PowerPort + fields = '__all__' + filterset_class = filtersets.PowerPortFilterSet + + +class PowerPortTemplateType(BaseObjectType): + + class Meta: + model = models.PowerPortTemplate + fields = '__all__' + filterset_class = filtersets.PowerPortTemplateFilterSet + + +class RackType(TaggedObjectType): + + class Meta: + model = models.Rack + fields = '__all__' + filterset_class = filtersets.RackFilterSet + + +class RackReservationType(TaggedObjectType): + + class Meta: + model = models.RackReservation + fields = '__all__' + filterset_class = filtersets.RackReservationFilterSet + + +class RackRoleType(ObjectType): + + class Meta: + model = models.RackRole + fields = '__all__' + filterset_class = filtersets.RackRoleFilterSet + + +class RearPortType(TaggedObjectType): + + class Meta: + model = models.RearPort + fields = '__all__' + filterset_class = filtersets.RearPortFilterSet + + +class RearPortTemplateType(BaseObjectType): + + class Meta: + model = models.RearPortTemplate + fields = '__all__' + filterset_class = filtersets.RearPortTemplateFilterSet + + +class RegionType(ObjectType): + + class Meta: + model = models.Region + fields = '__all__' + filterset_class = filtersets.RegionFilterSet + + +class SiteType(TaggedObjectType): + + class Meta: + model = models.Site + fields = '__all__' + filterset_class = filtersets.SiteFilterSet + + +class SiteGroupType(ObjectType): + + class Meta: + model = models.SiteGroup + fields = '__all__' + filterset_class = filtersets.SiteGroupFilterSet + + +class VirtualChassisType(TaggedObjectType): + + class Meta: + model = models.VirtualChassis + fields = '__all__' + filterset_class = filtersets.VirtualChassisFilterSet diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index ee19d553d..0375a9fb4 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -25,6 +25,7 @@ __all__ = ( 'Interface', 'InterfaceTemplate', 'InventoryItem', + 'Location', 'Manufacturer', 'Platform', 'PowerFeed', @@ -34,7 +35,6 @@ __all__ = ( 'PowerPort', 'PowerPortTemplate', 'Rack', - 'Location', 'RackReservation', 'RackRole', 'RearPort', diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 2c64ad294..7e9687ed8 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -57,7 +57,7 @@ class Mixins: self.assertEqual(segment1[2]['name'], peer_obj.name) -class RegionTest(APIViewTestCases.APIViewTestCase): +class RegionTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = Region brief_fields = ['_depth', 'display', 'id', 'name', 'site_count', 'slug', 'url'] create_data = [ @@ -86,7 +86,7 @@ class RegionTest(APIViewTestCases.APIViewTestCase): Region.objects.create(name='Region 3', slug='region-3') -class SiteGroupTest(APIViewTestCases.APIViewTestCase): +class SiteGroupTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = SiteGroup brief_fields = ['_depth', 'display', 'id', 'name', 'site_count', 'slug', 'url'] create_data = [ @@ -115,7 +115,7 @@ class SiteGroupTest(APIViewTestCases.APIViewTestCase): SiteGroup.objects.create(name='Site Group 3', slug='site-group-3') -class SiteTest(APIViewTestCases.APIViewTestCase): +class SiteTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = Site brief_fields = ['display', 'id', 'name', 'slug', 'url'] bulk_update_data = { @@ -167,7 +167,7 @@ class SiteTest(APIViewTestCases.APIViewTestCase): ] -class LocationTest(APIViewTestCases.APIViewTestCase): +class LocationTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = Location brief_fields = ['_depth', 'display', 'id', 'name', 'rack_count', 'slug', 'url'] bulk_update_data = { @@ -214,7 +214,7 @@ class LocationTest(APIViewTestCases.APIViewTestCase): ] -class RackRoleTest(APIViewTestCases.APIViewTestCase): +class RackRoleTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = RackRole brief_fields = ['display', 'id', 'name', 'rack_count', 'slug', 'url'] create_data = [ @@ -249,7 +249,7 @@ class RackRoleTest(APIViewTestCases.APIViewTestCase): RackRole.objects.bulk_create(rack_roles) -class RackTest(APIViewTestCases.APIViewTestCase): +class RackTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = Rack brief_fields = ['device_count', 'display', 'id', 'name', 'url'] bulk_update_data = { @@ -337,7 +337,7 @@ class RackTest(APIViewTestCases.APIViewTestCase): self.assertEqual(response.get('Content-Type'), 'image/svg+xml') -class RackReservationTest(APIViewTestCases.APIViewTestCase): +class RackReservationTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = RackReservation brief_fields = ['display', 'id', 'units', 'url', 'user'] bulk_update_data = { @@ -384,7 +384,7 @@ class RackReservationTest(APIViewTestCases.APIViewTestCase): ] -class ManufacturerTest(APIViewTestCases.APIViewTestCase): +class ManufacturerTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = Manufacturer brief_fields = ['devicetype_count', 'display', 'id', 'name', 'slug', 'url'] create_data = [ @@ -416,7 +416,7 @@ class ManufacturerTest(APIViewTestCases.APIViewTestCase): Manufacturer.objects.bulk_create(manufacturers) -class DeviceTypeTest(APIViewTestCases.APIViewTestCase): +class DeviceTypeTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = DeviceType brief_fields = ['device_count', 'display', 'id', 'manufacturer', 'model', 'slug', 'url'] bulk_update_data = { @@ -458,7 +458,7 @@ class DeviceTypeTest(APIViewTestCases.APIViewTestCase): ] -class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase): +class ConsolePortTemplateTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = ConsolePortTemplate brief_fields = ['display', 'id', 'name', 'url'] bulk_update_data = { @@ -495,7 +495,7 @@ class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase): ] -class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase): +class ConsoleServerPortTemplateTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = ConsoleServerPortTemplate brief_fields = ['display', 'id', 'name', 'url'] bulk_update_data = { @@ -532,7 +532,7 @@ class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase): ] -class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase): +class PowerPortTemplateTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = PowerPortTemplate brief_fields = ['display', 'id', 'name', 'url'] bulk_update_data = { @@ -569,7 +569,7 @@ class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase): ] -class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase): +class PowerOutletTemplateTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = PowerOutletTemplate brief_fields = ['display', 'id', 'name', 'url'] bulk_update_data = { @@ -606,7 +606,7 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase): ] -class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase): +class InterfaceTemplateTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = InterfaceTemplate brief_fields = ['display', 'id', 'name', 'url'] bulk_update_data = { @@ -646,7 +646,7 @@ class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase): ] -class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase): +class FrontPortTemplateTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = FrontPortTemplate brief_fields = ['display', 'id', 'name', 'url'] bulk_update_data = { @@ -717,7 +717,7 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase): ] -class RearPortTemplateTest(APIViewTestCases.APIViewTestCase): +class RearPortTemplateTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = RearPortTemplate brief_fields = ['display', 'id', 'name', 'url'] bulk_update_data = { @@ -757,7 +757,7 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase): ] -class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase): +class DeviceBayTemplateTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = DeviceBayTemplate brief_fields = ['display', 'id', 'name', 'url'] bulk_update_data = { @@ -797,7 +797,7 @@ class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase): ] -class DeviceRoleTest(APIViewTestCases.APIViewTestCase): +class DeviceRoleTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = DeviceRole brief_fields = ['device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count'] create_data = [ @@ -832,7 +832,7 @@ class DeviceRoleTest(APIViewTestCases.APIViewTestCase): DeviceRole.objects.bulk_create(device_roles) -class PlatformTest(APIViewTestCases.APIViewTestCase): +class PlatformTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = Platform brief_fields = ['device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count'] create_data = [ @@ -864,7 +864,7 @@ class PlatformTest(APIViewTestCases.APIViewTestCase): Platform.objects.bulk_create(platforms) -class DeviceTest(APIViewTestCases.APIViewTestCase): +class DeviceTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = Device brief_fields = ['display', 'id', 'name', 'url'] bulk_update_data = { @@ -1219,7 +1219,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase ] -class FrontPortTest(APIViewTestCases.APIViewTestCase): +class FrontPortTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = FrontPort brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { @@ -1277,7 +1277,7 @@ class FrontPortTest(APIViewTestCases.APIViewTestCase): ] -class RearPortTest(APIViewTestCases.APIViewTestCase): +class RearPortTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = RearPort brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { @@ -1319,7 +1319,7 @@ class RearPortTest(APIViewTestCases.APIViewTestCase): ] -class DeviceBayTest(APIViewTestCases.APIViewTestCase): +class DeviceBayTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = DeviceBay brief_fields = ['device', 'display', 'id', 'name', 'url'] bulk_update_data = { @@ -1382,7 +1382,7 @@ class DeviceBayTest(APIViewTestCases.APIViewTestCase): ] -class InventoryItemTest(APIViewTestCases.APIViewTestCase): +class InventoryItemTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = InventoryItem brief_fields = ['_depth', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { @@ -1420,7 +1420,7 @@ class InventoryItemTest(APIViewTestCases.APIViewTestCase): ] -class CableTest(APIViewTestCases.APIViewTestCase): +class CableTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = Cable brief_fields = ['display', 'id', 'label', 'url'] bulk_update_data = { @@ -1525,10 +1525,12 @@ class ConnectedDeviceTest(APITestCase): self.assertEqual(response.data['name'], self.device1.name) -class VirtualChassisTest(APIViewTestCases.APIViewTestCase): +class VirtualChassisTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = VirtualChassis brief_fields = ['id', 'master', 'member_count', 'name', 'url'] + graphql_base_name_plural = 'virtual_chassis_list' + @classmethod def setUpTestData(cls): site = Site.objects.create(name='Test Site', slug='test-site') @@ -1605,7 +1607,7 @@ class VirtualChassisTest(APIViewTestCases.APIViewTestCase): } -class PowerPanelTest(APIViewTestCases.APIViewTestCase): +class PowerPanelTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = PowerPanel brief_fields = ['display', 'id', 'name', 'powerfeed_count', 'url'] @@ -1654,7 +1656,7 @@ class PowerPanelTest(APIViewTestCases.APIViewTestCase): } -class PowerFeedTest(APIViewTestCases.APIViewTestCase): +class PowerFeedTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = PowerFeed brief_fields = ['_occupied', 'cable', 'display', 'id', 'name', 'url'] bulk_update_data = { diff --git a/netbox/netbox/graphql/schema.py b/netbox/netbox/graphql/schema.py index da16ea58a..136477245 100644 --- a/netbox/netbox/graphql/schema.py +++ b/netbox/netbox/graphql/schema.py @@ -1,6 +1,7 @@ import graphene from circuits.graphql.schema import CircuitsQuery +from dcim.graphql.schema import DCIMQuery from extras.graphql.schema import ExtrasQuery from ipam.graphql.schema import IPAMQuery from tenancy.graphql.schema import TenancyQuery @@ -9,6 +10,7 @@ from virtualization.graphql.schema import VirtualizationQuery class Query( CircuitsQuery, + DCIMQuery, ExtrasQuery, IPAMQuery, TenancyQuery, From bd1e019a4201a7c33277b2b2e5b4a395de3d5e77 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 28 Jun 2021 16:19:02 -0400 Subject: [PATCH 12/21] Clean up token-based authentication for GraphQL --- netbox/netbox/graphql/views.py | 40 ++++++++++++++++++++--------- netbox/netbox/tests/test_graphql.py | 2 +- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/netbox/netbox/graphql/views.py b/netbox/netbox/graphql/views.py index 1cde56cd6..047f7bab3 100644 --- a/netbox/netbox/graphql/views.py +++ b/netbox/netbox/graphql/views.py @@ -1,20 +1,36 @@ +from django.conf import settings +from django.contrib.auth.views import redirect_to_login +from django.http import HttpResponseForbidden +from django.urls import reverse from graphene_django.views import GraphQLView as GraphQLView_ -from rest_framework.decorators import authentication_classes, permission_classes, api_view -from rest_framework.permissions import IsAuthenticated -from rest_framework.settings import api_settings +from rest_framework.exceptions import AuthenticationFailed + +from netbox.api.authentication import TokenAuthentication class GraphQLView(GraphQLView_): """ - Extends grpahene_django's GraphQLView to support DRF's token-based authentication. + Extends graphene_django's GraphQLView to support DRF's token-based authentication. """ - @classmethod - def as_view(cls, *args, **kwargs): - view = super(GraphQLView, cls).as_view(*args, **kwargs) + def dispatch(self, request, *args, **kwargs): - # Apply DRF permission and authentication classes - view = permission_classes((IsAuthenticated,))(view) - view = authentication_classes(api_settings.DEFAULT_AUTHENTICATION_CLASSES)(view) - view = api_view(['GET', 'POST'])(view) + # Attempt to authenticate the user using a DRF token, if provided + if not request.user.is_authenticated: + authenticator = TokenAuthentication() + try: + auth_info = authenticator.authenticate(request) + if auth_info is not None: + request.user = auth_info[0] # User object + except AuthenticationFailed as exc: + return HttpResponseForbidden(exc.detail) - return view + # Enforce LOGIN_REQUIRED + if settings.LOGIN_REQUIRED and not request.user.is_authenticated: + + # If this is a human user, send a redirect to the login page + if self.request_wants_html(request): + return redirect_to_login(reverse('graphql')) + + return HttpResponseForbidden("No credentials provided.") + + return super().dispatch(request, *args, **kwargs) diff --git a/netbox/netbox/tests/test_graphql.py b/netbox/netbox/tests/test_graphql.py index dd43bbbdd..483c125a2 100644 --- a/netbox/netbox/tests/test_graphql.py +++ b/netbox/netbox/tests/test_graphql.py @@ -24,4 +24,4 @@ class GraphQLTestCase(TestCase): self.client.logout() response = self.client.get(url, **header) with disable_warnings('django.request'): - self.assertHttpStatus(response, 302) + self.assertHttpStatus(response, 302) # Redirect to login page From 7deabfe9cd26c3d33118af72384373242cc9cd14 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 29 Jun 2021 11:20:54 -0400 Subject: [PATCH 13/21] Extended GraphQL tests to include all fields --- netbox/netbox/api/exceptions.py | 4 +++ netbox/utilities/api.py | 16 +++++++++-- netbox/utilities/testing/api.py | 48 +++++++++++++++++++++++---------- 3 files changed, 52 insertions(+), 16 deletions(-) diff --git a/netbox/netbox/api/exceptions.py b/netbox/netbox/api/exceptions.py index 8c62eee4c..f552b06b5 100644 --- a/netbox/netbox/api/exceptions.py +++ b/netbox/netbox/api/exceptions.py @@ -8,3 +8,7 @@ class ServiceUnavailable(APIException): class SerializerNotFound(Exception): pass + + +class GraphQLTypeNotFound(Exception): + pass diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 09cc7004b..b4bde9b53 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -7,7 +7,7 @@ from django.urls import reverse from rest_framework import status from rest_framework.utils import formatting -from netbox.api.exceptions import SerializerNotFound +from netbox.api.exceptions import GraphQLTypeNotFound, SerializerNotFound from .utils import dynamic_import @@ -24,10 +24,22 @@ def get_serializer_for_model(model, prefix=''): return dynamic_import(serializer_name) except AttributeError: raise SerializerNotFound( - "Could not determine serializer for {}.{} with prefix '{}'".format(app_name, model_name, prefix) + f"Could not determine serializer for {app_name}.{model_name} with prefix '{prefix}'" ) +def get_graphql_type_for_model(model): + """ + Return the GraphQL type class for the given model. + """ + app_name, model_name = model._meta.label.split('.') + class_name = f'{app_name}.graphql.types.{model_name}Type' + try: + return dynamic_import(class_name) + except AttributeError: + raise GraphQLTypeNotFound(f"Could not find GraphQL type for {app_name}.{model_name}") + + def is_api_request(request): """ Return True of the request is being made via the REST API. diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index 2549492c4..fd18259d1 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -5,12 +5,14 @@ from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.urls import reverse from django.test import override_settings +from graphene.types.dynamic import Dynamic from rest_framework import status from rest_framework.test import APIClient from extras.choices import ObjectChangeActionChoices from extras.models import ObjectChange from users.models import ObjectPermission, Token +from utilities.api import get_graphql_type_for_model from .base import ModelTestCase from .utils import disable_warnings @@ -431,18 +433,42 @@ class APIViewTestCases: self.model._meta.verbose_name_plural.lower().replace(' ', '_')) return getattr(self, 'graphql_base_name', self.model._meta.verbose_name.lower().replace(' ', '_')) + def _build_query(self, name, **filters): + type_class = get_graphql_type_for_model(self.model) + if filters: + filter_string = ', '.join(f'{k}:{v}' for k, v in filters.items()) + filter_string = f'({filter_string})' + else: + filter_string = '' + + # Compile list of fields to include + fields_string = '' + for field_name, field in type_class._meta.fields.items(): + # TODO: Omit "hidden" fields from GraphQL types + if field_name.startswith('_'): + continue + if type(field) is Dynamic: + # Dynamic fields must specify a subselection + fields_string += f'{field_name} {{ id }}\n' + else: + fields_string += f'{field_name}\n' + + query = f""" + {{ + {name}{filter_string} {{ + {fields_string} + }} + }} + """ + + return query + @override_settings(LOGIN_REQUIRED=True) def test_graphql_get_object(self): url = reverse('graphql') object_type = self._get_graphql_base_name() object_id = self._get_queryset().first().pk - query = f""" - {{ - {object_type}(id:{object_id}) {{ - id - }} - }} - """ + query = self._build_query(object_type, id=object_id) # Non-authenticated requests should fail with disable_warnings('django.request'): @@ -466,13 +492,7 @@ class APIViewTestCases: def test_graphql_list_objects(self): url = reverse('graphql') object_type = self._get_graphql_base_name(plural=True) - query = f""" - {{ - {object_type} {{ - id - }} - }} - """ + query = self._build_query(object_type) # Non-authenticated requests should fail with disable_warnings('django.request'): From 79614ed2cf4936358a2f7beca703210720883df2 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 29 Jun 2021 11:36:18 -0400 Subject: [PATCH 14/21] Add GraphQL type for ContentType --- netbox/netbox/graphql/types.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/netbox/netbox/graphql/types.py b/netbox/netbox/graphql/types.py index f6d116f59..2958247b7 100644 --- a/netbox/netbox/graphql/types.py +++ b/netbox/netbox/graphql/types.py @@ -1,4 +1,5 @@ import graphene +from django.contrib.contenttypes.models import ContentType from graphene.types.generic import GenericScalar from graphene_django import DjangoObjectType @@ -9,6 +10,10 @@ __all__ = ( ) +# +# Base types +# + class BaseObjectType(DjangoObjectType): """ Base GraphQL object type for all NetBox objects @@ -26,13 +31,13 @@ class ObjectType(BaseObjectType): """ Extends BaseObjectType with support for custom field data. """ - # custom_fields = GenericScalar() + custom_fields = GenericScalar() class Meta: abstract = True - # def resolve_custom_fields(self, info): - # return self.custom_field_data + def resolve_custom_fields(self, info): + return self.custom_field_data class TaggedObjectType(ObjectType): @@ -46,3 +51,14 @@ class TaggedObjectType(ObjectType): def resolve_tags(self, info): return self.tags.all() + + +# +# Miscellaneous types +# + +class ContentTypeType(DjangoObjectType): + + class Meta: + model = ContentType + fields = ('id', 'app_label', 'model') From ef3cb9544ae6fc9cc4c13cb7f947576219db4aa0 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 29 Jun 2021 12:55:17 -0400 Subject: [PATCH 15/21] Clean up GraphQL object types & tests --- netbox/dcim/graphql/types.py | 55 +++++++++++++++++++++++++- netbox/dcim/tests/test_api.py | 30 +++++++++++--- netbox/extras/graphql/types.py | 2 +- netbox/ipam/graphql/types.py | 3 ++ netbox/tenancy/graphql/types.py | 4 +- netbox/virtualization/graphql/types.py | 5 ++- 6 files changed, 88 insertions(+), 11 deletions(-) diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index a11f2334a..12342ff4b 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -45,6 +45,12 @@ class CableType(TaggedObjectType): fields = '__all__' filterset_class = filtersets.CableFilterSet + def resolve_type(self, info): + return self.type or None + + def resolve_length_unit(self, info): + return self.length_unit or None + class ConsolePortType(TaggedObjectType): @@ -53,6 +59,9 @@ class ConsolePortType(TaggedObjectType): fields = '__all__' filterset_class = filtersets.ConsolePortFilterSet + def resolve_type(self, info): + return self.type or None + class ConsolePortTemplateType(BaseObjectType): @@ -61,6 +70,9 @@ class ConsolePortTemplateType(BaseObjectType): fields = '__all__' filterset_class = filtersets.ConsolePortTemplateFilterSet + def resolve_type(self, info): + return self.type or None + class ConsoleServerPortType(TaggedObjectType): @@ -69,6 +81,9 @@ class ConsoleServerPortType(TaggedObjectType): fields = '__all__' filterset_class = filtersets.ConsoleServerPortFilterSet + def resolve_type(self, info): + return self.type or None + class ConsoleServerPortTemplateType(BaseObjectType): @@ -77,6 +92,9 @@ class ConsoleServerPortTemplateType(BaseObjectType): fields = '__all__' filterset_class = filtersets.ConsoleServerPortTemplateFilterSet + def resolve_type(self, info): + return self.type or None + class DeviceType(TaggedObjectType): @@ -85,6 +103,9 @@ class DeviceType(TaggedObjectType): fields = '__all__' filterset_class = filtersets.DeviceFilterSet + def resolve_face(self, info): + return self.face or None + class DeviceBayType(TaggedObjectType): @@ -117,6 +138,9 @@ class DeviceTypeType(TaggedObjectType): fields = '__all__' filterset_class = filtersets.DeviceTypeFilterSet + def resolve_subdevice_role(self, info): + return self.subdevice_role or None + class FrontPortType(TaggedObjectType): @@ -141,6 +165,9 @@ class InterfaceType(TaggedObjectType): fields = '__all__' filterset_class = filtersets.InterfaceFilterSet + def resolve_mode(self, info): + return self.mode or None + class InterfaceTemplateType(BaseObjectType): @@ -150,7 +177,7 @@ class InterfaceTemplateType(BaseObjectType): filterset_class = filtersets.InterfaceTemplateFilterSet -class InventoryItemType(ObjectType): +class InventoryItemType(TaggedObjectType): class Meta: model = models.InventoryItem @@ -158,7 +185,7 @@ class InventoryItemType(ObjectType): filterset_class = filtersets.InventoryItemFilterSet -class LocationType(TaggedObjectType): +class LocationType(ObjectType): class Meta: model = models.Location @@ -197,6 +224,12 @@ class PowerOutletType(TaggedObjectType): fields = '__all__' filterset_class = filtersets.PowerOutletFilterSet + def resolve_feed_leg(self, info): + return self.feed_leg or None + + def resolve_type(self, info): + return self.type or None + class PowerOutletTemplateType(BaseObjectType): @@ -205,6 +238,12 @@ class PowerOutletTemplateType(BaseObjectType): fields = '__all__' filterset_class = filtersets.PowerOutletTemplateFilterSet + def resolve_feed_leg(self, info): + return self.feed_leg or None + + def resolve_type(self, info): + return self.type or None + class PowerPanelType(TaggedObjectType): @@ -221,6 +260,9 @@ class PowerPortType(TaggedObjectType): fields = '__all__' filterset_class = filtersets.PowerPortFilterSet + def resolve_type(self, info): + return self.type or None + class PowerPortTemplateType(BaseObjectType): @@ -229,6 +271,9 @@ class PowerPortTemplateType(BaseObjectType): fields = '__all__' filterset_class = filtersets.PowerPortTemplateFilterSet + def resolve_type(self, info): + return self.type or None + class RackType(TaggedObjectType): @@ -237,6 +282,12 @@ class RackType(TaggedObjectType): fields = '__all__' filterset_class = filtersets.RackFilterSet + def resolve_type(self, info): + return self.type or None + + def resolve_outer_unit(self, info): + return self.outer_unit or None + class RackReservationType(TaggedObjectType): diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 7e9687ed8..9d238395e 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1005,7 +1005,11 @@ class DeviceTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestC self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) -class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): +class ConsolePortTest( + Mixins.ComponentTraceMixin, + APIViewTestCases.GraphQLTestCase, + APIViewTestCases.APIViewTestCase +): model = ConsolePort brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { @@ -1044,7 +1048,11 @@ class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa ] -class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): +class ConsoleServerPortTest( + Mixins.ComponentTraceMixin, + APIViewTestCases.GraphQLTestCase, + APIViewTestCases.APIViewTestCase +): model = ConsoleServerPort brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { @@ -1083,7 +1091,11 @@ class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIView ] -class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): +class PowerPortTest( + Mixins.ComponentTraceMixin, + APIViewTestCases.GraphQLTestCase, + APIViewTestCases.APIViewTestCase +): model = PowerPort brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { @@ -1122,7 +1134,11 @@ class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase ] -class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): +class PowerOutletTest( + Mixins.ComponentTraceMixin, + APIViewTestCases.GraphQLTestCase, + APIViewTestCases.APIViewTestCase +): model = PowerOutlet brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { @@ -1161,7 +1177,11 @@ class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa ] -class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): +class InterfaceTest( + Mixins.ComponentTraceMixin, + APIViewTestCases.GraphQLTestCase, + APIViewTestCases.APIViewTestCase +): model = Interface brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { diff --git a/netbox/extras/graphql/types.py b/netbox/extras/graphql/types.py index abaed4de7..96651351d 100644 --- a/netbox/extras/graphql/types.py +++ b/netbox/extras/graphql/types.py @@ -65,7 +65,7 @@ class TagType(BaseObjectType): class Meta: model = models.Tag - fields = '__all__' + exclude = ('extras_taggeditem_items',) filterset_class = filtersets.TagFilterSet diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index 2fa10529e..be85150fa 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -30,6 +30,9 @@ class IPAddressType(TaggedObjectType): fields = '__all__' filterset_class = filtersets.IPAddressFilterSet + def resolve_role(self, info): + return self.role or None + class PrefixType(TaggedObjectType): diff --git a/netbox/tenancy/graphql/types.py b/netbox/tenancy/graphql/types.py index ca8e839d8..8f9469c10 100644 --- a/netbox/tenancy/graphql/types.py +++ b/netbox/tenancy/graphql/types.py @@ -7,7 +7,7 @@ __all__ = ( ) -class TenantType(ObjectType): +class TenantType(TaggedObjectType): class Meta: model = models.Tenant @@ -15,7 +15,7 @@ class TenantType(ObjectType): filterset_class = filtersets.TenantFilterSet -class TenantGroupType(TaggedObjectType): +class TenantGroupType(ObjectType): class Meta: model = models.TenantGroup diff --git a/netbox/virtualization/graphql/types.py b/netbox/virtualization/graphql/types.py index d7b6d344b..f4e757ecf 100644 --- a/netbox/virtualization/graphql/types.py +++ b/netbox/virtualization/graphql/types.py @@ -42,9 +42,12 @@ class VirtualMachineType(TaggedObjectType): filterset_class = filtersets.VirtualMachineFilterSet -class VMInterfaceType(ObjectType): +class VMInterfaceType(TaggedObjectType): class Meta: model = models.VMInterface fields = '__all__' filterset_class = filtersets.VMInterfaceFilterSet + + def resolve_mode(self, info): + return self.mode or None From 05ba54b6d39b17d243c2c7bcdc8737e7276fd881 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 29 Jun 2021 13:15:10 -0400 Subject: [PATCH 16/21] Add GraphQL for users and groups --- netbox/netbox/graphql/schema.py | 2 ++ netbox/users/graphql/__init__.py | 0 netbox/users/graphql/schema.py | 12 +++++++++++ netbox/users/graphql/types.py | 37 ++++++++++++++++++++++++++++++++ netbox/users/tests/test_api.py | 4 ++-- netbox/utilities/api.py | 3 +++ 6 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 netbox/users/graphql/__init__.py create mode 100644 netbox/users/graphql/schema.py create mode 100644 netbox/users/graphql/types.py diff --git a/netbox/netbox/graphql/schema.py b/netbox/netbox/graphql/schema.py index 136477245..bb752b8c4 100644 --- a/netbox/netbox/graphql/schema.py +++ b/netbox/netbox/graphql/schema.py @@ -5,6 +5,7 @@ from dcim.graphql.schema import DCIMQuery from extras.graphql.schema import ExtrasQuery from ipam.graphql.schema import IPAMQuery from tenancy.graphql.schema import TenancyQuery +from users.graphql.schema import UsersQuery from virtualization.graphql.schema import VirtualizationQuery @@ -14,6 +15,7 @@ class Query( ExtrasQuery, IPAMQuery, TenancyQuery, + UsersQuery, VirtualizationQuery, graphene.ObjectType ): diff --git a/netbox/users/graphql/__init__.py b/netbox/users/graphql/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/users/graphql/schema.py b/netbox/users/graphql/schema.py new file mode 100644 index 000000000..b25c8f33d --- /dev/null +++ b/netbox/users/graphql/schema.py @@ -0,0 +1,12 @@ +import graphene + +from netbox.graphql.fields import ObjectField, ObjectListField +from .types import * + + +class UsersQuery(graphene.ObjectType): + group = ObjectField(GroupType) + groups = ObjectListField(GroupType) + + user = ObjectField(UserType) + users = ObjectListField(UserType) diff --git a/netbox/users/graphql/types.py b/netbox/users/graphql/types.py new file mode 100644 index 000000000..3315744b9 --- /dev/null +++ b/netbox/users/graphql/types.py @@ -0,0 +1,37 @@ +from django.contrib.auth.models import Group, User +from graphene_django import DjangoObjectType + +from users import filtersets +from utilities.querysets import RestrictedQuerySet + +__all__ = ( + 'GroupType', + 'UserType', +) + + +class GroupType(DjangoObjectType): + + class Meta: + model = Group + fields = ('id', 'name') + filterset_class = filtersets.GroupFilterSet + + @classmethod + def get_queryset(cls, queryset, info): + return RestrictedQuerySet(model=Group) + + +class UserType(DjangoObjectType): + + class Meta: + model = User + fields = ( + 'id', 'username', 'password', 'first_name', 'last_name', 'email', 'is_staff', 'is_active', 'date_joined', + 'groups', + ) + filterset_class = filtersets.UserFilterSet + + @classmethod + def get_queryset(cls, queryset, info): + return RestrictedQuerySet(model=User) diff --git a/netbox/users/tests/test_api.py b/netbox/users/tests/test_api.py index 9ddb76884..adaa3c337 100644 --- a/netbox/users/tests/test_api.py +++ b/netbox/users/tests/test_api.py @@ -17,7 +17,7 @@ class AppTest(APITestCase): self.assertEqual(response.status_code, 200) -class UserTest(APIViewTestCases.APIViewTestCase): +class UserTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = User view_namespace = 'users' brief_fields = ['display', 'id', 'url', 'username'] @@ -48,7 +48,7 @@ class UserTest(APIViewTestCases.APIViewTestCase): User.objects.bulk_create(users) -class GroupTest(APIViewTestCases.APIViewTestCase): +class GroupTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): model = Group view_namespace = 'users' brief_fields = ['display', 'id', 'name', 'url'] diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index b4bde9b53..4e147b7a2 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -33,6 +33,9 @@ def get_graphql_type_for_model(model): Return the GraphQL type class for the given model. """ app_name, model_name = model._meta.label.split('.') + # Object types for Django's auth models are in the users app + if app_name == 'auth': + app_name = 'users' class_name = f'{app_name}.graphql.types.{model_name}Type' try: return dynamic_import(class_name) From 8d2f79cf246a0a84bfc061ac5b86ce77d0e8b70b Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 29 Jun 2021 13:30:38 -0400 Subject: [PATCH 17/21] Add configuration parameter to toggle GraphQL API --- docs/configuration/optional-settings.md | 8 ++++++++ netbox/netbox/configuration.example.py | 3 +++ netbox/netbox/graphql/views.py | 6 +++++- netbox/netbox/settings.py | 1 + netbox/netbox/tests/test_graphql.py | 9 +++++++++ 5 files changed, 26 insertions(+), 1 deletion(-) diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 9653968fe..31f7837de 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -201,6 +201,14 @@ EXEMPT_VIEW_PERMISSIONS = ['*'] --- +## GRAPHQL_ENABLED + +Default: True + +Setting this to False will disable the GraphQL API. + +--- + ## HTTP_PROXIES Default: None diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index 8f3612a36..c6865a6ba 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -149,6 +149,9 @@ EXEMPT_VIEW_PERMISSIONS = [ # 'ipam.prefix', ] +# Enable the GraphQL API +GRAPHQL_ENABLED = True + # HTTP proxies NetBox should use when sending outbound HTTP requests (e.g. for webhooks). # HTTP_PROXIES = { # 'http': 'http://10.10.1.10:3128', diff --git a/netbox/netbox/graphql/views.py b/netbox/netbox/graphql/views.py index 047f7bab3..18ed3843d 100644 --- a/netbox/netbox/graphql/views.py +++ b/netbox/netbox/graphql/views.py @@ -1,6 +1,6 @@ from django.conf import settings from django.contrib.auth.views import redirect_to_login -from django.http import HttpResponseForbidden +from django.http import HttpResponseNotFound, HttpResponseForbidden from django.urls import reverse from graphene_django.views import GraphQLView as GraphQLView_ from rest_framework.exceptions import AuthenticationFailed @@ -14,6 +14,10 @@ class GraphQLView(GraphQLView_): """ def dispatch(self, request, *args, **kwargs): + # Enforce GRAPHQL_ENABLED + if not settings.GRAPHQL_ENABLED: + return HttpResponseNotFound("The GraphQL API is not enabled.") + # Attempt to authenticate the user using a DRF token, if provided if not request.user.is_authenticated: authenticator = TokenAuthentication() diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index b64618ea5..9014ac656 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -83,6 +83,7 @@ DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', os.path.join(os.path.dirname(BAS EMAIL = getattr(configuration, 'EMAIL', {}) ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False) EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', []) +GRAPHQL_ENABLED = getattr(configuration, 'GRAPHQL_ENABLED', True) HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None) INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1')) LOGGING = getattr(configuration, 'LOGGING', {}) diff --git a/netbox/netbox/tests/test_graphql.py b/netbox/netbox/tests/test_graphql.py index 483c125a2..2cf9ee87b 100644 --- a/netbox/netbox/tests/test_graphql.py +++ b/netbox/netbox/tests/test_graphql.py @@ -6,6 +6,15 @@ from utilities.testing import disable_warnings, TestCase class GraphQLTestCase(TestCase): + @override_settings(GRAPHQL_ENABLED=False) + def test_graphql_enabled(self): + """ + The /graphql URL should return a 404 when GRAPHQL_ENABLED=False + """ + url = reverse('graphql') + response = self.client.get(url) + self.assertHttpStatus(response, 404) + @override_settings(LOGIN_REQUIRED=True) def test_graphiql_interface(self): """ From 0d7309cb19d677fe179e5b233a68b354e2d43fb5 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 30 Jun 2021 09:35:50 -0400 Subject: [PATCH 18/21] Add documentation for GraphQL API --- docs/graphql-api/overview.md | 68 ++++++++++++++++++++++++++++++++++++ mkdocs.yml | 2 ++ 2 files changed, 70 insertions(+) create mode 100644 docs/graphql-api/overview.md diff --git a/docs/graphql-api/overview.md b/docs/graphql-api/overview.md new file mode 100644 index 000000000..b202d77af --- /dev/null +++ b/docs/graphql-api/overview.md @@ -0,0 +1,68 @@ +# GraphQL API Overview + +NetBox provides a read-only [GraphQL](https://graphql.org/) API to complement its REST API. This API is powered by the [Graphene](https://graphene-python.org/) library and [Graphene-Django](https://docs.graphene-python.org/projects/django/en/latest/). + +## Queries + +GraphQL enables the client to specify an arbitrary nested list of fields to include in the response. All queries are made to the root `/graphql` API endpoint. For example, to return the circuit ID and provider name of each circuit with an active status, you can issue a request such as the following: + +``` +curl -H "Authorization: Token $TOKEN" \ +-H "Content-Type: application/json" \ +-H "Accept: application/json" \ +http://netbox/graphql/ \ +--data '{"query": "query {circuits(status:\"active\" {cid provider {name}}}"}' +``` + +The response will include the requested data formatted as JSON: + +```json +{ + "data": { + "circuits": [ + { + "cid": "1002840283", + "provider": { + "name": "CenturyLink" + } + }, + { + "cid": "1002840457", + "provider": { + "name": "CenturyLink" + } + } + ] + } +} +``` + +!!! note + It's recommended to pass the return data through a JSON parser such as `jq` for better readability. + +NetBox provides both a singular and plural query field for each object type: + +* `object`: Returns a single object. Must specify the object's unique ID as `(id: 123)`. +* `objects`: Returns a list of objects, optionally filtered by given parameters. + +For more detail on constructing GraphQL queries, see the [Graphene documentation](https://docs.graphene-python.org/en/latest/). + +## Filtering + +The GraphQL API employs the same filtering logic as the UI and REST API. Filters can be specified as key-value pairs within parentheses immediately following the query name. For example, the following will return only sites within the North Carolina region with a status of active: + +``` +{"query": "query {sites(region:\"north-carolina\", status:\"active\") {name}}"} +``` + +## Authentication + +NetBox's GraphQL API uses the same API authentication tokens as its REST API. Authentication tokens are included with requests by attaching an `Authorization` HTTP header in the following form: + +``` +Authorization: Token $TOKEN +``` + +## Disabling the GraphQL API + +If not needed, the GraphQL API can be disabled by setting the [`GRAPHQL_ENABLED`](../configuration/optional-settings.md#graphql_enabled) configuration parameter to False and restarting NetBox. diff --git a/mkdocs.yml b/mkdocs.yml index 89b72d433..031fab634 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -87,6 +87,8 @@ nav: - Overview: 'rest-api/overview.md' - Filtering: 'rest-api/filtering.md' - Authentication: 'rest-api/authentication.md' + - GraphQL API: + - Overview: 'graphql-api/overview.md' - Development: - Introduction: 'development/index.md' - Getting Started: 'development/getting-started.md' From 728b3bac67c8bc6d173767d83198d46c582c26d1 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 30 Jun 2021 09:57:33 -0400 Subject: [PATCH 19/21] Use _list appendix for GraphQL list queries --- docs/graphql-api/overview.md | 6 ++- netbox/circuits/graphql/schema.py | 10 ++-- netbox/dcim/graphql/schema.py | 65 ++++++++++++------------- netbox/dcim/tests/test_api.py | 2 - netbox/extras/graphql/schema.py | 16 +++--- netbox/ipam/graphql/schema.py | 20 ++++---- netbox/tenancy/graphql/schema.py | 4 +- netbox/users/graphql/schema.py | 4 +- netbox/utilities/testing/api.py | 24 ++++----- netbox/virtualization/graphql/schema.py | 10 ++-- netbox/virtualization/tests/test_api.py | 2 - 11 files changed, 81 insertions(+), 82 deletions(-) diff --git a/docs/graphql-api/overview.md b/docs/graphql-api/overview.md index b202d77af..f1ce4f455 100644 --- a/docs/graphql-api/overview.md +++ b/docs/graphql-api/overview.md @@ -42,8 +42,10 @@ The response will include the requested data formatted as JSON: NetBox provides both a singular and plural query field for each object type: -* `object`: Returns a single object. Must specify the object's unique ID as `(id: 123)`. -* `objects`: Returns a list of objects, optionally filtered by given parameters. +* `$OBJECT`: Returns a single object. Must specify the object's unique ID as `(id: 123)`. +* `$OBJECT_list`: Returns a list of objects, optionally filtered by given parameters. + +For example, query `device(id:123)` to fetch a specific device (identified by its unique ID), and query `device_list` (with an optional set of fitlers) to fetch all devices. For more detail on constructing GraphQL queries, see the [Graphene documentation](https://docs.graphene-python.org/en/latest/). diff --git a/netbox/circuits/graphql/schema.py b/netbox/circuits/graphql/schema.py index 5a3f42270..f65874239 100644 --- a/netbox/circuits/graphql/schema.py +++ b/netbox/circuits/graphql/schema.py @@ -6,16 +6,16 @@ from .types import * class CircuitsQuery(graphene.ObjectType): circuit = ObjectField(CircuitType) - circuits = ObjectListField(CircuitType) + circuit_list = ObjectListField(CircuitType) circuit_termination = ObjectField(CircuitTerminationType) - circuit_terminations = ObjectListField(CircuitTerminationType) + circuit_termination_list = ObjectListField(CircuitTerminationType) circuit_type = ObjectField(CircuitTypeType) - circuit_types = ObjectListField(CircuitTypeType) + circuit_type_list = ObjectListField(CircuitTypeType) provider = ObjectField(ProviderType) - providers = ObjectListField(ProviderType) + provider_list = ObjectListField(ProviderType) provider_network = ObjectField(ProviderNetworkType) - provider_networks = ObjectListField(ProviderNetworkType) + provider_network_list = ObjectListField(ProviderNetworkType) diff --git a/netbox/dcim/graphql/schema.py b/netbox/dcim/graphql/schema.py index d296c3ebc..13e0c20ec 100644 --- a/netbox/dcim/graphql/schema.py +++ b/netbox/dcim/graphql/schema.py @@ -6,101 +6,100 @@ from .types import * class DCIMQuery(graphene.ObjectType): cable = ObjectField(CableType) - cables = ObjectListField(CableType) + cable_list = ObjectListField(CableType) console_port = ObjectField(ConsolePortType) - console_ports = ObjectListField(ConsolePortType) + console_port_list = ObjectListField(ConsolePortType) console_port_template = ObjectField(ConsolePortTemplateType) - console_port_templates = ObjectListField(ConsolePortTemplateType) + console_port_template_list = ObjectListField(ConsolePortTemplateType) console_server_port = ObjectField(ConsoleServerPortType) - console_server_ports = ObjectListField(ConsoleServerPortType) + console_server_port_list = ObjectListField(ConsoleServerPortType) console_server_port_template = ObjectField(ConsoleServerPortTemplateType) - console_server_port_templates = ObjectListField(ConsoleServerPortTemplateType) + console_server_port_template_list = ObjectListField(ConsoleServerPortTemplateType) device = ObjectField(DeviceType) - devices = ObjectListField(DeviceType) + device_list = ObjectListField(DeviceType) device_bay = ObjectField(DeviceBayType) - device_bays = ObjectListField(DeviceBayType) + device_bay_list = ObjectListField(DeviceBayType) device_bay_template = ObjectField(DeviceBayTemplateType) - device_bay_templates = ObjectListField(DeviceBayTemplateType) + device_bay_template_list = ObjectListField(DeviceBayTemplateType) device_role = ObjectField(DeviceRoleType) - device_roles = ObjectListField(DeviceRoleType) + device_role_list = ObjectListField(DeviceRoleType) device_type = ObjectField(DeviceTypeType) - device_types = ObjectListField(DeviceTypeType) + device_type_list = ObjectListField(DeviceTypeType) front_port = ObjectField(FrontPortType) - front_ports = ObjectListField(FrontPortType) + front_port_list = ObjectListField(FrontPortType) front_port_template = ObjectField(FrontPortTemplateType) - front_port_templates = ObjectListField(FrontPortTemplateType) + front_port_template_list = ObjectListField(FrontPortTemplateType) interface = ObjectField(InterfaceType) - interfaces = ObjectListField(InterfaceType) + interface_list = ObjectListField(InterfaceType) interface_template = ObjectField(InterfaceTemplateType) - interface_templates = ObjectListField(InterfaceTemplateType) + interface_template_list = ObjectListField(InterfaceTemplateType) inventory_item = ObjectField(InventoryItemType) - inventory_items = ObjectListField(InventoryItemType) + inventory_item_list = ObjectListField(InventoryItemType) location = ObjectField(LocationType) - locations = ObjectListField(LocationType) + location_list = ObjectListField(LocationType) manufacturer = ObjectField(ManufacturerType) - manufacturers = ObjectListField(ManufacturerType) + manufacturer_list = ObjectListField(ManufacturerType) platform = ObjectField(PlatformType) - platforms = ObjectListField(PlatformType) + platform_list = ObjectListField(PlatformType) power_feed = ObjectField(PowerFeedType) - power_feeds = ObjectListField(PowerFeedType) + power_feed_list = ObjectListField(PowerFeedType) power_outlet = ObjectField(PowerOutletType) - power_outlets = ObjectListField(PowerOutletType) + power_outlet_list = ObjectListField(PowerOutletType) power_outlet_template = ObjectField(PowerOutletTemplateType) - power_outlet_templates = ObjectListField(PowerOutletTemplateType) + power_outlet_template_list = ObjectListField(PowerOutletTemplateType) power_panel = ObjectField(PowerPanelType) - power_panels = ObjectListField(PowerPanelType) + power_panel_list = ObjectListField(PowerPanelType) power_port = ObjectField(PowerPortType) - power_ports = ObjectListField(PowerPortType) + power_port_list = ObjectListField(PowerPortType) power_port_template = ObjectField(PowerPortTemplateType) - power_port_templates = ObjectListField(PowerPortTemplateType) + power_port_template_list = ObjectListField(PowerPortTemplateType) rack = ObjectField(RackType) - racks = ObjectListField(RackType) + rack_list = ObjectListField(RackType) rack_reservation = ObjectField(RackReservationType) - rack_reservations = ObjectListField(RackReservationType) + rack_reservation_list = ObjectListField(RackReservationType) rack_role = ObjectField(RackRoleType) - rack_roles = ObjectListField(RackRoleType) + rack_role_list = ObjectListField(RackRoleType) rear_port = ObjectField(RearPortType) - rear_ports = ObjectListField(RearPortType) + rear_port_list = ObjectListField(RearPortType) rear_port_template = ObjectField(RearPortTemplateType) - rear_port_templates = ObjectListField(RearPortTemplateType) + rear_port_template_list = ObjectListField(RearPortTemplateType) region = ObjectField(RegionType) - regions = ObjectListField(RegionType) + region_list = ObjectListField(RegionType) site = ObjectField(SiteType) - sites = ObjectListField(SiteType) + site_list = ObjectListField(SiteType) site_group = ObjectField(SiteGroupType) - site_groups = ObjectListField(SiteGroupType) + site_group_list = ObjectListField(SiteGroupType) virtual_chassis = ObjectField(VirtualChassisType) - # TODO: Rectify list field name virtual_chassis_list = ObjectListField(VirtualChassisType) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 9d238395e..5402405c5 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1549,8 +1549,6 @@ class VirtualChassisTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIV model = VirtualChassis brief_fields = ['id', 'master', 'member_count', 'name', 'url'] - graphql_base_name_plural = 'virtual_chassis_list' - @classmethod def setUpTestData(cls): site = Site.objects.create(name='Test Site', slug='test-site') diff --git a/netbox/extras/graphql/schema.py b/netbox/extras/graphql/schema.py index a0456e7cc..3073976e8 100644 --- a/netbox/extras/graphql/schema.py +++ b/netbox/extras/graphql/schema.py @@ -6,25 +6,25 @@ from .types import * class ExtrasQuery(graphene.ObjectType): config_context = ObjectField(ConfigContextType) - config_contexts = ObjectListField(ConfigContextType) + config_context_list = ObjectListField(ConfigContextType) custom_field = ObjectField(CustomFieldType) - custom_fields = ObjectListField(CustomFieldType) + custom_field_list = ObjectListField(CustomFieldType) custom_link = ObjectField(CustomLinkType) - custom_links = ObjectListField(CustomLinkType) + custom_link_list = ObjectListField(CustomLinkType) export_template = ObjectField(ExportTemplateType) - export_templates = ObjectListField(ExportTemplateType) + export_template_list = ObjectListField(ExportTemplateType) image_attachment = ObjectField(ImageAttachmentType) - image_attachments = ObjectListField(ImageAttachmentType) + image_attachment_list = ObjectListField(ImageAttachmentType) journal_entry = ObjectField(JournalEntryType) - journal_entries = ObjectListField(JournalEntryType) + journal_entry_list = ObjectListField(JournalEntryType) tag = ObjectField(TagType) - tags = ObjectListField(TagType) + tag_list = ObjectListField(TagType) webhook = ObjectField(WebhookType) - webhooks = ObjectListField(WebhookType) + webhook_list = ObjectListField(WebhookType) diff --git a/netbox/ipam/graphql/schema.py b/netbox/ipam/graphql/schema.py index d0ba11fc7..0d4b931c7 100644 --- a/netbox/ipam/graphql/schema.py +++ b/netbox/ipam/graphql/schema.py @@ -6,31 +6,31 @@ from .types import * class IPAMQuery(graphene.ObjectType): aggregate = ObjectField(AggregateType) - aggregates = ObjectListField(AggregateType) + aggregate_list = ObjectListField(AggregateType) ip_address = ObjectField(IPAddressType) - ip_addresses = ObjectListField(IPAddressType) + ip_address_list = ObjectListField(IPAddressType) prefix = ObjectField(PrefixType) - prefixes = ObjectListField(PrefixType) + prefix_list = ObjectListField(PrefixType) rir = ObjectField(RIRType) - rirs = ObjectListField(RIRType) + rir_list = ObjectListField(RIRType) role = ObjectField(RoleType) - roles = ObjectListField(RoleType) + role_list = ObjectListField(RoleType) route_target = ObjectField(RouteTargetType) - route_targets = ObjectListField(RouteTargetType) + route_target_list = ObjectListField(RouteTargetType) service = ObjectField(ServiceType) - services = ObjectListField(ServiceType) + service_list = ObjectListField(ServiceType) vlan = ObjectField(VLANType) - vlans = ObjectListField(VLANType) + vlan_list = ObjectListField(VLANType) vlan_group = ObjectField(VLANGroupType) - vlan_groups = ObjectListField(VLANGroupType) + vlan_group_list = ObjectListField(VLANGroupType) vrf = ObjectField(VRFType) - vrfs = ObjectListField(VRFType) + vrf_list = ObjectListField(VRFType) diff --git a/netbox/tenancy/graphql/schema.py b/netbox/tenancy/graphql/schema.py index 5a2f7438b..f420eb787 100644 --- a/netbox/tenancy/graphql/schema.py +++ b/netbox/tenancy/graphql/schema.py @@ -6,7 +6,7 @@ from .types import * class TenancyQuery(graphene.ObjectType): tenant = ObjectField(TenantType) - tenants = ObjectListField(TenantType) + tenant_list = ObjectListField(TenantType) tenant_group = ObjectField(TenantGroupType) - tenant_groups = ObjectListField(TenantGroupType) + tenant_group_list = ObjectListField(TenantGroupType) diff --git a/netbox/users/graphql/schema.py b/netbox/users/graphql/schema.py index b25c8f33d..4a58be128 100644 --- a/netbox/users/graphql/schema.py +++ b/netbox/users/graphql/schema.py @@ -6,7 +6,7 @@ from .types import * class UsersQuery(graphene.ObjectType): group = ObjectField(GroupType) - groups = ObjectListField(GroupType) + group_list = ObjectListField(GroupType) user = ObjectField(UserType) - users = ObjectListField(UserType) + user_list = ObjectListField(UserType) diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index fd18259d1..89f463cdf 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -24,7 +24,7 @@ __all__ = ( # -# REST API Tests +# REST/GraphQL API Tests # class APITestCase(ModelTestCase): @@ -427,11 +427,13 @@ class APIViewTestCases: class GraphQLTestCase(APITestCase): - def _get_graphql_base_name(self, plural=False): - if plural: - return getattr(self, 'graphql_base_name_plural', - self.model._meta.verbose_name_plural.lower().replace(' ', '_')) - return getattr(self, 'graphql_base_name', self.model._meta.verbose_name.lower().replace(' ', '_')) + def _get_graphql_base_name(self): + """ + Return graphql_base_name, if set. Otherwise, construct the base name for the query + field from the model's verbose name. + """ + base_name = self.model._meta.verbose_name.lower().replace(' ', '_') + return getattr(self, 'graphql_base_name', base_name) def _build_query(self, name, **filters): type_class = get_graphql_type_for_model(self.model) @@ -466,9 +468,9 @@ class APIViewTestCases: @override_settings(LOGIN_REQUIRED=True) def test_graphql_get_object(self): url = reverse('graphql') - object_type = self._get_graphql_base_name() + field_name = self._get_graphql_base_name() object_id = self._get_queryset().first().pk - query = self._build_query(object_type, id=object_id) + query = self._build_query(field_name, id=object_id) # Non-authenticated requests should fail with disable_warnings('django.request'): @@ -491,8 +493,8 @@ class APIViewTestCases: @override_settings(LOGIN_REQUIRED=True) def test_graphql_list_objects(self): url = reverse('graphql') - object_type = self._get_graphql_base_name(plural=True) - query = self._build_query(object_type) + field_name = f'{self._get_graphql_base_name()}_list' + query = self._build_query(field_name) # Non-authenticated requests should fail with disable_warnings('django.request'): @@ -511,7 +513,7 @@ class APIViewTestCases: self.assertHttpStatus(response, status.HTTP_200_OK) data = json.loads(response.content) self.assertNotIn('errors', data) - self.assertGreater(len(data['data'][object_type]), 0) + self.assertGreater(len(data['data'][field_name]), 0) class APIViewTestCase( GetObjectViewTestCase, diff --git a/netbox/virtualization/graphql/schema.py b/netbox/virtualization/graphql/schema.py index f7d9528f0..e22532214 100644 --- a/netbox/virtualization/graphql/schema.py +++ b/netbox/virtualization/graphql/schema.py @@ -6,16 +6,16 @@ from .types import * class VirtualizationQuery(graphene.ObjectType): cluster = ObjectField(ClusterType) - clusters = ObjectListField(ClusterType) + cluster_list = ObjectListField(ClusterType) cluster_group = ObjectField(ClusterGroupType) - cluster_groups = ObjectListField(ClusterGroupType) + cluster_group_list = ObjectListField(ClusterGroupType) cluster_type = ObjectField(ClusterTypeType) - cluster_types = ObjectListField(ClusterTypeType) + cluster_type_list = ObjectListField(ClusterTypeType) virtual_machine = ObjectField(VirtualMachineType) - virtual_machines = ObjectListField(VirtualMachineType) + virtual_machine_list = ObjectListField(VirtualMachineType) vm_interface = ObjectField(VMInterfaceType) - vm_interfaces = ObjectListField(VMInterfaceType) + vm_interface_list = ObjectListField(VMInterfaceType) diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 1712e93b2..9157b8780 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -211,9 +211,7 @@ class VMInterfaceTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIView bulk_update_data = { 'description': 'New description', } - graphql_base_name = 'vm_interface' - graphql_base_name_plural = 'vm_interfaces' @classmethod def setUpTestData(cls): From e6091ee0c99c2ac2f86328ad074863bfac5fe543 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 30 Jun 2021 10:07:33 -0400 Subject: [PATCH 20/21] Consolidate GraphQLTestCase into APIViewTestCase --- netbox/circuits/tests/test_api.py | 10 +-- netbox/dcim/tests/test_api.py | 86 ++++++++++--------------- netbox/extras/tests/test_api.py | 14 ++-- netbox/ipam/tests/test_api.py | 20 +++--- netbox/tenancy/tests/test_api.py | 4 +- netbox/users/tests/test_api.py | 22 +++++-- netbox/utilities/testing/api.py | 3 +- netbox/virtualization/tests/test_api.py | 10 +-- 8 files changed, 82 insertions(+), 87 deletions(-) diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index fd9e87412..424b13d40 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -15,7 +15,7 @@ class AppTest(APITestCase): self.assertEqual(response.status_code, 200) -class ProviderTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class ProviderTest(APIViewTestCases.APIViewTestCase): model = Provider brief_fields = ['circuit_count', 'display', 'id', 'name', 'slug', 'url'] create_data = [ @@ -47,7 +47,7 @@ class ProviderTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTes Provider.objects.bulk_create(providers) -class CircuitTypeTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class CircuitTypeTest(APIViewTestCases.APIViewTestCase): model = CircuitType brief_fields = ['circuit_count', 'display', 'id', 'name', 'slug', 'url'] create_data = ( @@ -79,7 +79,7 @@ class CircuitTypeTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIView CircuitType.objects.bulk_create(circuit_types) -class CircuitTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class CircuitTest(APIViewTestCases.APIViewTestCase): model = Circuit brief_fields = ['cid', 'display', 'id', 'url'] bulk_update_data = { @@ -127,7 +127,7 @@ class CircuitTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTest ] -class CircuitTerminationTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class CircuitTerminationTest(APIViewTestCases.APIViewTestCase): model = CircuitTermination brief_fields = ['_occupied', 'cable', 'circuit', 'display', 'id', 'term_side', 'url'] @@ -180,7 +180,7 @@ class CircuitTerminationTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases. } -class ProviderNetworkTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class ProviderNetworkTest(APIViewTestCases.APIViewTestCase): model = ProviderNetwork brief_fields = ['display', 'id', 'name', 'url'] diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 5402405c5..2c64ad294 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -57,7 +57,7 @@ class Mixins: self.assertEqual(segment1[2]['name'], peer_obj.name) -class RegionTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class RegionTest(APIViewTestCases.APIViewTestCase): model = Region brief_fields = ['_depth', 'display', 'id', 'name', 'site_count', 'slug', 'url'] create_data = [ @@ -86,7 +86,7 @@ class RegionTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestC Region.objects.create(name='Region 3', slug='region-3') -class SiteGroupTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class SiteGroupTest(APIViewTestCases.APIViewTestCase): model = SiteGroup brief_fields = ['_depth', 'display', 'id', 'name', 'site_count', 'slug', 'url'] create_data = [ @@ -115,7 +115,7 @@ class SiteGroupTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTe SiteGroup.objects.create(name='Site Group 3', slug='site-group-3') -class SiteTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class SiteTest(APIViewTestCases.APIViewTestCase): model = Site brief_fields = ['display', 'id', 'name', 'slug', 'url'] bulk_update_data = { @@ -167,7 +167,7 @@ class SiteTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCas ] -class LocationTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class LocationTest(APIViewTestCases.APIViewTestCase): model = Location brief_fields = ['_depth', 'display', 'id', 'name', 'rack_count', 'slug', 'url'] bulk_update_data = { @@ -214,7 +214,7 @@ class LocationTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTes ] -class RackRoleTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class RackRoleTest(APIViewTestCases.APIViewTestCase): model = RackRole brief_fields = ['display', 'id', 'name', 'rack_count', 'slug', 'url'] create_data = [ @@ -249,7 +249,7 @@ class RackRoleTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTes RackRole.objects.bulk_create(rack_roles) -class RackTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class RackTest(APIViewTestCases.APIViewTestCase): model = Rack brief_fields = ['device_count', 'display', 'id', 'name', 'url'] bulk_update_data = { @@ -337,7 +337,7 @@ class RackTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCas self.assertEqual(response.get('Content-Type'), 'image/svg+xml') -class RackReservationTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class RackReservationTest(APIViewTestCases.APIViewTestCase): model = RackReservation brief_fields = ['display', 'id', 'units', 'url', 'user'] bulk_update_data = { @@ -384,7 +384,7 @@ class RackReservationTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.API ] -class ManufacturerTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class ManufacturerTest(APIViewTestCases.APIViewTestCase): model = Manufacturer brief_fields = ['devicetype_count', 'display', 'id', 'name', 'slug', 'url'] create_data = [ @@ -416,7 +416,7 @@ class ManufacturerTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIVie Manufacturer.objects.bulk_create(manufacturers) -class DeviceTypeTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class DeviceTypeTest(APIViewTestCases.APIViewTestCase): model = DeviceType brief_fields = ['device_count', 'display', 'id', 'manufacturer', 'model', 'slug', 'url'] bulk_update_data = { @@ -458,7 +458,7 @@ class DeviceTypeTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewT ] -class ConsolePortTemplateTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase): model = ConsolePortTemplate brief_fields = ['display', 'id', 'name', 'url'] bulk_update_data = { @@ -495,7 +495,7 @@ class ConsolePortTemplateTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases ] -class ConsoleServerPortTemplateTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase): model = ConsoleServerPortTemplate brief_fields = ['display', 'id', 'name', 'url'] bulk_update_data = { @@ -532,7 +532,7 @@ class ConsoleServerPortTemplateTest(APIViewTestCases.GraphQLTestCase, APIViewTes ] -class PowerPortTemplateTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase): model = PowerPortTemplate brief_fields = ['display', 'id', 'name', 'url'] bulk_update_data = { @@ -569,7 +569,7 @@ class PowerPortTemplateTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.A ] -class PowerOutletTemplateTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase): model = PowerOutletTemplate brief_fields = ['display', 'id', 'name', 'url'] bulk_update_data = { @@ -606,7 +606,7 @@ class PowerOutletTemplateTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases ] -class InterfaceTemplateTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase): model = InterfaceTemplate brief_fields = ['display', 'id', 'name', 'url'] bulk_update_data = { @@ -646,7 +646,7 @@ class InterfaceTemplateTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.A ] -class FrontPortTemplateTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase): model = FrontPortTemplate brief_fields = ['display', 'id', 'name', 'url'] bulk_update_data = { @@ -717,7 +717,7 @@ class FrontPortTemplateTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.A ] -class RearPortTemplateTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class RearPortTemplateTest(APIViewTestCases.APIViewTestCase): model = RearPortTemplate brief_fields = ['display', 'id', 'name', 'url'] bulk_update_data = { @@ -757,7 +757,7 @@ class RearPortTemplateTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.AP ] -class DeviceBayTemplateTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase): model = DeviceBayTemplate brief_fields = ['display', 'id', 'name', 'url'] bulk_update_data = { @@ -797,7 +797,7 @@ class DeviceBayTemplateTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.A ] -class DeviceRoleTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class DeviceRoleTest(APIViewTestCases.APIViewTestCase): model = DeviceRole brief_fields = ['device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count'] create_data = [ @@ -832,7 +832,7 @@ class DeviceRoleTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewT DeviceRole.objects.bulk_create(device_roles) -class PlatformTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class PlatformTest(APIViewTestCases.APIViewTestCase): model = Platform brief_fields = ['device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count'] create_data = [ @@ -864,7 +864,7 @@ class PlatformTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTes Platform.objects.bulk_create(platforms) -class DeviceTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class DeviceTest(APIViewTestCases.APIViewTestCase): model = Device brief_fields = ['display', 'id', 'name', 'url'] bulk_update_data = { @@ -1005,11 +1005,7 @@ class DeviceTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestC self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) -class ConsolePortTest( - Mixins.ComponentTraceMixin, - APIViewTestCases.GraphQLTestCase, - APIViewTestCases.APIViewTestCase -): +class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = ConsolePort brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { @@ -1048,11 +1044,7 @@ class ConsolePortTest( ] -class ConsoleServerPortTest( - Mixins.ComponentTraceMixin, - APIViewTestCases.GraphQLTestCase, - APIViewTestCases.APIViewTestCase -): +class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = ConsoleServerPort brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { @@ -1091,11 +1083,7 @@ class ConsoleServerPortTest( ] -class PowerPortTest( - Mixins.ComponentTraceMixin, - APIViewTestCases.GraphQLTestCase, - APIViewTestCases.APIViewTestCase -): +class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = PowerPort brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { @@ -1134,11 +1122,7 @@ class PowerPortTest( ] -class PowerOutletTest( - Mixins.ComponentTraceMixin, - APIViewTestCases.GraphQLTestCase, - APIViewTestCases.APIViewTestCase -): +class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = PowerOutlet brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { @@ -1177,11 +1161,7 @@ class PowerOutletTest( ] -class InterfaceTest( - Mixins.ComponentTraceMixin, - APIViewTestCases.GraphQLTestCase, - APIViewTestCases.APIViewTestCase -): +class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = Interface brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { @@ -1239,7 +1219,7 @@ class InterfaceTest( ] -class FrontPortTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class FrontPortTest(APIViewTestCases.APIViewTestCase): model = FrontPort brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { @@ -1297,7 +1277,7 @@ class FrontPortTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTe ] -class RearPortTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class RearPortTest(APIViewTestCases.APIViewTestCase): model = RearPort brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { @@ -1339,7 +1319,7 @@ class RearPortTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTes ] -class DeviceBayTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class DeviceBayTest(APIViewTestCases.APIViewTestCase): model = DeviceBay brief_fields = ['device', 'display', 'id', 'name', 'url'] bulk_update_data = { @@ -1402,7 +1382,7 @@ class DeviceBayTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTe ] -class InventoryItemTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class InventoryItemTest(APIViewTestCases.APIViewTestCase): model = InventoryItem brief_fields = ['_depth', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { @@ -1440,7 +1420,7 @@ class InventoryItemTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIVi ] -class CableTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class CableTest(APIViewTestCases.APIViewTestCase): model = Cable brief_fields = ['display', 'id', 'label', 'url'] bulk_update_data = { @@ -1545,7 +1525,7 @@ class ConnectedDeviceTest(APITestCase): self.assertEqual(response.data['name'], self.device1.name) -class VirtualChassisTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class VirtualChassisTest(APIViewTestCases.APIViewTestCase): model = VirtualChassis brief_fields = ['id', 'master', 'member_count', 'name', 'url'] @@ -1625,7 +1605,7 @@ class VirtualChassisTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIV } -class PowerPanelTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class PowerPanelTest(APIViewTestCases.APIViewTestCase): model = PowerPanel brief_fields = ['display', 'id', 'name', 'powerfeed_count', 'url'] @@ -1674,7 +1654,7 @@ class PowerPanelTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewT } -class PowerFeedTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class PowerFeedTest(APIViewTestCases.APIViewTestCase): model = PowerFeed brief_fields = ['_occupied', 'cable', 'display', 'id', 'name', 'url'] bulk_update_data = { diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 76f8bfea3..d15b57e43 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -31,7 +31,7 @@ class AppTest(APITestCase): self.assertEqual(response.status_code, 200) -class WebhookTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class WebhookTest(APIViewTestCases.APIViewTestCase): model = Webhook brief_fields = ['display', 'id', 'name', 'url'] create_data = [ @@ -85,7 +85,7 @@ class WebhookTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTest webhook.content_types.add(site_ct, rack_ct) -class CustomFieldTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class CustomFieldTest(APIViewTestCases.APIViewTestCase): model = CustomField brief_fields = ['display', 'id', 'name', 'url'] create_data = [ @@ -132,7 +132,7 @@ class CustomFieldTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIView cf.content_types.add(site_ct) -class CustomLinkTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class CustomLinkTest(APIViewTestCases.APIViewTestCase): model = CustomLink brief_fields = ['display', 'id', 'name', 'url'] create_data = [ @@ -186,7 +186,7 @@ class CustomLinkTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewT CustomLink.objects.bulk_create(custom_links) -class ExportTemplateTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class ExportTemplateTest(APIViewTestCases.APIViewTestCase): model = ExportTemplate brief_fields = ['display', 'id', 'name', 'url'] create_data = [ @@ -234,7 +234,7 @@ class ExportTemplateTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIV ExportTemplate.objects.bulk_create(export_templates) -class TagTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class TagTest(APIViewTestCases.APIViewTestCase): model = Tag brief_fields = ['color', 'display', 'id', 'name', 'slug', 'url'] create_data = [ @@ -311,7 +311,7 @@ class ImageAttachmentTest( ImageAttachment.objects.bulk_create(image_attachments) -class JournalEntryTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class JournalEntryTest(APIViewTestCases.APIViewTestCase): model = JournalEntry brief_fields = ['created', 'display', 'id', 'url'] bulk_update_data = { @@ -361,7 +361,7 @@ class JournalEntryTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIVie ] -class ConfigContextTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class ConfigContextTest(APIViewTestCases.APIViewTestCase): model = ConfigContext brief_fields = ['display', 'id', 'name', 'url'] create_data = [ diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 6b86e2d26..38339fa14 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -20,7 +20,7 @@ class AppTest(APITestCase): self.assertEqual(response.status_code, 200) -class VRFTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class VRFTest(APIViewTestCases.APIViewTestCase): model = VRF brief_fields = ['display', 'id', 'name', 'prefix_count', 'rd', 'url'] create_data = [ @@ -52,7 +52,7 @@ class VRFTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase VRF.objects.bulk_create(vrfs) -class RouteTargetTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class RouteTargetTest(APIViewTestCases.APIViewTestCase): model = RouteTarget brief_fields = ['display', 'id', 'name', 'url'] create_data = [ @@ -81,7 +81,7 @@ class RouteTargetTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIView RouteTarget.objects.bulk_create(route_targets) -class RIRTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class RIRTest(APIViewTestCases.APIViewTestCase): model = RIR brief_fields = ['aggregate_count', 'display', 'id', 'name', 'slug', 'url'] create_data = [ @@ -113,7 +113,7 @@ class RIRTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase RIR.objects.bulk_create(rirs) -class AggregateTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class AggregateTest(APIViewTestCases.APIViewTestCase): model = Aggregate brief_fields = ['display', 'family', 'id', 'prefix', 'url'] bulk_update_data = { @@ -152,7 +152,7 @@ class AggregateTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTe ] -class RoleTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class RoleTest(APIViewTestCases.APIViewTestCase): model = Role brief_fields = ['display', 'id', 'name', 'prefix_count', 'slug', 'url', 'vlan_count'] create_data = [ @@ -184,7 +184,7 @@ class RoleTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCas Role.objects.bulk_create(roles) -class PrefixTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class PrefixTest(APIViewTestCases.APIViewTestCase): model = Prefix brief_fields = ['_depth', 'display', 'family', 'id', 'prefix', 'url'] create_data = [ @@ -358,7 +358,7 @@ class PrefixTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestC self.assertEqual(len(response.data), 8) -class IPAddressTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class IPAddressTest(APIViewTestCases.APIViewTestCase): model = IPAddress brief_fields = ['address', 'display', 'family', 'id', 'url'] create_data = [ @@ -387,7 +387,7 @@ class IPAddressTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTe IPAddress.objects.bulk_create(ip_addresses) -class VLANGroupTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class VLANGroupTest(APIViewTestCases.APIViewTestCase): model = VLANGroup brief_fields = ['display', 'id', 'name', 'slug', 'url', 'vlan_count'] create_data = [ @@ -419,7 +419,7 @@ class VLANGroupTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTe VLANGroup.objects.bulk_create(vlan_groups) -class VLANTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class VLANTest(APIViewTestCases.APIViewTestCase): model = VLAN brief_fields = ['display', 'id', 'name', 'url', 'vid'] bulk_update_data = { @@ -479,7 +479,7 @@ class VLANTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCas self.assertTrue(content['detail'].startswith('Unable to delete object.')) -class ServiceTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class ServiceTest(APIViewTestCases.APIViewTestCase): model = Service brief_fields = ['display', 'id', 'name', 'ports', 'protocol', 'url'] bulk_update_data = { diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py index 1777f204a..5a3c2c1b0 100644 --- a/netbox/tenancy/tests/test_api.py +++ b/netbox/tenancy/tests/test_api.py @@ -14,7 +14,7 @@ class AppTest(APITestCase): self.assertEqual(response.status_code, 200) -class TenantGroupTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class TenantGroupTest(APIViewTestCases.APIViewTestCase): model = TenantGroup brief_fields = ['_depth', 'display', 'id', 'name', 'slug', 'tenant_count', 'url'] bulk_update_data = { @@ -52,7 +52,7 @@ class TenantGroupTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIView ] -class TenantTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class TenantTest(APIViewTestCases.APIViewTestCase): model = Tenant brief_fields = ['display', 'id', 'name', 'slug', 'url'] bulk_update_data = { diff --git a/netbox/users/tests/test_api.py b/netbox/users/tests/test_api.py index adaa3c337..bcfc9cf14 100644 --- a/netbox/users/tests/test_api.py +++ b/netbox/users/tests/test_api.py @@ -17,7 +17,7 @@ class AppTest(APITestCase): self.assertEqual(response.status_code, 200) -class UserTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class UserTest(APIViewTestCases.APIViewTestCase): model = User view_namespace = 'users' brief_fields = ['display', 'id', 'url', 'username'] @@ -48,7 +48,7 @@ class UserTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCas User.objects.bulk_create(users) -class GroupTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class GroupTest(APIViewTestCases.APIViewTestCase): model = Group view_namespace = 'users' brief_fields = ['display', 'id', 'name', 'url'] @@ -75,7 +75,14 @@ class GroupTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCa Group.objects.bulk_create(users) -class TokenTest(APIViewTestCases.APIViewTestCase): +class TokenTest( + # No GraphQL support for Token + APIViewTestCases.GetObjectViewTestCase, + APIViewTestCases.ListObjectsViewTestCase, + APIViewTestCases.CreateObjectViewTestCase, + APIViewTestCases.UpdateObjectViewTestCase, + APIViewTestCases.DeleteObjectViewTestCase +): model = Token brief_fields = ['display', 'id', 'key', 'url', 'write_enabled'] bulk_update_data = { @@ -138,7 +145,14 @@ class TokenTest(APIViewTestCases.APIViewTestCase): self.assertEqual(response.status_code, 403) -class ObjectPermissionTest(APIViewTestCases.APIViewTestCase): +class ObjectPermissionTest( + # No GraphQL support for ObjectPermission + APIViewTestCases.GetObjectViewTestCase, + APIViewTestCases.ListObjectsViewTestCase, + APIViewTestCases.CreateObjectViewTestCase, + APIViewTestCases.UpdateObjectViewTestCase, + APIViewTestCases.DeleteObjectViewTestCase +): model = ObjectPermission brief_fields = ['actions', 'display', 'enabled', 'groups', 'id', 'name', 'object_types', 'url', 'users'] diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index 89f463cdf..f0c1c92c8 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -520,6 +520,7 @@ class APIViewTestCases: ListObjectsViewTestCase, CreateObjectViewTestCase, UpdateObjectViewTestCase, - DeleteObjectViewTestCase + DeleteObjectViewTestCase, + GraphQLTestCase ): pass diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 9157b8780..289ccf843 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -17,7 +17,7 @@ class AppTest(APITestCase): self.assertEqual(response.status_code, 200) -class ClusterTypeTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class ClusterTypeTest(APIViewTestCases.APIViewTestCase): model = ClusterType brief_fields = ['cluster_count', 'display', 'id', 'name', 'slug', 'url'] create_data = [ @@ -49,7 +49,7 @@ class ClusterTypeTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIView ClusterType.objects.bulk_create(cluster_types) -class ClusterGroupTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class ClusterGroupTest(APIViewTestCases.APIViewTestCase): model = ClusterGroup brief_fields = ['cluster_count', 'display', 'id', 'name', 'slug', 'url'] create_data = [ @@ -81,7 +81,7 @@ class ClusterGroupTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIVie ClusterGroup.objects.bulk_create(cluster_Groups) -class ClusterTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class ClusterTest(APIViewTestCases.APIViewTestCase): model = Cluster brief_fields = ['display', 'id', 'name', 'url', 'virtualmachine_count'] bulk_update_data = { @@ -129,7 +129,7 @@ class ClusterTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTest ] -class VirtualMachineTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class VirtualMachineTest(APIViewTestCases.APIViewTestCase): model = VirtualMachine brief_fields = ['display', 'id', 'name', 'url'] bulk_update_data = { @@ -205,7 +205,7 @@ class VirtualMachineTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIV self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) -class VMInterfaceTest(APIViewTestCases.GraphQLTestCase, APIViewTestCases.APIViewTestCase): +class VMInterfaceTest(APIViewTestCases.APIViewTestCase): model = VMInterface brief_fields = ['display', 'id', 'name', 'url', 'virtual_machine'] bulk_update_data = { From 2fe734e6f911f967a913d84de4e5874ea3401de7 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 30 Jun 2021 10:20:20 -0400 Subject: [PATCH 21/21] Omit _path from CableTermination models --- netbox/dcim/graphql/types.py | 12 ++++++------ netbox/utilities/testing/api.py | 3 --- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 12342ff4b..de091ec64 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -56,7 +56,7 @@ class ConsolePortType(TaggedObjectType): class Meta: model = models.ConsolePort - fields = '__all__' + exclude = ('_path',) filterset_class = filtersets.ConsolePortFilterSet def resolve_type(self, info): @@ -78,7 +78,7 @@ class ConsoleServerPortType(TaggedObjectType): class Meta: model = models.ConsoleServerPort - fields = '__all__' + exclude = ('_path',) filterset_class = filtersets.ConsoleServerPortFilterSet def resolve_type(self, info): @@ -162,7 +162,7 @@ class InterfaceType(TaggedObjectType): class Meta: model = models.Interface - fields = '__all__' + exclude = ('_path',) filterset_class = filtersets.InterfaceFilterSet def resolve_mode(self, info): @@ -213,7 +213,7 @@ class PowerFeedType(TaggedObjectType): class Meta: model = models.PowerFeed - fields = '__all__' + exclude = ('_path',) filterset_class = filtersets.PowerFeedFilterSet @@ -221,7 +221,7 @@ class PowerOutletType(TaggedObjectType): class Meta: model = models.PowerOutlet - fields = '__all__' + exclude = ('_path',) filterset_class = filtersets.PowerOutletFilterSet def resolve_feed_leg(self, info): @@ -257,7 +257,7 @@ class PowerPortType(TaggedObjectType): class Meta: model = models.PowerPort - fields = '__all__' + exclude = ('_path',) filterset_class = filtersets.PowerPortFilterSet def resolve_type(self, info): diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index f0c1c92c8..3bb54b529 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -446,9 +446,6 @@ class APIViewTestCases: # Compile list of fields to include fields_string = '' for field_name, field in type_class._meta.fields.items(): - # TODO: Omit "hidden" fields from GraphQL types - if field_name.startswith('_'): - continue if type(field) is Dynamic: # Dynamic fields must specify a subselection fields_string += f'{field_name} {{ id }}\n'