Przeglądaj źródła

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

Alex Gittings 4 lat temu
rodzic
commit
36d6dd1ca9

+ 13 - 3
netbox/circuits/api/serializers.py

@@ -6,7 +6,7 @@ from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSeriali
 from dcim.api.serializers import LinkTerminationSerializer
 from dcim.api.serializers import LinkTerminationSerializer
 from netbox.api import ChoiceField
 from netbox.api import ChoiceField
 from netbox.api.serializers import PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer
 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 *
 from .nested_serializers import *
 
 
 
 
@@ -17,12 +17,17 @@ from .nested_serializers import *
 class ProviderSerializer(PrimaryModelSerializer):
 class ProviderSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail')
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail')
     circuit_count = serializers.IntegerField(read_only=True)
     circuit_count = serializers.IntegerField(read_only=True)
+    contacts = NestedContactAssignmentSerializer(
+        required=False, 
+        allow_null=True,
+        many=True
+    )
 
 
     class Meta:
     class Meta:
         model = Provider
         model = Provider
         fields = [
         fields = [
             'id', 'url', 'display', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact',
             '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)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     termination_a = CircuitCircuitTerminationSerializer(read_only=True)
     termination_a = CircuitCircuitTerminationSerializer(read_only=True)
     termination_z = CircuitCircuitTerminationSerializer(read_only=True)
     termination_z = CircuitCircuitTerminationSerializer(read_only=True)
+    contacts = NestedContactAssignmentSerializer(
+        required=False, 
+        allow_null=True,
+        many=True
+    )
 
 
     class Meta:
     class Meta:
         model = Circuit
         model = Circuit
         fields = [
         fields = [
             'id', 'url', 'display', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate',
             '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',
             'last_updated',
         ]
         ]
 
 

+ 3 - 3
netbox/circuits/filtersets.py

@@ -5,7 +5,7 @@ from dcim.filtersets import CableTerminationFilterSet
 from dcim.models import Region, Site, SiteGroup
 from dcim.models import Region, Site, SiteGroup
 from extras.filters import TagFilter
 from extras.filters import TagFilter
 from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet
 from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet
-from tenancy.filtersets import TenancyFilterSet
+from tenancy.filtersets import (TenancyFilterSet, ContactModelFilterSet)
 from utilities.filters import TreeNodeMultipleChoiceFilter
 from utilities.filters import TreeNodeMultipleChoiceFilter
 from .choices import *
 from .choices import *
 from .models import *
 from .models import *
@@ -19,7 +19,7 @@ __all__ = (
 )
 )
 
 
 
 
-class ProviderFilterSet(PrimaryModelFilterSet):
+class ProviderFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
@@ -118,7 +118,7 @@ class CircuitTypeFilterSet(OrganizationalModelFilterSet):
         fields = ['id', 'name', 'slug']
         fields = ['id', 'name', 'slug']
 
 
 
 
-class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
+class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',

+ 5 - 3
netbox/circuits/forms/filtersets.py

@@ -5,7 +5,7 @@ from circuits.choices import CircuitStatusChoices
 from circuits.models import *
 from circuits.models import *
 from dcim.models import Region, Site, SiteGroup
 from dcim.models import Region, Site, SiteGroup
 from extras.forms import CustomFieldModelFilterForm
 from extras.forms import CustomFieldModelFilterForm
-from tenancy.forms import TenancyFilterForm
+from tenancy.forms import TenancyFilterForm, ContactModelFilterForm
 from utilities.forms import DynamicModelMultipleChoiceField, StaticSelectMultiple, TagFilterField
 from utilities.forms import DynamicModelMultipleChoiceField, StaticSelectMultiple, TagFilterField
 
 
 __all__ = (
 __all__ = (
@@ -16,12 +16,13 @@ __all__ = (
 )
 )
 
 
 
 
-class ProviderFilterForm(CustomFieldModelFilterForm):
+class ProviderFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
     model = Provider
     model = Provider
     field_groups = [
     field_groups = [
         ['q', 'tag'],
         ['q', 'tag'],
         ['region_id', 'site_group_id', 'site_id'],
         ['region_id', 'site_group_id', 'site_id'],
         ['asn'],
         ['asn'],
+        ['contact', 'contact_role']
     ]
     ]
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
@@ -68,7 +69,7 @@ class CircuitTypeFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class CircuitFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm):
     model = Circuit
     model = Circuit
     field_groups = [
     field_groups = [
         ['q', 'tag'],
         ['q', 'tag'],
@@ -76,6 +77,7 @@ class CircuitFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
         ['type_id', 'status', 'commit_rate'],
         ['type_id', 'status', 'commit_rate'],
         ['region_id', 'site_group_id', 'site_id'],
         ['region_id', 'site_group_id', 'site_id'],
         ['tenant_group_id', 'tenant_id'],
         ['tenant_group_id', 'tenant_id'],
+        ['contact', 'contact_role']
     ]
     ]
     type_id = DynamicModelMultipleChoiceField(
     type_id = DynamicModelMultipleChoiceField(
         queryset=CircuitType.objects.all(),
         queryset=CircuitType.objects.all(),

+ 55 - 11
netbox/dcim/api/serializers.py

@@ -6,6 +6,7 @@ from timezone_field.rest_framework import TimeZoneSerializerField
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.models import *
 from dcim.models import *
+from tenancy.models import ContactAssignment
 from ipam.api.nested_serializers import NestedASNSerializer, NestedIPAddressSerializer, NestedVLANSerializer
 from ipam.api.nested_serializers import NestedASNSerializer, NestedIPAddressSerializer, NestedVLANSerializer
 from ipam.models import ASN, VLAN
 from ipam.models import ASN, VLAN
 from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
 from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
@@ -13,7 +14,7 @@ from netbox.api.serializers import (
     NestedGroupModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer,
     NestedGroupModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer,
 )
 )
 from netbox.config import ConfigItem
 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 users.api.nested_serializers import NestedUserSerializer
 from utilities.api import get_serializer_for_model
 from utilities.api import get_serializer_for_model
 from virtualization.api.nested_serializers import NestedClusterSerializer
 from virtualization.api.nested_serializers import NestedClusterSerializer
@@ -85,11 +86,16 @@ class RegionSerializer(NestedGroupModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail')
     parent = NestedRegionSerializer(required=False, allow_null=True, default=None)
     parent = NestedRegionSerializer(required=False, allow_null=True, default=None)
     site_count = serializers.IntegerField(read_only=True)
     site_count = serializers.IntegerField(read_only=True)
+    contacts = NestedContactAssignmentSerializer(
+        required=False, 
+        allow_null=True,
+        many=True
+    )
 
 
     class Meta:
     class Meta:
         model = Region
         model = Region
         fields = [
         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',
             'last_updated', 'site_count', '_depth',
         ]
         ]
 
 
@@ -98,11 +104,16 @@ class SiteGroupSerializer(NestedGroupModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:sitegroup-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:sitegroup-detail')
     parent = NestedSiteGroupSerializer(required=False, allow_null=True, default=None)
     parent = NestedSiteGroupSerializer(required=False, allow_null=True, default=None)
     site_count = serializers.IntegerField(read_only=True)
     site_count = serializers.IntegerField(read_only=True)
+    contacts = NestedContactAssignmentSerializer(
+        required=False, 
+        allow_null=True,
+        many=True
+    )
 
 
     class Meta:
     class Meta:
         model = SiteGroup
         model = SiteGroup
         fields = [
         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',
             'last_updated', 'site_count', '_depth',
         ]
         ]
 
 
@@ -113,6 +124,13 @@ class SiteSerializer(PrimaryModelSerializer):
     region = NestedRegionSerializer(required=False, allow_null=True)
     region = NestedRegionSerializer(required=False, allow_null=True)
     group = NestedSiteGroupSerializer(required=False, allow_null=True)
     group = NestedSiteGroupSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(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)
     time_zone = TimeZoneSerializerField(required=False)
     asns = SerializedPKRelatedField(
     asns = SerializedPKRelatedField(
         queryset=ASN.objects.all(),
         queryset=ASN.objects.all(),
@@ -126,7 +144,7 @@ class SiteSerializer(PrimaryModelSerializer):
     device_count = serializers.IntegerField(read_only=True)
     device_count = serializers.IntegerField(read_only=True)
     prefix_count = serializers.IntegerField(read_only=True)
     prefix_count = serializers.IntegerField(read_only=True)
     rack_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)
     vlan_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
@@ -134,7 +152,7 @@ class SiteSerializer(PrimaryModelSerializer):
         fields = [
         fields = [
             'id', 'url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'asns',
             'id', 'url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'asns',
             'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name',
             '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',
             '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)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     rack_count = serializers.IntegerField(read_only=True)
     rack_count = serializers.IntegerField(read_only=True)
     device_count = serializers.IntegerField(read_only=True)
     device_count = serializers.IntegerField(read_only=True)
+    contacts = NestedContactAssignmentSerializer(
+        required=False, 
+        allow_null=True,
+        many=True
+    )
 
 
     class Meta:
     class Meta:
         model = Location
         model = Location
         fields = [
         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',
             '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)
     outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False)
     device_count = serializers.IntegerField(read_only=True)
     device_count = serializers.IntegerField(read_only=True)
     powerfeed_count = serializers.IntegerField(read_only=True)
     powerfeed_count = serializers.IntegerField(read_only=True)
+    contacts = NestedContactAssignmentSerializer(
+        required=False, 
+        allow_null=True,
+        many=True
+    )
 
 
     class Meta:
     class Meta:
         model = Rack
         model = Rack
         fields = [
         fields = [
             'id', 'url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status', 'role', 'serial',
             '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',
             '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)
     devicetype_count = serializers.IntegerField(read_only=True)
     inventoryitem_count = serializers.IntegerField(read_only=True)
     inventoryitem_count = serializers.IntegerField(read_only=True)
     platform_count = serializers.IntegerField(read_only=True)
     platform_count = serializers.IntegerField(read_only=True)
+    contacts = NestedContactAssignmentSerializer(
+        required=False, 
+        allow_null=True,
+        many=True
+    )
+    
 
 
     class Meta:
     class Meta:
         model = Manufacturer
         model = Manufacturer
         fields = [
         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',
             'devicetype_count', 'inventoryitem_count', 'platform_count',
         ]
         ]
 
 
@@ -469,6 +503,11 @@ class DeviceSerializer(PrimaryModelSerializer):
     cluster = NestedClusterSerializer(required=False, allow_null=True)
     cluster = NestedClusterSerializer(required=False, allow_null=True)
     virtual_chassis = NestedVirtualChassisSerializer(required=False, allow_null=True, default=None)
     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)
     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:
     class Meta:
         model = Device
         model = Device
@@ -476,7 +515,7 @@ class DeviceSerializer(PrimaryModelSerializer):
             'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
             'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
             'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip',
             'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip',
             'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments',
             '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)
     @swagger_serializer_method(serializer_or_field=NestedDeviceSerializer)
@@ -498,7 +537,7 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
         fields = [
         fields = [
             'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
             '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',
             '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',
             'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
         ]
         ]
 
 
@@ -875,11 +914,16 @@ class PowerPanelSerializer(PrimaryModelSerializer):
         default=None
         default=None
     )
     )
     powerfeed_count = serializers.IntegerField(read_only=True)
     powerfeed_count = serializers.IntegerField(read_only=True)
+    contacts = NestedContactAssignmentSerializer(
+        required=False, 
+        allow_null=True,
+        many=True
+    )
 
 
     class Meta:
     class Meta:
         model = PowerPanel
         model = PowerPanel
         fields = [
         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',
             'created', 'last_updated',
         ]
         ]
 
 

+ 10 - 10
netbox/dcim/filtersets.py

@@ -7,8 +7,8 @@ from ipam.models import ASN
 from netbox.filtersets import (
 from netbox.filtersets import (
     BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet,
     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.choices import ColorChoices
 from utilities.filters import (
 from utilities.filters import (
     ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter,
     ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter,
@@ -62,7 +62,7 @@ __all__ = (
 )
 )
 
 
 
 
-class RegionFilterSet(OrganizationalModelFilterSet):
+class RegionFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
     parent_id = django_filters.ModelMultipleChoiceFilter(
     parent_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         label='Parent region (ID)',
         label='Parent region (ID)',
@@ -80,7 +80,7 @@ class RegionFilterSet(OrganizationalModelFilterSet):
         fields = ['id', 'name', 'slug', 'description']
         fields = ['id', 'name', 'slug', 'description']
 
 
 
 
-class SiteGroupFilterSet(OrganizationalModelFilterSet):
+class SiteGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
     parent_id = django_filters.ModelMultipleChoiceFilter(
     parent_id = django_filters.ModelMultipleChoiceFilter(
         queryset=SiteGroup.objects.all(),
         queryset=SiteGroup.objects.all(),
         label='Parent site group (ID)',
         label='Parent site group (ID)',
@@ -98,7 +98,7 @@ class SiteGroupFilterSet(OrganizationalModelFilterSet):
         fields = ['id', 'name', 'slug', 'description']
         fields = ['id', 'name', 'slug', 'description']
 
 
 
 
-class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
+class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
@@ -167,7 +167,7 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
         return queryset.filter(qs_filter)
         return queryset.filter(qs_filter)
 
 
 
 
-class LocationFilterSet(TenancyFilterSet, OrganizationalModelFilterSet):
+class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalModelFilterSet):
     region_id = TreeNodeMultipleChoiceFilter(
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         field_name='site__region',
         field_name='site__region',
@@ -240,7 +240,7 @@ class RackRoleFilterSet(OrganizationalModelFilterSet):
         fields = ['id', 'name', 'slug', 'color']
         fields = ['id', 'name', 'slug', 'color']
 
 
 
 
-class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
+class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
@@ -398,7 +398,7 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
         )
         )
 
 
 
 
-class ManufacturerFilterSet(OrganizationalModelFilterSet):
+class ManufacturerFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
     tag = TagFilter()
     tag = TagFilter()
 
 
     class Meta:
     class Meta:
@@ -608,7 +608,7 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
         fields = ['id', 'name', 'slug', 'napalm_driver', 'description']
         fields = ['id', 'name', 'slug', 'napalm_driver', 'description']
 
 
 
 
-class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContextFilterSet):
+class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
@@ -1289,7 +1289,7 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
         return queryset
         return queryset
 
 
 
 
-class PowerPanelFilterSet(PrimaryModelFilterSet):
+class PowerPanelFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',

+ 19 - 12
netbox/dcim/forms/filtersets.py

@@ -5,9 +5,12 @@ from django.utils.translation import gettext as _
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.models import *
 from dcim.models import *
+from tenancy.models import *
 from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm
 from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm
 from ipam.models import ASN
 from ipam.models import ASN
-from tenancy.forms import TenancyFilterForm
+from tenancy.forms import (
+    TenancyFilterForm, ContactModelFilterForm
+)
 from utilities.forms import (
 from utilities.forms import (
     APISelectMultiple, add_blank_choice, ColorField, DynamicModelMultipleChoiceField, FilterForm, StaticSelect,
     APISelectMultiple, add_blank_choice, ColorField, DynamicModelMultipleChoiceField, FilterForm, StaticSelect,
     StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
     StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
@@ -98,7 +101,7 @@ class DeviceComponentFilterForm(CustomFieldModelFilterForm):
     )
     )
 
 
 
 
-class RegionFilterForm(CustomFieldModelFilterForm):
+class RegionFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
     model = Region
     model = Region
     parent_id = DynamicModelMultipleChoiceField(
     parent_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
@@ -108,7 +111,7 @@ class RegionFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class SiteGroupFilterForm(CustomFieldModelFilterForm):
+class SiteGroupFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
     model = SiteGroup
     model = SiteGroup
     parent_id = DynamicModelMultipleChoiceField(
     parent_id = DynamicModelMultipleChoiceField(
         queryset=SiteGroup.objects.all(),
         queryset=SiteGroup.objects.all(),
@@ -118,13 +121,14 @@ class SiteGroupFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm):
     model = Site
     model = Site
     field_groups = [
     field_groups = [
         ['q', 'tag'],
         ['q', 'tag'],
         ['status', 'region_id', 'group_id'],
         ['status', 'region_id', 'group_id'],
         ['tenant_group_id', 'tenant_id'],
         ['tenant_group_id', 'tenant_id'],
-        ['asn_id']
+        ['asn_id'],
+        ['contact', 'contact_role'],
     ]
     ]
     status = forms.MultipleChoiceField(
     status = forms.MultipleChoiceField(
         choices=SiteStatusChoices,
         choices=SiteStatusChoices,
@@ -148,13 +152,13 @@ class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     )
     )
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
-
-class LocationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm):
     model = Location
     model = Location
     field_groups = [
     field_groups = [
         ['q', 'tag'],
         ['q', 'tag'],
         ['region_id', 'site_group_id', 'site_id', 'parent_id'],
         ['region_id', 'site_group_id', 'site_id', 'parent_id'],
         ['tenant_group_id', 'tenant_id'],
         ['tenant_group_id', 'tenant_id'],
+        ['contact', 'contact_role'],
     ]
     ]
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
@@ -192,7 +196,7 @@ class RackRoleFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm):
     model = Rack
     model = Rack
     field_groups = [
     field_groups = [
         ['q', 'tag'],
         ['q', 'tag'],
@@ -200,6 +204,7 @@ class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
         ['status', 'role_id'],
         ['status', 'role_id'],
         ['type', 'width', 'serial', 'asset_tag'],
         ['type', 'width', 'serial', 'asset_tag'],
         ['tenant_group_id', 'tenant_id'],
         ['tenant_group_id', 'tenant_id'],
+        ['contact', 'contact_role']
     ]
     ]
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
@@ -303,7 +308,7 @@ class RackReservationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class ManufacturerFilterForm(CustomFieldModelFilterForm):
+class ManufacturerFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
     model = Manufacturer
     model = Manufacturer
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
@@ -390,7 +395,7 @@ class PlatformFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm):
+class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm):
     model = Device
     model = Device
     field_groups = [
     field_groups = [
         ['q', 'tag'],
         ['q', 'tag'],
@@ -402,6 +407,7 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi
             'has_primary_ip', 'virtual_chassis_member', 'console_ports', 'console_server_ports', 'power_ports',
             'has_primary_ip', 'virtual_chassis_member', 'console_ports', 'console_server_ports', 'power_ports',
             'power_outlets', 'interfaces', 'pass_through_ports', 'local_context_data',
             'power_outlets', 'interfaces', 'pass_through_ports', 'local_context_data',
         ],
         ],
+        ['contact', 'contact_role'],
     ]
     ]
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
@@ -636,11 +642,12 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class PowerPanelFilterForm(CustomFieldModelFilterForm):
+class PowerPanelFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
     model = PowerPanel
     model = PowerPanel
     field_groups = (
     field_groups = (
         ('q', 'tag'),
         ('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(
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),

+ 6 - 1
netbox/tenancy/api/serializers.py

@@ -40,11 +40,16 @@ class TenantSerializer(PrimaryModelSerializer):
     vlan_count = serializers.IntegerField(read_only=True)
     vlan_count = serializers.IntegerField(read_only=True)
     vrf_count = serializers.IntegerField(read_only=True)
     vrf_count = serializers.IntegerField(read_only=True)
     cluster_count = serializers.IntegerField(read_only=True)
     cluster_count = serializers.IntegerField(read_only=True)
+    contacts = NestedContactAssignmentSerializer(
+        required=False, 
+        allow_null=True,
+        many=True
+    )
 
 
     class Meta:
     class Meta:
         model = Tenant
         model = Tenant
         fields = [
         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',
             'created', 'last_updated', 'circuit_count', 'device_count', 'ipaddress_count', 'prefix_count', 'rack_count',
             'site_count', 'virtualmachine_count', 'vlan_count', 'vrf_count', 'cluster_count',
             'site_count', 'virtualmachine_count', 'vlan_count', 'vrf_count', 'cluster_count',
         ]
         ]

+ 94 - 80
netbox/tenancy/filtersets.py

@@ -15,179 +15,193 @@ __all__ = (
     'TenancyFilterSet',
     'TenancyFilterSet',
     'TenantFilterSet',
     'TenantFilterSet',
     'TenantGroupFilterSet',
     'TenantGroupFilterSet',
+    'ContactModelFilterSet'
 )
 )
 
 
 
 
 #
 #
-# Tenancy
+# Contacts
 #
 #
 
 
-class TenantGroupFilterSet(OrganizationalModelFilterSet):
+class ContactGroupFilterSet(OrganizationalModelFilterSet):
     parent_id = django_filters.ModelMultipleChoiceFilter(
     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(
     parent = django_filters.ModelMultipleChoiceFilter(
         field_name='parent__slug',
         field_name='parent__slug',
-        queryset=TenantGroup.objects.all(),
+        queryset=ContactGroup.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
-        label='Tenant group (slug)',
+        label='Contact group (slug)',
     )
     )
     tag = TagFilter()
     tag = TagFilter()
 
 
     class Meta:
     class Meta:
-        model = TenantGroup
+        model = ContactGroup
         fields = ['id', 'name', 'slug', 'description']
         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(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
     )
     )
     group_id = TreeNodeMultipleChoiceFilter(
     group_id = TreeNodeMultipleChoiceFilter(
-        queryset=TenantGroup.objects.all(),
+        queryset=ContactGroup.objects.all(),
         field_name='group',
         field_name='group',
         lookup_expr='in',
         lookup_expr='in',
-        label='Tenant group (ID)',
+        label='Contact group (ID)',
     )
     )
     group = TreeNodeMultipleChoiceFilter(
     group = TreeNodeMultipleChoiceFilter(
-        queryset=TenantGroup.objects.all(),
+        queryset=ContactGroup.objects.all(),
         field_name='group',
         field_name='group',
         lookup_expr='in',
         lookup_expr='in',
         to_field_name='slug',
         to_field_name='slug',
-        label='Tenant group (slug)',
+        label='Contact group (slug)',
     )
     )
     tag = TagFilter()
     tag = TagFilter()
 
 
     class Meta:
     class Meta:
-        model = Tenant
-        fields = ['id', 'name', 'slug']
+        model = Contact
+        fields = ['id', 'name', 'title', 'phone', 'email', 'address']
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
             return queryset
             return queryset
         return queryset.filter(
         return queryset.filter(
             Q(name__icontains=value) |
             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)
             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',
         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(
     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(
     parent = django_filters.ModelMultipleChoiceFilter(
         field_name='parent__slug',
         field_name='parent__slug',
-        queryset=ContactGroup.objects.all(),
+        queryset=TenantGroup.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
-        label='Contact group (slug)',
+        label='Tenant group (slug)',
     )
     )
     tag = TagFilter()
     tag = TagFilter()
 
 
     class Meta:
     class Meta:
-        model = ContactGroup
+        model = TenantGroup
         fields = ['id', 'name', 'slug', 'description']
         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(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
     )
     )
     group_id = TreeNodeMultipleChoiceFilter(
     group_id = TreeNodeMultipleChoiceFilter(
-        queryset=ContactGroup.objects.all(),
+        queryset=TenantGroup.objects.all(),
         field_name='group',
         field_name='group',
         lookup_expr='in',
         lookup_expr='in',
-        label='Contact group (ID)',
+        label='Tenant group (ID)',
     )
     )
     group = TreeNodeMultipleChoiceFilter(
     group = TreeNodeMultipleChoiceFilter(
-        queryset=ContactGroup.objects.all(),
+        queryset=TenantGroup.objects.all(),
         field_name='group',
         field_name='group',
         lookup_expr='in',
         lookup_expr='in',
         to_field_name='slug',
         to_field_name='slug',
-        label='Contact group (slug)',
+        label='Tenant group (slug)',
     )
     )
     tag = TagFilter()
     tag = TagFilter()
 
 
     class Meta:
     class Meta:
-        model = Contact
-        fields = ['id', 'name', 'title', 'phone', 'email', 'address']
+        model = Tenant
+        fields = ['id', 'name', 'slug']
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
             return queryset
             return queryset
         return queryset.filter(
         return queryset.filter(
             Q(name__icontains=value) |
             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)
             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',
         to_field_name='slug',
-        label='Contact role (slug)',
+        label='Tenant (slug)',
     )
     )
-
-    class Meta:
-        model = ContactAssignment
-        fields = ['id', 'content_type_id', 'object_id', 'priority']

+ 3 - 1
netbox/tenancy/forms/filtersets.py

@@ -2,6 +2,7 @@ from django.utils.translation import gettext as _
 
 
 from extras.forms import CustomFieldModelFilterForm
 from extras.forms import CustomFieldModelFilterForm
 from tenancy.models import *
 from tenancy.models import *
+from tenancy.forms import ContactModelFilterForm
 from utilities.forms import DynamicModelMultipleChoiceField, TagFilterField
 from utilities.forms import DynamicModelMultipleChoiceField, TagFilterField
 
 
 __all__ = (
 __all__ = (
@@ -27,11 +28,12 @@ class TenantGroupFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class TenantFilterForm(CustomFieldModelFilterForm):
+class TenantFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
     model = Tenant
     model = Tenant
     field_groups = (
     field_groups = (
         ('q', 'tag'),
         ('q', 'tag'),
         ('group_id',),
         ('group_id',),
+        ('contact', 'contact_role')
     )
     )
     group_id = DynamicModelMultipleChoiceField(
     group_id = DynamicModelMultipleChoiceField(
         queryset=TenantGroup.objects.all(),
         queryset=TenantGroup.objects.all(),

+ 15 - 1
netbox/tenancy/forms/forms.py

@@ -1,12 +1,13 @@
 from django import forms
 from django import forms
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
-from tenancy.models import Tenant, TenantGroup
+from tenancy.models import *
 from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
 from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
 
 
 __all__ = (
 __all__ = (
     'TenancyForm',
     'TenancyForm',
     'TenancyFilterForm',
     'TenancyFilterForm',
+    'ContactModelFilterForm'
 )
 )
 
 
 
 
@@ -44,3 +45,16 @@ class TenancyFilterForm(forms.Form):
         },
         },
         label=_('Tenant')
         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')
+    )

+ 25 - 5
netbox/virtualization/api/serializers.py

@@ -7,7 +7,7 @@ from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSer
 from ipam.models import VLAN
 from ipam.models import VLAN
 from netbox.api import ChoiceField, SerializedPKRelatedField
 from netbox.api import ChoiceField, SerializedPKRelatedField
 from netbox.api.serializers import PrimaryModelSerializer
 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.choices import *
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 from .nested_serializers import *
 from .nested_serializers import *
@@ -32,11 +32,16 @@ class ClusterTypeSerializer(PrimaryModelSerializer):
 class ClusterGroupSerializer(PrimaryModelSerializer):
 class ClusterGroupSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustergroup-detail')
     url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustergroup-detail')
     cluster_count = serializers.IntegerField(read_only=True)
     cluster_count = serializers.IntegerField(read_only=True)
+    contacts = NestedContactAssignmentSerializer(
+        required=False, 
+        allow_null=True,
+        many=True
+    )
 
 
     class Meta:
     class Meta:
         model = ClusterGroup
         model = ClusterGroup
         fields = [
         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',
             'cluster_count',
         ]
         ]
 
 
@@ -49,11 +54,16 @@ class ClusterSerializer(PrimaryModelSerializer):
     site = NestedSiteSerializer(required=False, allow_null=True, default=None)
     site = NestedSiteSerializer(required=False, allow_null=True, default=None)
     device_count = serializers.IntegerField(read_only=True)
     device_count = serializers.IntegerField(read_only=True)
     virtualmachine_count = serializers.IntegerField(read_only=True)
     virtualmachine_count = serializers.IntegerField(read_only=True)
+    contacts = NestedContactAssignmentSerializer(
+        required=False, 
+        allow_null=True,
+        many=True
+    )
 
 
     class Meta:
     class Meta:
         model = Cluster
         model = Cluster
         fields = [
         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',
             'created', 'last_updated', 'device_count', 'virtualmachine_count',
         ]
         ]
 
 
@@ -73,12 +83,17 @@ class VirtualMachineSerializer(PrimaryModelSerializer):
     primary_ip = NestedIPAddressSerializer(read_only=True)
     primary_ip = NestedIPAddressSerializer(read_only=True)
     primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
     primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
     primary_ip6 = 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:
     class Meta:
         model = VirtualMachine
         model = VirtualMachine
         fields = [
         fields = [
             'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip',
             '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',
             'custom_fields', 'created', 'last_updated',
         ]
         ]
         validators = []
         validators = []
@@ -86,11 +101,16 @@ class VirtualMachineSerializer(PrimaryModelSerializer):
 
 
 class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
 class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
     config_context = serializers.SerializerMethodField()
     config_context = serializers.SerializerMethodField()
+    contacts = NestedContactAssignmentSerializer(
+        required=False, 
+        allow_null=True,
+        many=True
+    )
 
 
     class Meta(VirtualMachineSerializer.Meta):
     class Meta(VirtualMachineSerializer.Meta):
         fields = [
         fields = [
             'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip',
             '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',
             'custom_fields', 'config_context', 'created', 'last_updated',
         ]
         ]
 
 

+ 4 - 4
netbox/virtualization/filtersets.py

@@ -5,7 +5,7 @@ from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
 from extras.filters import TagFilter
 from extras.filters import TagFilter
 from extras.filtersets import LocalConfigContextFilterSet
 from extras.filtersets import LocalConfigContextFilterSet
 from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet
 from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet
-from tenancy.filtersets import TenancyFilterSet
+from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
 from utilities.filters import MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter
 from utilities.filters import MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter
 from .choices import *
 from .choices import *
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@@ -27,7 +27,7 @@ class ClusterTypeFilterSet(OrganizationalModelFilterSet):
         fields = ['id', 'name', 'slug', 'description']
         fields = ['id', 'name', 'slug', 'description']
 
 
 
 
-class ClusterGroupFilterSet(OrganizationalModelFilterSet):
+class ClusterGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
     tag = TagFilter()
     tag = TagFilter()
 
 
     class Meta:
     class Meta:
@@ -35,7 +35,7 @@ class ClusterGroupFilterSet(OrganizationalModelFilterSet):
         fields = ['id', 'name', 'slug', 'description']
         fields = ['id', 'name', 'slug', 'description']
 
 
 
 
-class ClusterFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
+class ClusterFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
@@ -111,7 +111,7 @@ class ClusterFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
         )
         )
 
 
 
 
-class VirtualMachineFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContextFilterSet):
+class VirtualMachineFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',

+ 6 - 4
netbox/virtualization/forms/filtersets.py

@@ -3,7 +3,7 @@ from django.utils.translation import gettext as _
 
 
 from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
 from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
 from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm
 from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm
-from tenancy.forms import TenancyFilterForm
+from tenancy.forms import TenancyFilterForm, ContactModelFilterForm
 from utilities.forms import (
 from utilities.forms import (
     DynamicModelMultipleChoiceField, StaticSelect, StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
     DynamicModelMultipleChoiceField, StaticSelect, StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
 )
 )
@@ -24,18 +24,19 @@ class ClusterTypeFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class ClusterGroupFilterForm(CustomFieldModelFilterForm):
+class ClusterGroupFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
     model = ClusterGroup
     model = ClusterGroup
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class ClusterFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm):
     model = Cluster
     model = Cluster
     field_groups = [
     field_groups = [
         ['q', 'tag'],
         ['q', 'tag'],
         ['group_id', 'type_id'],
         ['group_id', 'type_id'],
         ['region_id', 'site_group_id', 'site_id'],
         ['region_id', 'site_group_id', 'site_id'],
         ['tenant_group_id', 'tenant_id'],
         ['tenant_group_id', 'tenant_id'],
+        ['contact', 'contact_role'],
     ]
     ]
     type_id = DynamicModelMultipleChoiceField(
     type_id = DynamicModelMultipleChoiceField(
         queryset=ClusterType.objects.all(),
         queryset=ClusterType.objects.all(),
@@ -71,7 +72,7 @@ class ClusterFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class VirtualMachineFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm):
+class VirtualMachineFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm):
     model = VirtualMachine
     model = VirtualMachine
     field_groups = [
     field_groups = [
         ['q', 'tag'],
         ['q', 'tag'],
@@ -79,6 +80,7 @@ class VirtualMachineFilterForm(LocalConfigContextFilterForm, TenancyFilterForm,
         ['region_id', 'site_group_id', 'site_id'],
         ['region_id', 'site_group_id', 'site_id'],
         ['status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data'],
         ['status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data'],
         ['tenant_group_id', 'tenant_id'],
         ['tenant_group_id', 'tenant_id'],
+        ['contact', 'contact_role'],
     ]
     ]
     cluster_group_id = DynamicModelMultipleChoiceField(
     cluster_group_id = DynamicModelMultipleChoiceField(
         queryset=ClusterGroup.objects.all(),
         queryset=ClusterGroup.objects.all(),