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

Merge pull request #8742 from minitriga/issue_8645

Allow filtering on Core models for Contacts
Jeremy Stretch 3 лет назад
Родитель
Сommit
1541060091

+ 3 - 3
netbox/circuits/filtersets.py

@@ -5,7 +5,7 @@ from dcim.filtersets import CableTerminationFilterSet
 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 ContactModelFilterSet, TenancyFilterSet
 from utilities.filters import TreeNodeMultipleChoiceFilter
 from .choices import *
 from .models import *
@@ -19,7 +19,7 @@ __all__ = (
 )
 
 
-class ProviderFilterSet(PrimaryModelFilterSet):
+class ProviderFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',
@@ -118,7 +118,7 @@ class CircuitTypeFilterSet(OrganizationalModelFilterSet):
         fields = ['id', 'name', 'slug', 'description']
 
 
-class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
+class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',

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

@@ -5,7 +5,7 @@ from circuits.choices import CircuitStatusChoices
 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 @@ __all__ = (
 )
 
 
-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(),

+ 8 - 2
netbox/circuits/tables.py

@@ -58,6 +58,9 @@ class ProviderTable(BaseTable):
         verbose_name='Circuits'
     )
     comments = MarkdownColumn()
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     tags = TagColumn(
         url_name='circuits:provider_list'
     )
@@ -66,7 +69,7 @@ class ProviderTable(BaseTable):
         model = Provider
         fields = (
             'pk', 'id', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count',
-            'comments', 'tags', 'created', 'last_updated',
+            'comments', 'contacts', 'tags', 'created', 'last_updated',
         )
         default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count')
 
@@ -142,6 +145,9 @@ class CircuitTable(BaseTable):
     )
     commit_rate = CommitRateColumn()
     comments = MarkdownColumn()
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     tags = TagColumn(
         url_name='circuits:circuit_list'
     )
@@ -150,7 +156,7 @@ class CircuitTable(BaseTable):
         model = Circuit
         fields = (
             'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date',
-            'commit_rate', 'description', 'comments', 'tags', 'created', 'last_updated',
+            'commit_rate', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated',
         )
         default_columns = (
             'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description',

+ 10 - 10
netbox/dcim/filtersets.py

@@ -7,8 +7,8 @@ from ipam.models import ASN
 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 @@ __all__ = (
 )
 
 
-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 RegionFilterSet(OrganizationalModelFilterSet):
         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 SiteGroupFilterSet(OrganizationalModelFilterSet):
         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 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
         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 RackRoleFilterSet(OrganizationalModelFilterSet):
         fields = ['id', 'name', 'slug', 'color', 'description']
 
 
-class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
+class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',
@@ -398,7 +398,7 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
         )
 
 
-class ManufacturerFilterSet(OrganizationalModelFilterSet):
+class ManufacturerFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
     tag = TagFilter()
 
     class Meta:
@@ -608,7 +608,7 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
         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 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
         return queryset
 
 
-class PowerPanelFilterSet(PrimaryModelFilterSet):
+class PowerPanelFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',

+ 31 - 11
netbox/dcim/forms/filtersets.py

@@ -5,9 +5,10 @@ from django.utils.translation import gettext as _
 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 ContactModelFilterForm, TenancyFilterForm
 from utilities.forms import (
     APISelectMultiple, add_blank_choice, ColorField, DynamicModelMultipleChoiceField, FilterForm, StaticSelect,
     StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
@@ -98,8 +99,13 @@ class DeviceComponentFilterForm(CustomFieldModelFilterForm):
     )
 
 
-class RegionFilterForm(CustomFieldModelFilterForm):
+class RegionFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
     model = Region
+    field_groups = [
+        ['q', 'tag'],
+        ['parent_id'],
+        ['contact', 'contact_role'],
+    ]
     parent_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         required=False,
@@ -108,8 +114,13 @@ class RegionFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
 
 
-class SiteGroupFilterForm(CustomFieldModelFilterForm):
+class SiteGroupFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
     model = SiteGroup
+    field_groups = [
+        ['q', 'tag'],
+        ['parent_id'],
+        ['contact', 'contact_role'],
+    ]
     parent_id = DynamicModelMultipleChoiceField(
         queryset=SiteGroup.objects.all(),
         required=False,
@@ -118,13 +129,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,
@@ -149,12 +161,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 +205,7 @@ class RackRoleFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
 
 
-class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
+class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm):
     model = Rack
     field_groups = [
         ['q', 'tag'],
@@ -200,6 +213,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,8 +317,12 @@ class RackReservationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     tag = TagFilterField(model)
 
 
-class ManufacturerFilterForm(CustomFieldModelFilterForm):
+class ManufacturerFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm):
     model = Manufacturer
+    field_groups = [
+        ['q', 'tag'],
+        ['contact', 'contact_role'],
+    ]
     tag = TagFilterField(model)
 
 
