diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 9b3f10589..dd2819cb6 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -2,6 +2,10 @@ ## v3.0-beta2 (FUTURE) +### Enhancements + +* [#6829](https://github.com/netbox-community/netbox/issues/6829) - Extend GraphQL API to support reverse generic relationships + ### Bug Fixes * [#6811](https://github.com/netbox-community/netbox/issues/6811) - Fix exception when editing users diff --git a/netbox/circuits/graphql/types.py b/netbox/circuits/graphql/types.py index bde25a09f..a6c28c4cd 100644 --- a/netbox/circuits/graphql/types.py +++ b/netbox/circuits/graphql/types.py @@ -1,5 +1,5 @@ from circuits import filtersets, models -from netbox.graphql.types import BaseObjectType, ObjectType, TaggedObjectType +from netbox.graphql.types import ObjectType, OrganizationalObjectType, PrimaryObjectType __all__ = ( 'CircuitTerminationType', @@ -10,7 +10,7 @@ __all__ = ( ) -class CircuitTerminationType(BaseObjectType): +class CircuitTerminationType(ObjectType): class Meta: model = models.CircuitTermination @@ -18,7 +18,7 @@ class CircuitTerminationType(BaseObjectType): filterset_class = filtersets.CircuitTerminationFilterSet -class CircuitType(TaggedObjectType): +class CircuitType(PrimaryObjectType): class Meta: model = models.Circuit @@ -26,7 +26,7 @@ class CircuitType(TaggedObjectType): filterset_class = filtersets.CircuitFilterSet -class CircuitTypeType(ObjectType): +class CircuitTypeType(OrganizationalObjectType): class Meta: model = models.CircuitType @@ -34,7 +34,7 @@ class CircuitTypeType(ObjectType): filterset_class = filtersets.CircuitTypeFilterSet -class ProviderType(TaggedObjectType): +class ProviderType(PrimaryObjectType): class Meta: model = models.Provider @@ -42,7 +42,7 @@ class ProviderType(TaggedObjectType): filterset_class = filtersets.ProviderFilterSet -class ProviderNetworkType(TaggedObjectType): +class ProviderNetworkType(PrimaryObjectType): class Meta: model = models.ProviderNetwork diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index de091ec64..45552ae8c 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -1,8 +1,11 @@ from dcim import filtersets, models -from netbox.graphql.types import BaseObjectType, ObjectType, TaggedObjectType +from extras.graphql.mixins import ChangelogMixin, CustomFieldsMixin, ImageAttachmentsMixin, TagsMixin +from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin +from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, PrimaryObjectType __all__ = ( 'CableType', + 'ComponentObjectType', 'ConsolePortType', 'ConsolePortTemplateType', 'ConsoleServerPortType', @@ -38,7 +41,40 @@ __all__ = ( ) -class CableType(TaggedObjectType): +# +# Base types +# + + +class ComponentObjectType( + ChangelogMixin, + CustomFieldsMixin, + TagsMixin, + BaseObjectType +): + """ + Base type for device/VM components + """ + class Meta: + abstract = True + + +class ComponentTemplateObjectType( + ChangelogMixin, + BaseObjectType +): + """ + Base type for device/VM components + """ + class Meta: + abstract = True + + +# +# Model types +# + +class CableType(PrimaryObjectType): class Meta: model = models.Cable @@ -52,7 +88,7 @@ class CableType(TaggedObjectType): return self.length_unit or None -class ConsolePortType(TaggedObjectType): +class ConsolePortType(ComponentObjectType): class Meta: model = models.ConsolePort @@ -63,7 +99,7 @@ class ConsolePortType(TaggedObjectType): return self.type or None -class ConsolePortTemplateType(BaseObjectType): +class ConsolePortTemplateType(ComponentTemplateObjectType): class Meta: model = models.ConsolePortTemplate @@ -74,7 +110,7 @@ class ConsolePortTemplateType(BaseObjectType): return self.type or None -class ConsoleServerPortType(TaggedObjectType): +class ConsoleServerPortType(ComponentObjectType): class Meta: model = models.ConsoleServerPort @@ -85,7 +121,7 @@ class ConsoleServerPortType(TaggedObjectType): return self.type or None -class ConsoleServerPortTemplateType(BaseObjectType): +class ConsoleServerPortTemplateType(ComponentTemplateObjectType): class Meta: model = models.ConsoleServerPortTemplate @@ -96,7 +132,7 @@ class ConsoleServerPortTemplateType(BaseObjectType): return self.type or None -class DeviceType(TaggedObjectType): +class DeviceType(ImageAttachmentsMixin, PrimaryObjectType): class Meta: model = models.Device @@ -107,7 +143,7 @@ class DeviceType(TaggedObjectType): return self.face or None -class DeviceBayType(TaggedObjectType): +class DeviceBayType(ComponentObjectType): class Meta: model = models.DeviceBay @@ -115,7 +151,7 @@ class DeviceBayType(TaggedObjectType): filterset_class = filtersets.DeviceBayFilterSet -class DeviceBayTemplateType(BaseObjectType): +class DeviceBayTemplateType(ComponentTemplateObjectType): class Meta: model = models.DeviceBayTemplate @@ -123,7 +159,7 @@ class DeviceBayTemplateType(BaseObjectType): filterset_class = filtersets.DeviceBayTemplateFilterSet -class DeviceRoleType(ObjectType): +class DeviceRoleType(OrganizationalObjectType): class Meta: model = models.DeviceRole @@ -131,7 +167,7 @@ class DeviceRoleType(ObjectType): filterset_class = filtersets.DeviceRoleFilterSet -class DeviceTypeType(TaggedObjectType): +class DeviceTypeType(PrimaryObjectType): class Meta: model = models.DeviceType @@ -142,7 +178,7 @@ class DeviceTypeType(TaggedObjectType): return self.subdevice_role or None -class FrontPortType(TaggedObjectType): +class FrontPortType(ComponentObjectType): class Meta: model = models.FrontPort @@ -150,7 +186,7 @@ class FrontPortType(TaggedObjectType): filterset_class = filtersets.FrontPortFilterSet -class FrontPortTemplateType(BaseObjectType): +class FrontPortTemplateType(ComponentTemplateObjectType): class Meta: model = models.FrontPortTemplate @@ -158,7 +194,7 @@ class FrontPortTemplateType(BaseObjectType): filterset_class = filtersets.FrontPortTemplateFilterSet -class InterfaceType(TaggedObjectType): +class InterfaceType(IPAddressesMixin, ComponentObjectType): class Meta: model = models.Interface @@ -169,7 +205,7 @@ class InterfaceType(TaggedObjectType): return self.mode or None -class InterfaceTemplateType(BaseObjectType): +class InterfaceTemplateType(ComponentTemplateObjectType): class Meta: model = models.InterfaceTemplate @@ -177,7 +213,7 @@ class InterfaceTemplateType(BaseObjectType): filterset_class = filtersets.InterfaceTemplateFilterSet -class InventoryItemType(TaggedObjectType): +class InventoryItemType(ComponentObjectType): class Meta: model = models.InventoryItem @@ -185,7 +221,7 @@ class InventoryItemType(TaggedObjectType): filterset_class = filtersets.InventoryItemFilterSet -class LocationType(ObjectType): +class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, OrganizationalObjectType): class Meta: model = models.Location @@ -193,7 +229,7 @@ class LocationType(ObjectType): filterset_class = filtersets.LocationFilterSet -class ManufacturerType(ObjectType): +class ManufacturerType(OrganizationalObjectType): class Meta: model = models.Manufacturer @@ -201,7 +237,7 @@ class ManufacturerType(ObjectType): filterset_class = filtersets.ManufacturerFilterSet -class PlatformType(ObjectType): +class PlatformType(OrganizationalObjectType): class Meta: model = models.Platform @@ -209,7 +245,7 @@ class PlatformType(ObjectType): filterset_class = filtersets.PlatformFilterSet -class PowerFeedType(TaggedObjectType): +class PowerFeedType(PrimaryObjectType): class Meta: model = models.PowerFeed @@ -217,7 +253,7 @@ class PowerFeedType(TaggedObjectType): filterset_class = filtersets.PowerFeedFilterSet -class PowerOutletType(TaggedObjectType): +class PowerOutletType(ComponentObjectType): class Meta: model = models.PowerOutlet @@ -231,7 +267,7 @@ class PowerOutletType(TaggedObjectType): return self.type or None -class PowerOutletTemplateType(BaseObjectType): +class PowerOutletTemplateType(ComponentTemplateObjectType): class Meta: model = models.PowerOutletTemplate @@ -245,7 +281,7 @@ class PowerOutletTemplateType(BaseObjectType): return self.type or None -class PowerPanelType(TaggedObjectType): +class PowerPanelType(PrimaryObjectType): class Meta: model = models.PowerPanel @@ -253,7 +289,7 @@ class PowerPanelType(TaggedObjectType): filterset_class = filtersets.PowerPanelFilterSet -class PowerPortType(TaggedObjectType): +class PowerPortType(ComponentObjectType): class Meta: model = models.PowerPort @@ -264,7 +300,7 @@ class PowerPortType(TaggedObjectType): return self.type or None -class PowerPortTemplateType(BaseObjectType): +class PowerPortTemplateType(ComponentTemplateObjectType): class Meta: model = models.PowerPortTemplate @@ -275,7 +311,7 @@ class PowerPortTemplateType(BaseObjectType): return self.type or None -class RackType(TaggedObjectType): +class RackType(VLANGroupsMixin, ImageAttachmentsMixin, PrimaryObjectType): class Meta: model = models.Rack @@ -289,7 +325,7 @@ class RackType(TaggedObjectType): return self.outer_unit or None -class RackReservationType(TaggedObjectType): +class RackReservationType(PrimaryObjectType): class Meta: model = models.RackReservation @@ -297,7 +333,7 @@ class RackReservationType(TaggedObjectType): filterset_class = filtersets.RackReservationFilterSet -class RackRoleType(ObjectType): +class RackRoleType(OrganizationalObjectType): class Meta: model = models.RackRole @@ -305,7 +341,7 @@ class RackRoleType(ObjectType): filterset_class = filtersets.RackRoleFilterSet -class RearPortType(TaggedObjectType): +class RearPortType(ComponentObjectType): class Meta: model = models.RearPort @@ -313,7 +349,7 @@ class RearPortType(TaggedObjectType): filterset_class = filtersets.RearPortFilterSet -class RearPortTemplateType(BaseObjectType): +class RearPortTemplateType(ComponentTemplateObjectType): class Meta: model = models.RearPortTemplate @@ -321,7 +357,7 @@ class RearPortTemplateType(BaseObjectType): filterset_class = filtersets.RearPortTemplateFilterSet -class RegionType(ObjectType): +class RegionType(VLANGroupsMixin, OrganizationalObjectType): class Meta: model = models.Region @@ -329,7 +365,7 @@ class RegionType(ObjectType): filterset_class = filtersets.RegionFilterSet -class SiteType(TaggedObjectType): +class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, PrimaryObjectType): class Meta: model = models.Site @@ -337,7 +373,7 @@ class SiteType(TaggedObjectType): filterset_class = filtersets.SiteFilterSet -class SiteGroupType(ObjectType): +class SiteGroupType(VLANGroupsMixin, OrganizationalObjectType): class Meta: model = models.SiteGroup @@ -345,7 +381,7 @@ class SiteGroupType(ObjectType): filterset_class = filtersets.SiteGroupFilterSet -class VirtualChassisType(TaggedObjectType): +class VirtualChassisType(PrimaryObjectType): class Meta: model = models.VirtualChassis diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 3b74a3d3d..c287d7d6c 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -175,6 +175,12 @@ class Rack(PrimaryModel): comments = models.TextField( blank=True ) + vlan_groups = GenericRelation( + to='ipam.VLANGroup', + content_type_field='scope_type', + object_id_field='scope_id', + related_query_name='rack' + ) images = GenericRelation( to='extras.ImageAttachment' ) diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index 943e98106..56946642b 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -53,6 +53,12 @@ class Region(NestedGroupModel): max_length=200, blank=True ) + vlan_groups = GenericRelation( + to='ipam.VLANGroup', + content_type_field='scope_type', + object_id_field='scope_id', + related_query_name='region' + ) def get_absolute_url(self): return reverse('dcim:region', args=[self.pk]) @@ -95,6 +101,12 @@ class SiteGroup(NestedGroupModel): max_length=200, blank=True ) + vlan_groups = GenericRelation( + to='ipam.VLANGroup', + content_type_field='scope_type', + object_id_field='scope_id', + related_query_name='site_group' + ) def get_absolute_url(self): return reverse('dcim:sitegroup', args=[self.pk]) @@ -210,6 +222,12 @@ class Site(PrimaryModel): comments = models.TextField( blank=True ) + vlan_groups = GenericRelation( + to='ipam.VLANGroup', + content_type_field='scope_type', + object_id_field='scope_id', + related_query_name='site' + ) images = GenericRelation( to='extras.ImageAttachment' ) @@ -267,6 +285,12 @@ class Location(NestedGroupModel): max_length=200, blank=True ) + vlan_groups = GenericRelation( + to='ipam.VLANGroup', + content_type_field='scope_type', + object_id_field='scope_id', + related_query_name='location' + ) images = GenericRelation( to='extras.ImageAttachment' ) diff --git a/netbox/extras/graphql/mixins.py b/netbox/extras/graphql/mixins.py new file mode 100644 index 000000000..0afa6732e --- /dev/null +++ b/netbox/extras/graphql/mixins.py @@ -0,0 +1,45 @@ +import graphene +from graphene.types.generic import GenericScalar + +__all__ = ( + 'ChangelogMixin', + 'CustomFieldsMixin', + 'ImageAttachmentsMixin', + 'JournalEntriesMixin', + 'TagsMixin', +) + + +class ChangelogMixin: + changelog = graphene.List('extras.graphql.types.ObjectChangeType') + + def resolve_changelog(self, info): + return self.object_changes.restrict(info.context.user, 'view') + + +class CustomFieldsMixin: + custom_fields = GenericScalar() + + def resolve_custom_fields(self, info): + return self.custom_field_data + + +class ImageAttachmentsMixin: + image_attachments = graphene.List('extras.graphql.types.ImageAttachmentType') + + def resolve_image_attachments(self, info): + return self.images.restrict(info.context.user, 'view') + + +class JournalEntriesMixin: + journal_entries = graphene.List('extras.graphql.types.JournalEntryType') + + def resolve_journal_entries(self, info): + return self.journal_entries.restrict(info.context.user, 'view') + + +class TagsMixin: + tags = graphene.List(graphene.String) + + def resolve_tags(self, info): + return self.tags.all() diff --git a/netbox/extras/graphql/types.py b/netbox/extras/graphql/types.py index 96651351d..c5f34a11e 100644 --- a/netbox/extras/graphql/types.py +++ b/netbox/extras/graphql/types.py @@ -1,5 +1,5 @@ from extras import filtersets, models -from netbox.graphql.types import BaseObjectType +from netbox.graphql.types import BaseObjectType, ObjectType __all__ = ( 'ConfigContextType', @@ -8,12 +8,13 @@ __all__ = ( 'ExportTemplateType', 'ImageAttachmentType', 'JournalEntryType', + 'ObjectChangeType', 'TagType', 'WebhookType', ) -class ConfigContextType(BaseObjectType): +class ConfigContextType(ObjectType): class Meta: model = models.ConfigContext @@ -21,7 +22,7 @@ class ConfigContextType(BaseObjectType): filterset_class = filtersets.ConfigContextFilterSet -class CustomFieldType(BaseObjectType): +class CustomFieldType(ObjectType): class Meta: model = models.CustomField @@ -29,7 +30,7 @@ class CustomFieldType(BaseObjectType): filterset_class = filtersets.CustomFieldFilterSet -class CustomLinkType(BaseObjectType): +class CustomLinkType(ObjectType): class Meta: model = models.CustomLink @@ -37,7 +38,7 @@ class CustomLinkType(BaseObjectType): filterset_class = filtersets.CustomLinkFilterSet -class ExportTemplateType(BaseObjectType): +class ExportTemplateType(ObjectType): class Meta: model = models.ExportTemplate @@ -53,7 +54,7 @@ class ImageAttachmentType(BaseObjectType): filterset_class = filtersets.ImageAttachmentFilterSet -class JournalEntryType(BaseObjectType): +class JournalEntryType(ObjectType): class Meta: model = models.JournalEntry @@ -61,7 +62,15 @@ class JournalEntryType(BaseObjectType): filterset_class = filtersets.JournalEntryFilterSet -class TagType(BaseObjectType): +class ObjectChangeType(BaseObjectType): + + class Meta: + model = models.ObjectChange + fields = '__all__' + filterset_class = filtersets.ObjectChangeFilterSet + + +class TagType(ObjectType): class Meta: model = models.Tag @@ -69,7 +78,7 @@ class TagType(BaseObjectType): filterset_class = filtersets.TagFilterSet -class WebhookType(BaseObjectType): +class WebhookType(ObjectType): class Meta: model = models.Webhook diff --git a/netbox/ipam/graphql/mixins.py b/netbox/ipam/graphql/mixins.py new file mode 100644 index 000000000..283414df3 --- /dev/null +++ b/netbox/ipam/graphql/mixins.py @@ -0,0 +1,20 @@ +import graphene + +__all__ = ( + 'IPAddressesMixin', + 'VLANGroupsMixin', +) + + +class IPAddressesMixin: + ip_addresses = graphene.List('ipam.graphql.types.IPAddressType') + + def resolve_ip_addresses(self, info): + return self.ip_addresses.restrict(info.context.user, 'view') + + +class VLANGroupsMixin: + vlan_groups = graphene.List('ipam.graphql.types.VLANGroupType') + + def resolve_vlan_groups(self, info): + return self.vlan_groups.restrict(info.context.user, 'view') diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index 2172d63fa..c822dab6b 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -1,5 +1,5 @@ from ipam import filtersets, models -from netbox.graphql.types import ObjectType, TaggedObjectType +from netbox.graphql.types import OrganizationalObjectType, PrimaryObjectType __all__ = ( 'AggregateType', @@ -16,7 +16,7 @@ __all__ = ( ) -class AggregateType(TaggedObjectType): +class AggregateType(PrimaryObjectType): class Meta: model = models.Aggregate @@ -24,7 +24,7 @@ class AggregateType(TaggedObjectType): filterset_class = filtersets.AggregateFilterSet -class IPAddressType(TaggedObjectType): +class IPAddressType(PrimaryObjectType): class Meta: model = models.IPAddress @@ -35,7 +35,7 @@ class IPAddressType(TaggedObjectType): return self.role or None -class IPRangeType(TaggedObjectType): +class IPRangeType(PrimaryObjectType): class Meta: model = models.IPRange @@ -46,7 +46,7 @@ class IPRangeType(TaggedObjectType): return self.role or None -class PrefixType(TaggedObjectType): +class PrefixType(PrimaryObjectType): class Meta: model = models.Prefix @@ -54,7 +54,7 @@ class PrefixType(TaggedObjectType): filterset_class = filtersets.PrefixFilterSet -class RIRType(ObjectType): +class RIRType(OrganizationalObjectType): class Meta: model = models.RIR @@ -62,7 +62,7 @@ class RIRType(ObjectType): filterset_class = filtersets.RIRFilterSet -class RoleType(ObjectType): +class RoleType(OrganizationalObjectType): class Meta: model = models.Role @@ -70,7 +70,7 @@ class RoleType(ObjectType): filterset_class = filtersets.RoleFilterSet -class RouteTargetType(TaggedObjectType): +class RouteTargetType(PrimaryObjectType): class Meta: model = models.RouteTarget @@ -78,7 +78,7 @@ class RouteTargetType(TaggedObjectType): filterset_class = filtersets.RouteTargetFilterSet -class ServiceType(TaggedObjectType): +class ServiceType(PrimaryObjectType): class Meta: model = models.Service @@ -86,7 +86,7 @@ class ServiceType(TaggedObjectType): filterset_class = filtersets.ServiceFilterSet -class VLANType(TaggedObjectType): +class VLANType(PrimaryObjectType): class Meta: model = models.VLAN @@ -94,7 +94,7 @@ class VLANType(TaggedObjectType): filterset_class = filtersets.VLANFilterSet -class VLANGroupType(ObjectType): +class VLANGroupType(OrganizationalObjectType): class Meta: model = models.VLANGroup @@ -102,7 +102,7 @@ class VLANGroupType(ObjectType): filterset_class = filtersets.VLANGroupFilterSet -class VRFType(TaggedObjectType): +class VRFType(PrimaryObjectType): class Meta: model = models.VRF diff --git a/netbox/netbox/graphql/types.py b/netbox/netbox/graphql/types.py index 2958247b7..181b9a0c6 100644 --- a/netbox/netbox/graphql/types.py +++ b/netbox/netbox/graphql/types.py @@ -1,12 +1,12 @@ -import graphene from django.contrib.contenttypes.models import ContentType -from graphene.types.generic import GenericScalar from graphene_django import DjangoObjectType +from extras.graphql.mixins import ChangelogMixin, CustomFieldsMixin, JournalEntriesMixin, TagsMixin + __all__ = ( 'BaseObjectType', - 'ObjectType', - 'TaggedObjectType', + 'OrganizationalObjectType', + 'PrimaryObjectType', ) @@ -27,30 +27,41 @@ class BaseObjectType(DjangoObjectType): return queryset.restrict(info.context.user, 'view') -class ObjectType(BaseObjectType): +class ObjectType( + ChangelogMixin, + BaseObjectType +): """ - Extends BaseObjectType with support for custom field data. + Base GraphQL object type for unclassified models which support change logging """ - custom_fields = GenericScalar() - class Meta: abstract = True - def resolve_custom_fields(self, info): - return self.custom_field_data - -class TaggedObjectType(ObjectType): +class OrganizationalObjectType( + ChangelogMixin, + CustomFieldsMixin, + BaseObjectType +): """ - Extends ObjectType with support for Tags + Base type for organizational models """ - tags = graphene.List(graphene.String) - class Meta: abstract = True - def resolve_tags(self, info): - return self.tags.all() + +class PrimaryObjectType( + ChangelogMixin, + CustomFieldsMixin, + JournalEntriesMixin, + TagsMixin, + BaseObjectType +): + """ + Base type for primary models + """ + class Meta: + abstract = True # diff --git a/netbox/netbox/models.py b/netbox/netbox/models.py index 3045e1fce..317548921 100644 --- a/netbox/netbox/models.py +++ b/netbox/netbox/models.py @@ -40,6 +40,11 @@ class ChangeLoggingMixin(models.Model): blank=True, null=True ) + object_changes = GenericRelation( + to='extras.ObjectChange', + content_type_field='changed_object_type', + object_id_field='changed_object_id' + ) class Meta: abstract = True diff --git a/netbox/tenancy/graphql/types.py b/netbox/tenancy/graphql/types.py index 8f9469c10..6f1e27274 100644 --- a/netbox/tenancy/graphql/types.py +++ b/netbox/tenancy/graphql/types.py @@ -1,5 +1,5 @@ from tenancy import filtersets, models -from netbox.graphql.types import ObjectType, TaggedObjectType +from netbox.graphql.types import OrganizationalObjectType, PrimaryObjectType __all__ = ( 'TenantType', @@ -7,7 +7,7 @@ __all__ = ( ) -class TenantType(TaggedObjectType): +class TenantType(PrimaryObjectType): class Meta: model = models.Tenant @@ -15,7 +15,7 @@ class TenantType(TaggedObjectType): filterset_class = filtersets.TenantFilterSet -class TenantGroupType(ObjectType): +class TenantGroupType(OrganizationalObjectType): class Meta: model = models.TenantGroup diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index 3bb54b529..fe652843a 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -5,7 +5,7 @@ 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 graphene.types import Dynamic as GQLDynamic, List as GQLList from rest_framework import status from rest_framework.test import APIClient @@ -446,9 +446,13 @@ class APIViewTestCases: # Compile list of fields to include fields_string = '' for field_name, field in type_class._meta.fields.items(): - if type(field) is Dynamic: + if type(field) is GQLDynamic: # Dynamic fields must specify a subselection fields_string += f'{field_name} {{ id }}\n' + elif type(field.type) is GQLList and field_name not in ('tags', 'choices'): + # TODO: Come up with something more elegant + # Temporary hack to support automated testing of reverse generic relations + fields_string += f'{field_name} {{ id }}\n' else: fields_string += f'{field_name}\n' diff --git a/netbox/virtualization/graphql/types.py b/netbox/virtualization/graphql/types.py index f4e757ecf..3f1304a61 100644 --- a/netbox/virtualization/graphql/types.py +++ b/netbox/virtualization/graphql/types.py @@ -1,5 +1,7 @@ +from dcim.graphql.types import ComponentObjectType +from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin from virtualization import filtersets, models -from netbox.graphql.types import ObjectType, TaggedObjectType +from netbox.graphql.types import OrganizationalObjectType, PrimaryObjectType __all__ = ( 'ClusterType', @@ -10,7 +12,7 @@ __all__ = ( ) -class ClusterType(TaggedObjectType): +class ClusterType(VLANGroupsMixin, PrimaryObjectType): class Meta: model = models.Cluster @@ -18,7 +20,7 @@ class ClusterType(TaggedObjectType): filterset_class = filtersets.ClusterFilterSet -class ClusterGroupType(ObjectType): +class ClusterGroupType(VLANGroupsMixin, OrganizationalObjectType): class Meta: model = models.ClusterGroup @@ -26,7 +28,7 @@ class ClusterGroupType(ObjectType): filterset_class = filtersets.ClusterGroupFilterSet -class ClusterTypeType(ObjectType): +class ClusterTypeType(OrganizationalObjectType): class Meta: model = models.ClusterType @@ -34,7 +36,7 @@ class ClusterTypeType(ObjectType): filterset_class = filtersets.ClusterTypeFilterSet -class VirtualMachineType(TaggedObjectType): +class VirtualMachineType(PrimaryObjectType): class Meta: model = models.VirtualMachine @@ -42,7 +44,7 @@ class VirtualMachineType(TaggedObjectType): filterset_class = filtersets.VirtualMachineFilterSet -class VMInterfaceType(TaggedObjectType): +class VMInterfaceType(IPAddressesMixin, ComponentObjectType): class Meta: model = models.VMInterface diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 4f8683114..3408cedbc 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -81,6 +81,12 @@ class ClusterGroup(OrganizationalModel): max_length=200, blank=True ) + vlan_groups = GenericRelation( + to='ipam.VLANGroup', + content_type_field='scope_type', + object_id_field='scope_id', + related_query_name='cluster_group' + ) objects = RestrictedQuerySet.as_manager() @@ -136,6 +142,12 @@ class Cluster(PrimaryModel): comments = models.TextField( blank=True ) + vlan_groups = GenericRelation( + to='ipam.VLANGroup', + content_type_field='scope_type', + object_id_field='scope_id', + related_query_name='cluster' + ) objects = RestrictedQuerySet.as_manager()