Repository: netbox-community/netbox
Pull request: Allow filtering on Core models for Contacts
<!--
    Thank you for your interest in contributing to NetBox! Please note
    that our contribution policy requires that a feature request or bug
    report be opened for approval prior to filing a pull request. This
    helps avoid wasting time and effort on something that we might not
    be able to accept.

    Please indicate the relevant feature request or bug report below.
    IF YOUR PULL REQUEST DOES NOT REFERENCE AN ACCEPTED BUG REPORT OR
    FEATURE REQUEST, IT WILL BE MARKED AS INVALID AND CLOSED.
-->
### Fixes: #8645 

Implements filtering for all core models that have contacts. 

- [x] Filter by contacts
- [x] Filter by contact roles
- [x] Allow contacts to be shown in the table columns 

Problem statement:
Searchable Contact Assignments per Content-Type in API/UI
### NetBox version

v3.1.7

### Feature type

New functionality

### Proposed functionality

I propose adding query/filter parameters in the API & UI on each Content-Type, that is supported by Contact Assignments.

So it would be possible to just filter by Contact & Contact-Role to receive all matching Objects:
`virtualization/virtual-machines/?contact=Some%20Team&contact_role=administration`


If the direct integration is not the design pattern desired by the maintainers, and it should be kept as a standalone endpoint/functionality, the UI at least requires a combined filterable list on all Contact Assignments, that basically represents the API endpoint `/api/tenancy/contact-assignments/` for users being able to leverage this useful feature.
Like with `/dcim/interfaces/` in the UI.

### Use case

Currently the only places for viewing Contact Assignments in the UI are:
* on the Contact itself
* on the Contact-Role itself
* on the Object itself

All these pages provide no further filtering on shown Contact Assignments.

Consider having a Contact that has more than a handful of Devices, VMs, Virtual-Chassis and Clusters assigned - how would one leverage the UI to find all VMs, the Contact is an "Administrator" for?

Via API it is possible to filter for NetBox Objects by Contact & Contact-Role, e.g.:
`api/tenancy/contact-assignments/?content_type=virtualization.virtualmachine&contact_id=6&role=administration`

### Database changes

None

### External dependencies

None

Hints from issue discussion:

Commit message: Fixes #8645; Allow filtering on core models in the UI and API for contact assignments

Patch to review:
diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py
index 42f9d932285..0ad25edca3f 100644
--- a/netbox/circuits/api/serializers.py
+++ b/netbox/circuits/api/serializers.py
@@ -6,7 +6,7 @@
 from dcim.api.serializers import LinkTerminationSerializer
 from netbox.api import ChoiceField
 from netbox.api.serializers import PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer
-from tenancy.api.nested_serializers import NestedTenantSerializer
+from tenancy.api.nested_serializers import NestedTenantSerializer, NestedContactAssignmentSerializer
 from .nested_serializers import *
 
 
@@ -17,12 +17,17 @@
 class ProviderSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail')
     circuit_count = serializers.IntegerField(read_only=True)
+    contacts = NestedContactAssignmentSerializer(
+        required=False, 
+        allow_null=True,
+        many=True
+    )
 
     class Meta:
         model = Provider
         fields = [
             'id', 'url', 'display', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact',
-            'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count',
+            'comments', 'contacts', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count',
         ]
 
 
@@ -78,12 +83,17 @@ class CircuitSerializer(PrimaryModelSerializer):
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     termination_a = CircuitCircuitTerminationSerializer(read_only=True)
     termination_z = CircuitCircuitTerminationSerializer(read_only=True)
+    contacts = NestedContactAssignmentSerializer(
+        required=False, 
+        allow_null=True,
+        many=True
+    )
 
     class Meta:
         model = Circuit
         fields = [
             'id', 'url', 'display', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate',
-            'description', 'termination_a', 'termination_z', 'comments', 'tags', 'custom_fields', 'created',
+            'description', 'termination_a', 'termination_z', 'comments', 'contacts', 'tags', 'custom_fields', 'created',
             'last_updated',
         ]
 
diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py
index fd582dd99c4..5e3e00301d0 100644
--- a/netbox/circuits/filtersets.py
+++ b/netbox/circuits/filtersets.py
@@ -5,7 +5,7 @@
 from dcim.models import Region, Site, SiteGroup
 from extras.filters import TagFilter
 from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet
-from tenancy.filtersets import TenancyFilterSet
+from tenancy.filtersets import (TenancyFilterSet, ContactModelFilterSet)
 from utilities.filters import TreeNodeMultipleChoiceFilter
 from .choices import *
 from .models import *
@@ -19,7 +19,7 @@
 )
 
 
-class ProviderFilterSet(PrimaryModelFilterSet):
+class ProviderFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',
@@ -118,7 +118,7 @@ class Meta:
         fields = ['id', 'name', 'slug']
 
 
-class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
+class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',
diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py
index a668f9b1611..ee7a77572c8 100644
--- a/netbox/circuits/forms/filtersets.py
+++ b/netbox/circuits/forms/filtersets.py
@@ -5,7 +5,7 @@
 from circuits.models import *
 from dcim.models import Region, Site, SiteGroup
 from extras.forms import CustomFieldModelFilterForm
-from tenancy.forms import TenancyFilterForm
+from tenancy.forms import TenancyFilterForm, ContactModelFilterForm
 from utilities.forms import DynamicModelMultipleChoiceField, StaticSelectMultiple, TagFilterField
 
 __all__ = (
@@ -16,12 +16,13 @@
 )
 
 
-class ProviderFilterForm(CustomFieldModelFilterForm):
+class ProviderFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
     model = Provider
     field_groups = [
         ['q', 'tag'],
         ['region_id', 'site_group_id', 'site_id'],
         ['asn'],
+        ['contact', 'contact_role']
     ]
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
@@ -68,7 +69,7 @@ class CircuitTypeFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
 
 
-class CircuitFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm):
     model = Circuit
     field_groups = [
         ['q', 'tag'],
@@ -76,6 +77,7 @@ class CircuitFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
         ['type_id', 'status', 'commit_rate'],
         ['region_id', 'site_group_id', 'site_id'],
         ['tenant_group_id', 'tenant_id'],
+        ['contact', 'contact_role']
     ]
     type_id = DynamicModelMultipleChoiceField(
         queryset=CircuitType.objects.all(),
diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py
index 6549e51a1f1..b041f990bdc 100644
--- a/netbox/dcim/api/serializers.py
+++ b/netbox/dcim/api/serializers.py
@@ -6,6 +6,7 @@
 from dcim.choices import *
 from dcim.constants import *
 from dcim.models import *
+from tenancy.models import ContactAssignment
 from ipam.api.nested_serializers import NestedASNSerializer, NestedIPAddressSerializer, NestedVLANSerializer
 from ipam.models import ASN, VLAN
 from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
@@ -13,7 +14,7 @@
     NestedGroupModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer,
 )
 from netbox.config import ConfigItem
-from tenancy.api.nested_serializers import NestedTenantSerializer
+from tenancy.api.nested_serializers import NestedTenantSerializer, NestedContactAssignmentSerializer
 from users.api.nested_serializers import NestedUserSerializer
 from utilities.api import get_serializer_for_model
 from virtualization.api.nested_serializers import NestedClusterSerializer
@@ -85,11 +86,16 @@ class RegionSerializer(NestedGroupModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail')
     parent = NestedRegionSerializer(required=False, allow_null=True, default=None)
     site_count = serializers.IntegerField(read_only=True)
+    contacts = NestedContactAssignmentSerializer(
+        required=False, 
+        allow_null=True,
+        many=True
+    )
 
     class Meta:
         model = Region
         fields = [
-            'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
+            'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'contacts', 'tags', 'custom_fields', 'created',
             'last_updated', 'site_count', '_depth',
         ]
 
@@ -98,11 +104,16 @@ class SiteGroupSerializer(NestedGroupModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:sitegroup-detail')
     parent = NestedSiteGroupSerializer(required=False, allow_null=True, default=None)
     site_count = serializers.IntegerField(read_only=True)
+    contacts = NestedContactAssignmentSerializer(
+        required=False, 
+        allow_null=True,
+        many=True
+    )
 
     class Meta:
         model = SiteGroup
         fields = [
-            'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
+            'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'contacts', 'tags', 'custom_fields', 'created',
             'last_updated', 'site_count', '_depth',
         ]
 
@@ -113,6 +124,13 @@ class SiteSerializer(PrimaryModelSerializer):
     region = NestedRegionSerializer(required=False, allow_null=True)
     group = NestedSiteGroupSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
+    contacts = SerializedPKRelatedField(
+        queryset=ContactAssignment.objects.all(),
+        serializer=NestedContactAssignmentSerializer,
+        required=False, 
+        allow_null=True,
+        many=True
+    )
     time_zone = TimeZoneSerializerField(required=False)
     asns = SerializedPKRelatedField(
         queryset=ASN.objects.all(),
@@ -126,7 +144,7 @@ class SiteSerializer(PrimaryModelSerializer):
     device_count = serializers.IntegerField(read_only=True)
     prefix_count = serializers.IntegerField(read_only=True)
     rack_count = serializers.IntegerField(read_only=True)
-    virtualmachine_count = serializers.IntegerField(read_only=True)
+    virtualmachine_count = serializers.IntegerField(read_only=True,)
     vlan_count = serializers.IntegerField(read_only=True)
 
     class Meta:
@@ -134,7 +152,7 @@ class Meta:
         fields = [
             'id', 'url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'asns',
             'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name',
-            'contact_phone', 'contact_email', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+            'contact_phone', 'contact_email', 'comments', 'contacts', 'tags', 'custom_fields', 'created', 'last_updated',
             'circuit_count', 'device_count', 'prefix_count', 'rack_count', 'virtualmachine_count', 'vlan_count',
         ]
 
@@ -150,11 +168,16 @@ class LocationSerializer(NestedGroupModelSerializer):
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     rack_count = serializers.IntegerField(read_only=True)
     device_count = serializers.IntegerField(read_only=True)
+    contacts = NestedContactAssignmentSerializer(
+        required=False, 
+        allow_null=True,
+        many=True
+    )
 
     class Meta:
         model = Location
         fields = [
-            'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'tenant', 'description', 'tags', 'custom_fields',
+            'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'tenant', 'description', 'contacts', 'tags', 'custom_fields',
             'created', 'last_updated', 'rack_count', 'device_count', '_depth',
         ]
 
@@ -185,13 +208,18 @@ class RackSerializer(PrimaryModelSerializer):
     outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False)
     device_count = serializers.IntegerField(read_only=True)
     powerfeed_count = serializers.IntegerField(read_only=True)
+    contacts = NestedContactAssignmentSerializer(
+        required=False, 
+        allow_null=True,
+        many=True
+    )
 
     class Meta:
         model = Rack
         fields = [
             'id', 'url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status', 'role', 'serial',
             'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
-            'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count',
+            'comments', 'contacts', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count',
         ]
 
 
@@ -269,11 +297,17 @@ class ManufacturerSerializer(PrimaryModelSerializer):
     devicetype_count = serializers.IntegerField(read_only=True)
     inventoryitem_count = serializers.IntegerField(read_only=True)
     platform_count = serializers.IntegerField(read_only=True)
+    contacts = NestedContactAssignmentSerializer(
+        required=False, 
+        allow_null=True,
+        many=True
+    )
+    
 
     class Meta:
         model = Manufacturer
         fields = [
-            'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
+            'id', 'url', 'display', 'name', 'slug', 'description', 'contacts', 'tags', 'custom_fields', 'created', 'last_updated',
             'devicetype_count', 'inventoryitem_count', 'platform_count',
         ]
 
@@ -469,6 +503,11 @@ class DeviceSerializer(PrimaryModelSerializer):
     cluster = NestedClusterSerializer(required=False, allow_null=True)
     virtual_chassis = NestedVirtualChassisSerializer(required=False, allow_null=True, default=None)
     vc_position = serializers.IntegerField(allow_null=True, max_value=255, min_value=0, default=None)
+    contacts = NestedContactAssignmentSerializer(
+        required=False, 
+        allow_null=True,
+        many=True
+    )
 
     class Meta:
         model = Device
@@ -476,7 +515,7 @@ class Meta:
             'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
             'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip',
             'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments',
-            'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated',
+            'local_context_data', 'contacts', 'tags', 'custom_fields', 'created', 'last_updated',
         ]
 
     @swagger_serializer_method(serializer_or_field=NestedDeviceSerializer)
@@ -498,7 +537,7 @@ class Meta(DeviceSerializer.Meta):
         fields = [
             'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
             'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4',
-            'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'local_context_data',
+            'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'local_context_data', 'contacts',
             'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
         ]
 
@@ -875,11 +914,16 @@ class PowerPanelSerializer(PrimaryModelSerializer):
         default=None
     )
     powerfeed_count = serializers.IntegerField(read_only=True)
+    contacts = NestedContactAssignmentSerializer(
+        required=False, 
+        allow_null=True,
+        many=True
+    )
 
     class Meta:
         model = PowerPanel
         fields = [
-            'id', 'url', 'display', 'site', 'location', 'name', 'tags', 'custom_fields', 'powerfeed_count',
+            'id', 'url', 'display', 'site', 'location', 'name', 'contacts', 'tags', 'custom_fields', 'powerfeed_count',
             'created', 'last_updated',
         ]
 
diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py
index d9c75d3fa57..c198335f46e 100644
--- a/netbox/dcim/filtersets.py
+++ b/netbox/dcim/filtersets.py
@@ -7,8 +7,8 @@
 from netbox.filtersets import (
     BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet,
 )
-from tenancy.filtersets import TenancyFilterSet
-from tenancy.models import Tenant
+from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
+from tenancy.models import *
 from utilities.choices import ColorChoices
 from utilities.filters import (
     ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter,
@@ -62,7 +62,7 @@
 )
 
 
-class RegionFilterSet(OrganizationalModelFilterSet):
+class RegionFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
     parent_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Region.objects.all(),
         label='Parent region (ID)',
@@ -80,7 +80,7 @@ class Meta:
         fields = ['id', 'name', 'slug', 'description']
 
 
-class SiteGroupFilterSet(OrganizationalModelFilterSet):
+class SiteGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
     parent_id = django_filters.ModelMultipleChoiceFilter(
         queryset=SiteGroup.objects.all(),
         label='Parent site group (ID)',
@@ -98,7 +98,7 @@ class Meta:
         fields = ['id', 'name', 'slug', 'description']
 
 
-class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
+class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',
@@ -167,7 +167,7 @@ def search(self, queryset, name, value):
         return queryset.filter(qs_filter)
 
 
-class LocationFilterSet(TenancyFilterSet, OrganizationalModelFilterSet):
+class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalModelFilterSet):
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         field_name='site__region',
@@ -240,7 +240,7 @@ class Meta:
         fields = ['id', 'name', 'slug', 'color']
 
 
-class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
+class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',
@@ -398,7 +398,7 @@ def search(self, queryset, name, value):
         )
 
 
-class ManufacturerFilterSet(OrganizationalModelFilterSet):
+class ManufacturerFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
     tag = TagFilter()
 
     class Meta:
@@ -608,7 +608,7 @@ class Meta:
         fields = ['id', 'name', 'slug', 'napalm_driver', 'description']
 
 
-class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContextFilterSet):
+class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',
@@ -1289,7 +1289,7 @@ def filter_device(self, queryset, name, value):
         return queryset
 
 
-class PowerPanelFilterSet(PrimaryModelFilterSet):
+class PowerPanelFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',
diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py
index f4b4c0a878a..a6cccb00ec0 100644
--- a/netbox/dcim/forms/filtersets.py
+++ b/netbox/dcim/forms/filtersets.py
@@ -5,9 +5,12 @@
 from dcim.choices import *
 from dcim.constants import *
 from dcim.models import *
+from tenancy.models import *
 from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm
 from ipam.models import ASN
-from tenancy.forms import TenancyFilterForm
+from tenancy.forms import (
+    TenancyFilterForm, ContactModelFilterForm
+)
 from utilities.forms import (
     APISelectMultiple, add_blank_choice, ColorField, DynamicModelMultipleChoiceField, FilterForm, StaticSelect,
     StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
@@ -98,7 +101,7 @@ class DeviceComponentFilterForm(CustomFieldModelFilterForm):
     )
 
 
-class RegionFilterForm(CustomFieldModelFilterForm):
+class RegionFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
     model = Region
     parent_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
@@ -108,7 +111,7 @@ class RegionFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
 
 
-class SiteGroupFilterForm(CustomFieldModelFilterForm):
+class SiteGroupFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
     model = SiteGroup
     parent_id = DynamicModelMultipleChoiceField(
         queryset=SiteGroup.objects.all(),
@@ -118,13 +121,14 @@ class SiteGroupFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
 
 
-class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm):
     model = Site
     field_groups = [
         ['q', 'tag'],
         ['status', 'region_id', 'group_id'],
         ['tenant_group_id', 'tenant_id'],
-        ['asn_id']
+        ['asn_id'],
+        ['contact', 'contact_role'],
     ]
     status = forms.MultipleChoiceField(
         choices=SiteStatusChoices,
@@ -148,13 +152,13 @@ class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     )
     tag = TagFilterField(model)
 
-
-class LocationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm):
     model = Location
     field_groups = [
         ['q', 'tag'],
         ['region_id', 'site_group_id', 'site_id', 'parent_id'],
         ['tenant_group_id', 'tenant_id'],
+        ['contact', 'contact_role'],
     ]
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
@@ -192,7 +196,7 @@ class RackRoleFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
 
 
-class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm):
     model = Rack
     field_groups = [
         ['q', 'tag'],
@@ -200,6 +204,7 @@ class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
         ['status', 'role_id'],
         ['type', 'width', 'serial', 'asset_tag'],
         ['tenant_group_id', 'tenant_id'],
+        ['contact', 'contact_role']
     ]
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
@@ -303,7 +308,7 @@ class RackReservationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     tag = TagFilterField(model)
 
 
-class ManufacturerFilterForm(CustomFieldModelFilterForm):
+class ManufacturerFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
     model = Manufacturer
     tag = TagFilterField(model)
 
@@ -390,7 +395,7 @@ class PlatformFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
 
 
-class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm):
+class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm):
     model = Device
     field_groups = [
         ['q', 'tag'],
@@ -402,6 +407,7 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi
             'has_primary_ip', 'virtual_chassis_member', 'console_ports', 'console_server_ports', 'power_ports',
             'power_outlets', 'interfaces', 'pass_through_ports', 'local_context_data',
         ],
+        ['contact', 'contact_role'],
     ]
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
@@ -636,11 +642,12 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     tag = TagFilterField(model)
 
 
-class PowerPanelFilterForm(CustomFieldModelFilterForm):
+class PowerPanelFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
     model = PowerPanel
     field_groups = (
         ('q', 'tag'),
-        ('region_id', 'site_group_id', 'site_id', 'location_id')
+        ('region_id', 'site_group_id', 'site_id', 'location_id'),
+        ('contact', 'contact_role')
     )
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py
index a0482aa1df8..7afab04e78b 100644
--- a/netbox/tenancy/api/serializers.py
+++ b/netbox/tenancy/api/serializers.py
@@ -40,11 +40,16 @@ class TenantSerializer(PrimaryModelSerializer):
     vlan_count = serializers.IntegerField(read_only=True)
     vrf_count = serializers.IntegerField(read_only=True)
     cluster_count = serializers.IntegerField(read_only=True)
+    contacts = NestedContactAssignmentSerializer(
+        required=False, 
+        allow_null=True,
+        many=True
+    )
 
     class Meta:
         model = Tenant
         fields = [
-            'id', 'url', 'display', 'name', 'slug', 'group', 'description', 'comments', 'tags', 'custom_fields',
+            'id', 'url', 'display', 'name', 'slug', 'group', 'description', 'comments', 'contacts', 'tags', 'custom_fields',
             'created', 'last_updated', 'circuit_count', 'device_count', 'ipaddress_count', 'prefix_count', 'rack_count',
             'site_count', 'virtualmachine_count', 'vlan_count', 'vrf_count', 'cluster_count',
         ]
diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py
index c8af891432c..0fc709eb684 100644
--- a/netbox/tenancy/filtersets.py
+++ b/netbox/tenancy/filtersets.py
@@ -15,179 +15,193 @@
     'TenancyFilterSet',
     'TenantFilterSet',
     'TenantGroupFilterSet',
+    'ContactModelFilterSet'
 )
 
 
 #
-# Tenancy
+# Contacts
 #
 
-class TenantGroupFilterSet(OrganizationalModelFilterSet):
+class ContactGroupFilterSet(OrganizationalModelFilterSet):
     parent_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=TenantGroup.objects.all(),
-        label='Tenant group (ID)',
+        queryset=ContactGroup.objects.all(),
+        label='Contact group (ID)',
     )
     parent = django_filters.ModelMultipleChoiceFilter(
         field_name='parent__slug',
-        queryset=TenantGroup.objects.all(),
+        queryset=ContactGroup.objects.all(),
         to_field_name='slug',
-        label='Tenant group (slug)',
+        label='Contact group (slug)',
     )
     tag = TagFilter()
 
     class Meta:
-        model = TenantGroup
+        model = ContactGroup
         fields = ['id', 'name', 'slug', 'description']
 
 
-class TenantFilterSet(PrimaryModelFilterSet):
+class ContactRoleFilterSet(OrganizationalModelFilterSet):
+    tag = TagFilter()
+
+    class Meta:
+        model = ContactRole
+        fields = ['id', 'name', 'slug']
+
+
+class ContactFilterSet(PrimaryModelFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',
     )
     group_id = TreeNodeMultipleChoiceFilter(
-        queryset=TenantGroup.objects.all(),
+        queryset=ContactGroup.objects.all(),
         field_name='group',
         lookup_expr='in',
-        label='Tenant group (ID)',
+        label='Contact group (ID)',
     )
     group = TreeNodeMultipleChoiceFilter(
-        queryset=TenantGroup.objects.all(),
+        queryset=ContactGroup.objects.all(),
         field_name='group',
         lookup_expr='in',
         to_field_name='slug',
-        label='Tenant group (slug)',
+        label='Contact group (slug)',
     )
     tag = TagFilter()
 
     class Meta:
-        model = Tenant
-        fields = ['id', 'name', 'slug']
+        model = Contact
+        fields = ['id', 'name', 'title', 'phone', 'email', 'address']
 
     def search(self, queryset, name, value):
         if not value.strip():
             return queryset
         return queryset.filter(
             Q(name__icontains=value) |
-            Q(slug__icontains=value) |
-            Q(description__icontains=value) |
+            Q(title__icontains=value) |
+            Q(phone__icontains=value) |
+            Q(email__icontains=value) |
+            Q(address__icontains=value) |
             Q(comments__icontains=value)
         )
 
 
-class TenancyFilterSet(django_filters.FilterSet):
-    """
-    An inheritable FilterSet for models which support Tenant assignment.
-    """
-    tenant_group_id = TreeNodeMultipleChoiceFilter(
-        queryset=TenantGroup.objects.all(),
-        field_name='tenant__group',
-        lookup_expr='in',
-        label='Tenant Group (ID)',
+class ContactAssignmentFilterSet(ChangeLoggedModelFilterSet):
+    content_type = ContentTypeFilter()
+    contact_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=Contact.objects.all(),
+        label='Contact (ID)',
     )
-    tenant_group = TreeNodeMultipleChoiceFilter(
-        queryset=TenantGroup.objects.all(),
-        field_name='tenant__group',
+    role_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=ContactRole.objects.all(),
+        label='Contact role (ID)',
+    )
+    role = django_filters.ModelMultipleChoiceFilter(
+        field_name='role__slug',
+        queryset=ContactRole.objects.all(),
         to_field_name='slug',
-        lookup_expr='in',
-        label='Tenant Group (slug)',
+        label='Contact role (slug)',
     )
-    tenant_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=Tenant.objects.all(),
-        label='Tenant (ID)',
+
+    class Meta:
+        model = ContactAssignment
+        fields = ['id', 'content_type_id', 'object_id', 'priority']
+
+
+class ContactModelFilterSet(django_filters.FilterSet):
+    contact = django_filters.ModelMultipleChoiceFilter(
+        field_name='contacts__contact',
+        queryset=Contact.objects.all(),
+        label='Contact',
     )
-    tenant = django_filters.ModelMultipleChoiceFilter(
-        queryset=Tenant.objects.all(),
-        field_name='tenant__slug',
-        to_field_name='slug',
-        label='Tenant (slug)',
+    contact_role = django_filters.ModelMultipleChoiceFilter(
+        field_name='contacts__role',
+        queryset=ContactRole.objects.all(),
+        label='Contact Role'
     )
 
 
 #
-# Contacts
+# Tenancy
 #
 
-class ContactGroupFilterSet(OrganizationalModelFilterSet):
+class TenantGroupFilterSet(OrganizationalModelFilterSet):
     parent_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=ContactGroup.objects.all(),
-        label='Contact group (ID)',
+        queryset=TenantGroup.objects.all(),
+        label='Tenant group (ID)',
     )
     parent = django_filters.ModelMultipleChoiceFilter(
         field_name='parent__slug',
-        queryset=ContactGroup.objects.all(),
+        queryset=TenantGroup.objects.all(),
         to_field_name='slug',
-        label='Contact group (slug)',
+        label='Tenant group (slug)',
     )
     tag = TagFilter()
 
     class Meta:
-        model = ContactGroup
+        model = TenantGroup
         fields = ['id', 'name', 'slug', 'description']
 
 
-class ContactRoleFilterSet(OrganizationalModelFilterSet):
-    tag = TagFilter()
-
-    class Meta:
-        model = ContactRole
-        fields = ['id', 'name', 'slug']
-
-
-class ContactFilterSet(PrimaryModelFilterSet):
+class TenantFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',
     )
     group_id = TreeNodeMultipleChoiceFilter(
-        queryset=ContactGroup.objects.all(),
+        queryset=TenantGroup.objects.all(),
         field_name='group',
         lookup_expr='in',
-        label='Contact group (ID)',
+        label='Tenant group (ID)',
     )
     group = TreeNodeMultipleChoiceFilter(
-        queryset=ContactGroup.objects.all(),
+        queryset=TenantGroup.objects.all(),
         field_name='group',
         lookup_expr='in',
         to_field_name='slug',
-        label='Contact group (slug)',
+        label='Tenant group (slug)',
     )
     tag = TagFilter()
 
     class Meta:
-        model = Contact
-        fields = ['id', 'name', 'title', 'phone', 'email', 'address']
+        model = Tenant
+        fields = ['id', 'name', 'slug']
 
     def search(self, queryset, name, value):
         if not value.strip():
             return queryset
         return queryset.filter(
             Q(name__icontains=value) |
-            Q(title__icontains=value) |
-            Q(phone__icontains=value) |
-            Q(email__icontains=value) |
-            Q(address__icontains=value) |
+            Q(slug__icontains=value) |
+            Q(description__icontains=value) |
             Q(comments__icontains=value)
         )
 
 
-class ContactAssignmentFilterSet(ChangeLoggedModelFilterSet):
-    content_type = ContentTypeFilter()
-    contact_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=Contact.objects.all(),
-        label='Contact (ID)',
+class TenancyFilterSet(django_filters.FilterSet):
+    """
+    An inheritable FilterSet for models which support Tenant assignment.
+    """
+    tenant_group_id = TreeNodeMultipleChoiceFilter(
+        queryset=TenantGroup.objects.all(),
+        field_name='tenant__group',
+        lookup_expr='in',
+        label='Tenant Group (ID)',
     )
-    role_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=ContactRole.objects.all(),
-        label='Contact role (ID)',
+    tenant_group = TreeNodeMultipleChoiceFilter(
+        queryset=TenantGroup.objects.all(),
+        field_name='tenant__group',
+        to_field_name='slug',
+        lookup_expr='in',
+        label='Tenant Group (slug)',
     )
-    role = django_filters.ModelMultipleChoiceFilter(
-        field_name='role__slug',
-        queryset=ContactRole.objects.all(),
+    tenant_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=Tenant.objects.all(),
+        label='Tenant (ID)',
+    )
+    tenant = django_filters.ModelMultipleChoiceFilter(
+        queryset=Tenant.objects.all(),
+        field_name='tenant__slug',
         to_field_name='slug',
-        label='Contact role (slug)',
+        label='Tenant (slug)',
     )
-
-    class Meta:
-        model = ContactAssignment
-        fields = ['id', 'content_type_id', 'object_id', 'priority']
diff --git a/netbox/tenancy/forms/filtersets.py b/netbox/tenancy/forms/filtersets.py
index 7849e21716b..ada279d9dd6 100644
--- a/netbox/tenancy/forms/filtersets.py
+++ b/netbox/tenancy/forms/filtersets.py
@@ -2,6 +2,7 @@
 
 from extras.forms import CustomFieldModelFilterForm
 from tenancy.models import *
+from tenancy.forms import ContactModelFilterForm
 from utilities.forms import DynamicModelMultipleChoiceField, TagFilterField
 
 __all__ = (
@@ -27,11 +28,12 @@ class TenantGroupFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
 
 
-class TenantFilterForm(CustomFieldModelFilterForm):
+class TenantFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
     model = Tenant
     field_groups = (
         ('q', 'tag'),
         ('group_id',),
+        ('contact', 'contact_role')
     )
     group_id = DynamicModelMultipleChoiceField(
         queryset=TenantGroup.objects.all(),
diff --git a/netbox/tenancy/forms/forms.py b/netbox/tenancy/forms/forms.py
index 9a3d00e055c..d979a3c96e9 100644
--- a/netbox/tenancy/forms/forms.py
+++ b/netbox/tenancy/forms/forms.py
@@ -1,12 +1,13 @@
 from django import forms
 from django.utils.translation import gettext as _
 
-from tenancy.models import Tenant, TenantGroup
+from tenancy.models import *
 from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
 
 __all__ = (
     'TenancyForm',
     'TenancyFilterForm',
+    'ContactModelFilterForm'
 )
 
 
@@ -44,3 +45,16 @@ class TenancyFilterForm(forms.Form):
         },
         label=_('Tenant')
     )
+
+
+class ContactModelFilterForm(forms.Form):
+    contact = DynamicModelMultipleChoiceField(
+        queryset=Contact.objects.all(),
+        required=False,
+        label=_('Contact')
+    )
+    contact_role = DynamicModelMultipleChoiceField(
+        queryset=ContactRole.objects.all(),
+        required=False,
+        label=_('Contact Role')
+    )
\ No newline at end of file
diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py
index 866b8f9bb9d..f3c487bedb4 100644
--- a/netbox/virtualization/api/serializers.py
+++ b/netbox/virtualization/api/serializers.py
@@ -7,7 +7,7 @@
 from ipam.models import VLAN
 from netbox.api import ChoiceField, SerializedPKRelatedField
 from netbox.api.serializers import PrimaryModelSerializer
-from tenancy.api.nested_serializers import NestedTenantSerializer
+from tenancy.api.nested_serializers import NestedTenantSerializer, NestedContactAssignmentSerializer
 from virtualization.choices import *
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 from .nested_serializers import *
@@ -32,11 +32,16 @@ class Meta:
 class ClusterGroupSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustergroup-detail')
     cluster_count = serializers.IntegerField(read_only=True)
+    contacts = NestedContactAssignmentSerializer(
+        required=False, 
+        allow_null=True,
+        many=True
+    )
 
     class Meta:
         model = ClusterGroup
         fields = [
-            'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
+            'id', 'url', 'display', 'name', 'slug', 'description', 'contacts', 'tags', 'custom_fields', 'created', 'last_updated',
             'cluster_count',
         ]
 
@@ -49,11 +54,16 @@ class ClusterSerializer(PrimaryModelSerializer):
     site = NestedSiteSerializer(required=False, allow_null=True, default=None)
     device_count = serializers.IntegerField(read_only=True)
     virtualmachine_count = serializers.IntegerField(read_only=True)
+    contacts = NestedContactAssignmentSerializer(
+        required=False, 
+        allow_null=True,
+        many=True
+    )
 
     class Meta:
         model = Cluster
         fields = [
-            'id', 'url', 'display', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'tags', 'custom_fields',
+            'id', 'url', 'display', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'contacts', 'tags', 'custom_fields',
             'created', 'last_updated', 'device_count', 'virtualmachine_count',
         ]
 
@@ -73,12 +83,17 @@ class VirtualMachineSerializer(PrimaryModelSerializer):
     primary_ip = NestedIPAddressSerializer(read_only=True)
     primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
     primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
+    contacts = NestedContactAssignmentSerializer(
+        required=False, 
+        allow_null=True,
+        many=True
+    )
 
     class Meta:
         model = VirtualMachine
         fields = [
             'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip',
-            'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', 'tags',
+            'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', 'contacts', 'tags',
             'custom_fields', 'created', 'last_updated',
         ]
         validators = []
@@ -86,11 +101,16 @@ class Meta:
 
 class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
     config_context = serializers.SerializerMethodField()
+    contacts = NestedContactAssignmentSerializer(
+        required=False, 
+        allow_null=True,
+        many=True
+    )
 
     class Meta(VirtualMachineSerializer.Meta):
         fields = [
             'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip',
-            'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', 'tags',
+            'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', 'contacts', 'tags',
             'custom_fields', 'config_context', 'created', 'last_updated',
         ]
 
diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py
index ed2775de2ac..dadf781a3bf 100644
--- a/netbox/virtualization/filtersets.py
+++ b/netbox/virtualization/filtersets.py
@@ -5,7 +5,7 @@
 from extras.filters import TagFilter
 from extras.filtersets import LocalConfigContextFilterSet
 from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet
-from tenancy.filtersets import TenancyFilterSet
+from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
 from utilities.filters import MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter
 from .choices import *
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@@ -27,7 +27,7 @@ class Meta:
         fields = ['id', 'name', 'slug', 'description']
 
 
-class ClusterGroupFilterSet(OrganizationalModelFilterSet):
+class ClusterGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
     tag = TagFilter()
 
     class Meta:
@@ -35,7 +35,7 @@ class Meta:
         fields = ['id', 'name', 'slug', 'description']
 
 
-class ClusterFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
+class ClusterFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',
@@ -111,7 +111,7 @@ def search(self, queryset, name, value):
         )
 
 
-class VirtualMachineFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContextFilterSet):
+class VirtualMachineFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',
diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py
index 9ca8eba6e14..908fa17c8c4 100644
--- a/netbox/virtualization/forms/filtersets.py
+++ b/netbox/virtualization/forms/filtersets.py
@@ -3,7 +3,7 @@
 
 from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
 from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm
-from tenancy.forms import TenancyFilterForm
+from tenancy.forms import TenancyFilterForm, ContactModelFilterForm
 from utilities.forms import (
     DynamicModelMultipleChoiceField, StaticSelect, StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
 )
@@ -24,18 +24,19 @@ class ClusterTypeFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
 
 
-class ClusterGroupFilterForm(CustomFieldModelFilterForm):
+class ClusterGroupFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
     model = ClusterGroup
     tag = TagFilterField(model)
 
 
-class ClusterFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm):
     model = Cluster
     field_groups = [
         ['q', 'tag'],
         ['group_id', 'type_id'],
         ['region_id', 'site_group_id', 'site_id'],
         ['tenant_group_id', 'tenant_id'],
+        ['contact', 'contact_role'],
     ]
     type_id = DynamicModelMultipleChoiceField(
         queryset=ClusterType.objects.all(),
@@ -71,7 +72,7 @@ class ClusterFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     tag = TagFilterField(model)
 
 
-class VirtualMachineFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm):
+class VirtualMachineFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm):
     model = VirtualMachine
     field_groups = [
         ['q', 'tag'],
@@ -79,6 +80,7 @@ class VirtualMachineFilterForm(LocalConfigContextFilterForm, TenancyFilterForm,
         ['region_id', 'site_group_id', 'site_id'],
         ['status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data'],
         ['tenant_group_id', 'tenant_id'],
+        ['contact', 'contact_role'],
     ]
     cluster_group_id = DynamicModelMultipleChoiceField(
         queryset=ClusterGroup.objects.all(),


Code review comments:
diff_hunk: @@ -17,12 +17,17 @@
 class ProviderSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail')
     circuit_count = serializers.IntegerField(read_only=True)
+    contacts = NestedContactAssignmentSerializer(
path: netbox/circuits/api/serializers.py
text: @user1:
I'm afraid this approach of following the reverse relationship is not tenable, as it can result in a substantial performance hit. Notice that as a rule we don't follow reverse relations for any model in the REST API (though there may be a handful of exceptions).

REST API consumers should instead query the contact assignments endpoint to retrieve these objects.

@author:
I have removed the API changes from the PR.