Просмотр исходного кода

Merge pull request #6873 from netbox-community/6829-graphql-reverse-relations

Closes #6829: GraphQL reverse generic relations
Jeremy Stretch 4 лет назад
Родитель
Сommit
c411d2a9f1

+ 4 - 0
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

+ 6 - 6
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

+ 70 - 34
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

+ 6 - 0
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'
     )

+ 24 - 0
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'
     )

+ 45 - 0
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()

+ 17 - 8
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

+ 20 - 0
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')

+ 12 - 12
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

+ 28 - 17
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
 
 
 #

+ 5 - 0
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

+ 3 - 3
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

+ 6 - 2
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'
 

+ 8 - 6
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

+ 12 - 0
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()