@@ -390,7 +408,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 +420,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 +655,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(),

+ 5 - 2
netbox/dcim/tables/devices.py

@@ -194,6 +194,9 @@ class DeviceTable(BaseTable):
     vc_priority = tables.Column(
         verbose_name='VC Priority'
     )
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     comments = MarkdownColumn()
     tags = TagColumn(
         url_name='dcim:device_list'
@@ -204,8 +207,8 @@ class DeviceTable(BaseTable):
         fields = (
             'pk', 'id', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial',
             'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'airflow', 'primary_ip4',
-            'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', 'created',
-            'last_updated',
+            'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'contacts', 'tags',
+            'created', 'last_updated',
         )
         default_columns = (
             'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',

+ 4 - 1
netbox/dcim/tables/devicetypes.py

@@ -41,6 +41,9 @@ class ManufacturerTable(BaseTable):
         verbose_name='Platforms'
     )
     slug = tables.Column()
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     tags = TagColumn(
         url_name='dcim:manufacturer_list'
     )
@@ -50,7 +53,7 @@ class ManufacturerTable(BaseTable):
         model = Manufacturer
         fields = (
             'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
-            'actions', 'created', 'last_updated',
+            'contacts', 'actions', 'created', 'last_updated',
         )
         default_columns = (
             'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'actions',

+ 4 - 1
netbox/dcim/tables/power.py

@@ -27,13 +27,16 @@ class PowerPanelTable(BaseTable):
         url_params={'power_panel_id': 'pk'},
         verbose_name='Feeds'
     )
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     tags = TagColumn(
         url_name='dcim:powerpanel_list'
     )
 
     class Meta(BaseTable.Meta):
         model = PowerPanel
-        fields = ('pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'tags', 'created', 'last_updated',)
+        fields = ('pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'contacts', 'tags', 'created', 'last_updated',)
         default_columns = ('pk', 'name', 'site', 'location', 'powerfeed_count')
 
 

+ 4 - 1
netbox/dcim/tables/racks.py

@@ -75,6 +75,9 @@ class RackTable(BaseTable):
         orderable=False,
         verbose_name='Power'
     )
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     tags = TagColumn(
         url_name='dcim:rack_list'
     )
@@ -92,7 +95,7 @@ class RackTable(BaseTable):
         fields = (
             'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag',
             'type', 'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization',
-            'get_power_utilization', 'tags', 'created', 'last_updated',
+            'get_power_utilization', 'contacts', 'tags', 'created', 'last_updated',
         )
         default_columns = (
             'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',

+ 17 - 5
netbox/dcim/tables/sites.py

@@ -29,6 +29,9 @@ class RegionTable(BaseTable):
         url_params={'region_id': 'pk'},
         verbose_name='Sites'
     )
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     tags = TagColumn(
         url_name='dcim:region_list'
     )
@@ -36,7 +39,7 @@ class RegionTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         model = Region
-        fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'actions', 'created', 'last_updated')
+        fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'contacts', 'tags', 'actions', 'created', 'last_updated')
         default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
 
 
@@ -54,6 +57,9 @@ class SiteGroupTable(BaseTable):
         url_params={'group_id': 'pk'},
         verbose_name='Sites'
     )
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     tags = TagColumn(
         url_name='dcim:sitegroup_list'
     )
@@ -61,7 +67,7 @@ class SiteGroupTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         model = SiteGroup
-        fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'actions', 'created', 'last_updated')
+        fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'contacts', 'tags', 'actions', 'created', 'last_updated')
         default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
 
 
@@ -92,6 +98,9 @@ class SiteTable(BaseTable):
         verbose_name='ASNs'
     )
     tenant = TenantColumn()
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     comments = MarkdownColumn()
     tags = TagColumn(
         url_name='dcim:site_list'
@@ -102,7 +111,7 @@ class SiteTable(BaseTable):
         fields = (
             'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asns', 'asn_count',
             'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name',
-            'contact_phone', 'contact_email', 'comments', 'tags', 'created', 'last_updated',
+            'contact_phone', 'contact_email', 'contacts', 'comments', 'tags', 'created', 'last_updated',
         )
         default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'description')
 
@@ -130,6 +139,9 @@ class LocationTable(BaseTable):
         url_params={'location_id': 'pk'},
         verbose_name='Devices'
     )
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     tags = TagColumn(
         url_name='dcim:location_list'
     )
@@ -141,7 +153,7 @@ class LocationTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = Location
         fields = (
-            'pk', 'id', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'tags',
-            'actions', 'created', 'last_updated',
+            'pk', 'id', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'contacts',
+            'tags', 'actions', 'created', 'last_updated',
         )
         default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'actions')

+ 94 - 80
netbox/tenancy/filtersets.py

@@ -11,6 +11,7 @@ __all__ = (
     'ContactAssignmentFilterSet',
     'ContactFilterSet',
     'ContactGroupFilterSet',
+    'ContactModelFilterSet',
     'ContactRoleFilterSet',
     'TenancyFilterSet',
     'TenantFilterSet',
@@ -19,175 +20,188 @@ __all__ = (
 
 
 #
-# 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', 'description']
+
+
+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', 'description']
+        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
-        fields = ['id', 'name', 'slug', 'description']
-
-
-class ContactRoleFilterSet(OrganizationalModelFilterSet):
-    tag = TagFilter()
-
-    class Meta:
-        model = ContactRole
+        model = TenantGroup
         fields = ['id', 'name', 'slug', 'description']
 
 
-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', 'description']
 
     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']

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

@@ -2,6 +2,7 @@ from django.utils.translation import gettext as _
 
 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(),

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

@@ -1,10 +1,11 @@
 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__ = (
+    'ContactModelFilterForm',
     'TenancyForm',
     'TenancyFilterForm',
 )
@@ -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')
+    )

+ 3 - 0
netbox/tenancy/models/contacts.py

@@ -166,3 +166,6 @@ class ContactAssignment(ChangeLoggedModel):
         if self.priority:
             return f"{self.contact} ({self.get_priority_display()})"
         return str(self.contact)
+
+    def get_absolute_url(self):
+        return reverse('tenancy:contact', args=[self.contact.pk])

+ 4 - 1
netbox/tenancy/tables.py

@@ -77,6 +77,9 @@ class TenantTable(BaseTable):
     group = tables.Column(
         linkify=True
     )
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     comments = MarkdownColumn()
     tags = TagColumn(
         url_name='tenancy:tenant_list'
@@ -84,7 +87,7 @@ class TenantTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         model = Tenant
-        fields = ('pk', 'id', 'name', 'slug', 'group', 'description', 'comments', 'tags', 'created', 'last_updated',)
+        fields = ('pk', 'id', 'name', 'slug', 'group', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated',)
         default_columns = ('pk', 'name', 'group', 'description')
 
 

+ 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.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 ClusterTypeFilterSet(OrganizationalModelFilterSet):
         fields = ['id', 'name', 'slug', 'description']
 
 
-class ClusterGroupFilterSet(OrganizationalModelFilterSet):
+class ClusterGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
     tag = TagFilter()
 
     class Meta:
@@ -35,7 +35,7 @@ class ClusterGroupFilterSet(OrganizationalModelFilterSet):
         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 @@ class ClusterFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
         )
 
 
-class VirtualMachineFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContextFilterSet):
+class VirtualMachineFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet):
     q = django_filters.CharFilter(
         method='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 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(),

+ 12 - 3
netbox/virtualization/tables.py

@@ -62,6 +62,9 @@ class ClusterGroupTable(BaseTable):
     cluster_count = tables.Column(
         verbose_name='Clusters'
     )
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     tags = TagColumn(
         url_name='virtualization:clustergroup_list'
     )
@@ -70,7 +73,7 @@ class ClusterGroupTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = ClusterGroup
         fields = (
-            'pk', 'id', 'name', 'slug', 'cluster_count', 'description', 'tags', 'actions', 'created', 'last_updated',
+            'pk', 'id', 'name', 'slug', 'cluster_count', 'description', 'contacts', 'tags', 'actions', 'created', 'last_updated',
         )
         default_columns = ('pk', 'name', 'cluster_count', 'description', 'actions')
 
@@ -106,6 +109,9 @@ class ClusterTable(BaseTable):
         url_params={'cluster_id': 'pk'},
         verbose_name='VMs'
     )
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     comments = MarkdownColumn()
     tags = TagColumn(
         url_name='virtualization:cluster_list'
@@ -114,7 +120,7 @@ class ClusterTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = Cluster
         fields = (
-            'pk', 'id', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'device_count', 'vm_count', 'tags',
+            'pk', 'id', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'device_count', 'vm_count', 'contacts', 'tags',
             'created', 'last_updated',
         )
         default_columns = ('pk', 'name', 'type', 'group', 'tenant', 'site', 'device_count', 'vm_count')
@@ -150,6 +156,9 @@ class VirtualMachineTable(BaseTable):
         order_by=('primary_ip4', 'primary_ip6'),
         verbose_name='IP Address'
     )
+    contacts = tables.ManyToManyColumn(
+        linkify_item=True
+    )
     tags = TagColumn(
         url_name='virtualization:virtualmachine_list'
     )
@@ -158,7 +167,7 @@ class VirtualMachineTable(BaseTable):
         model = VirtualMachine
         fields = (
             'pk', 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk',
-            'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'tags', 'created', 'last_updated',
+            'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'contacts', 'tags', 'created', 'last_updated',
         )
         default_columns = (
             'pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip',