Repository: netbox-community/netbox
Pull request: Adds contact tabs
<!--
    Thank you for your interest in contributing to NetBox! Please note that
    our contribution policy requires that a feature request or bug report be
    approved and assigned prior to opening a pull request. This helps avoid
    waste time and effort on a proposed change that we might not be able to
    accept.

    IF YOUR PULL REQUEST DOES NOT REFERENCE AN ISSUE WHICH HAS BEEN ASSIGNED
    TO YOU, IT WILL BE CLOSED AUTOMATICALLY.

    Please specify your assigned issue number on the line below.
-->
### Fixes: #11599

<!--
    Please include a summary of the proposed changes below.
-->


Problem statement:
Move contacts panel on tenant object to ViewTab
### NetBox version

v3.4.3

### Feature type

Change to existing functionality

### Proposed functionality

Move contacts panel to ViewTab

### Use case

On certain tenants, I have several contacts. This causes the tenant object page to load slightly slower and there are a few niceties missing as a result like:

- Quick search
- configure columns
- pagination


### Database changes

_No response_

### External dependencies

_No response_

Hints from issue discussion:
If we decide to do this, it should be done consistently for _all_ object types to which contacts can be assigned (providers, sites, etc.).

Commit message: fixed lint issues

Patch to review:
diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py
index e5f4faee1e0..b74874fafdf 100644
--- a/netbox/circuits/views.py
+++ b/netbox/circuits/views.py
@@ -1,10 +1,10 @@
 from django.contrib import messages
 from django.db import transaction
-from django.db.models import Q
 from django.shortcuts import get_object_or_404, redirect, render
 
 from dcim.views import PathTraceView
 from netbox.views import generic
+from tenancy.views import ChildContactView
 from utilities.forms import ConfirmationForm
 from utilities.utils import count_related
 from utilities.views import register_model_view
@@ -73,6 +73,11 @@ class ProviderBulkDeleteView(generic.BulkDeleteView):
     table = tables.ProviderTable
 
 
+@register_model_view(Provider, 'contacts')
+class ProviderContactsView(ChildContactView):
+    queryset = Provider.objects.all()
+
+
 #
 # ProviderAccounts
 #
@@ -134,6 +139,11 @@ class ProviderAccountBulkDeleteView(generic.BulkDeleteView):
     table = tables.ProviderAccountTable
 
 
+@register_model_view(ProviderAccount, 'contacts')
+class ProviderAccountContactsView(ChildContactView):
+    queryset = ProviderAccount.objects.all()
+
+
 #
 # Provider networks
 #
@@ -389,6 +399,11 @@ def post(self, request, pk):
         })
 
 
+@register_model_view(Circuit, 'contacts')
+class CircuitContactsView(ChildContactView):
+    queryset = Circuit.objects.all()
+
+
 #
 # Circuit terminations
 #
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index bcbbf17395b..c46e33095aa 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -20,6 +20,7 @@
 from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup
 from ipam.tables import InterfaceVLANTable
 from netbox.views import generic
+from tenancy.views import ChildContactView
 from utilities.forms import ConfirmationForm
 from utilities.paginator import EnhancedPaginator, get_paginate_count
 from utilities.permissions import get_permission_for_model
@@ -267,6 +268,11 @@ class RegionBulkDeleteView(generic.BulkDeleteView):
     table = tables.RegionTable
 
 
+@register_model_view(Region, 'contacts')
+class RegionContactsView(ChildContactView):
+    queryset = Region.objects.all()
+
+
 #
 # Site groups
 #
@@ -342,6 +348,11 @@ class SiteGroupBulkDeleteView(generic.BulkDeleteView):
     table = tables.SiteGroupTable
 
 
+@register_model_view(SiteGroup, 'contacts')
+class SiteGroupContactsView(ChildContactView):
+    queryset = SiteGroup.objects.all()
+
+
 #
 # Sites
 #
@@ -435,6 +446,11 @@ class SiteBulkDeleteView(generic.BulkDeleteView):
     table = tables.SiteTable
 
 
+@register_model_view(Site, 'contacts')
+class SiteContactsView(ChildContactView):
+    queryset = Site.objects.all()
+
+
 #
 # Locations
 #
@@ -523,6 +539,11 @@ class LocationBulkDeleteView(generic.BulkDeleteView):
     table = tables.LocationTable
 
 
+@register_model_view(Location, 'contacts')
+class LocationContactsView(ChildContactView):
+    queryset = Location.objects.all()
+
+
 #
 # Rack roles
 #
@@ -740,6 +761,11 @@ class RackBulkDeleteView(generic.BulkDeleteView):
     table = tables.RackTable
 
 
+@register_model_view(Rack, 'contacts')
+class RackContactsView(ChildContactView):
+    queryset = Rack.objects.all()
+
+
 #
 # Rack reservations
 #
@@ -874,6 +900,11 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView):
     table = tables.ManufacturerTable
 
 
+@register_model_view(Manufacturer, 'contacts')
+class ManufacturerContactsView(ChildContactView):
+    queryset = Manufacturer.objects.all()
+
+
 #
 # Device types
 #
@@ -2088,6 +2119,11 @@ class DeviceBulkRenameView(generic.BulkRenameView):
     table = tables.DeviceTable
 
 
+@register_model_view(Device, 'contacts')
+class DeviceContactsView(ChildContactView):
+    queryset = Device.objects.all()
+
+
 #
 # Modules
 #
@@ -3469,6 +3505,11 @@ class PowerPanelBulkDeleteView(generic.BulkDeleteView):
     table = tables.PowerPanelTable
 
 
+@register_model_view(PowerPanel, 'contacts')
+class PowerPanelContactsView(ChildContactView):
+    queryset = PowerPanel.objects.all()
+
+
 #
 # Power feeds
 #
diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py
index a49c4aab3de..08d1b705a55 100644
--- a/netbox/ipam/views.py
+++ b/netbox/ipam/views.py
@@ -9,6 +9,7 @@
 from dcim.filtersets import InterfaceFilterSet
 from dcim.models import Interface, Site
 from netbox.views import generic
+from tenancy.views import ChildContactView
 from utilities.utils import count_related
 from utilities.views import ViewTab, register_model_view
 from virtualization.filtersets import VMInterfaceFilterSet
@@ -1291,6 +1292,11 @@ class L2VPNBulkDeleteView(generic.BulkDeleteView):
     table = tables.L2VPNTable
 
 
+@register_model_view(L2VPN, 'contacts')
+class L2VPNContactsView(ChildContactView):
+    queryset = L2VPN.objects.all()
+
+
 #
 # L2VPN terminations
 #
diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html
index ee994e95997..a5913e2ad1a 100644
--- a/netbox/templates/circuits/circuit.html
+++ b/netbox/templates/circuits/circuit.html
@@ -70,7 +70,6 @@ <h5 class="card-header">Circuit</h5>
     <div class="col col-md-6">
       {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %}
       {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %}
-      {% include 'inc/panels/contacts.html' %}
       {% include 'inc/panels/image_attachments.html' %}
       {% plugin_right_page object %}
     </div>
diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html
index 69520217640..58e62ab07cd 100644
--- a/netbox/templates/circuits/provider.html
+++ b/netbox/templates/circuits/provider.html
@@ -54,7 +54,6 @@ <h5 class="card-header">Provider</h5>
     <div class="col col-md-6">
         {% include 'inc/panels/related_objects.html' %}
         {% include 'inc/panels/custom_fields.html' %}
-        {% include 'inc/panels/contacts.html' %}
         {% plugin_right_page object %}
     </div>
 </div>
diff --git a/netbox/templates/circuits/provideraccount.html b/netbox/templates/circuits/provideraccount.html
index 63344ada1dd..c55663b4a09 100644
--- a/netbox/templates/circuits/provideraccount.html
+++ b/netbox/templates/circuits/provideraccount.html
@@ -38,7 +38,6 @@ <h5 class="card-header">Provider Account</h5>
       {% include 'inc/panels/related_objects.html' %}
       {% include 'inc/panels/comments.html' %}
       {% include 'inc/panels/custom_fields.html' %}
-      {% include 'inc/panels/contacts.html' %}
       {% plugin_right_page object %}
     </div>
     <div class="col col-md-12">
diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html
index 2c79ab006f7..df6397a38d8 100644
--- a/netbox/templates/dcim/device.html
+++ b/netbox/templates/dcim/device.html
@@ -298,7 +298,6 @@ <h5 class="card-header">Services</h5>
                 </div>
               {% endif %}
             </div>
-            {% include 'inc/panels/contacts.html' %}
             {% include 'inc/panels/image_attachments.html' %}
             {% if object.rack and object.position %}
               <div class="row" style="margin-bottom: 20px">
diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html
index 193d93f9a85..795aeb35f20 100644
--- a/netbox/templates/dcim/location.html
+++ b/netbox/templates/dcim/location.html
@@ -65,7 +65,6 @@ <h5 class="card-header">
   </div>
 	<div class="col col-md-6">
     {% include 'inc/panels/related_objects.html' %}
-    {% include 'inc/panels/contacts.html' %}
     {% include 'dcim/inc/nonracked_devices.html' %}
     {% include 'inc/panels/image_attachments.html' %}
     {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/manufacturer.html b/netbox/templates/dcim/manufacturer.html
index a60b3503c8d..8233b6fc8c6 100644
--- a/netbox/templates/dcim/manufacturer.html
+++ b/netbox/templates/dcim/manufacturer.html
@@ -51,7 +51,6 @@ <h5 class="card-header">
 	<div class="col col-md-6">
     {% include 'inc/panels/related_objects.html' %}
     {% include 'inc/panels/custom_fields.html' %}
-    {% include 'inc/panels/contacts.html' %}
     {% plugin_right_page object %}
   </div>
 </div>
diff --git a/netbox/templates/dcim/powerpanel.html b/netbox/templates/dcim/powerpanel.html
index af08f30230a..ea9210ba7ec 100644
--- a/netbox/templates/dcim/powerpanel.html
+++ b/netbox/templates/dcim/powerpanel.html
@@ -40,7 +40,6 @@ <h5 class="card-header">Power Panel</h5>
 	<div class="col col-md-6">
     {% include 'inc/panels/related_objects.html' %}
     {% include 'inc/panels/custom_fields.html' %}
-    {% include 'inc/panels/contacts.html' %}
     {% include 'inc/panels/image_attachments.html' %}
     {% plugin_right_page object %}
   </div>
diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html
index 9cb046b4e4b..52b5d4bfec6 100644
--- a/netbox/templates/dcim/rack.html
+++ b/netbox/templates/dcim/rack.html
@@ -191,7 +191,6 @@ <h4>Rear</h4>
         </div>
         {% include 'inc/panels/related_objects.html' %}
         {% include 'dcim/inc/nonracked_devices.html' %}
-        {% include 'inc/panels/contacts.html' %}
         {% plugin_right_page object %}
     </div>
   </div>
diff --git a/netbox/templates/dcim/region.html b/netbox/templates/dcim/region.html
index 85587e4b5ed..05cc424d7c9 100644
--- a/netbox/templates/dcim/region.html
+++ b/netbox/templates/dcim/region.html
@@ -46,7 +46,6 @@ <h5 class="card-header">Region</h5>
   </div>
 	<div class="col col-md-6">
     {% include 'inc/panels/related_objects.html' %}
-    {% include 'inc/panels/contacts.html' %}
     {% plugin_right_page object %}
 	</div>
 </div>
diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html
index d6de8f3cbda..6b802c56dd7 100644
--- a/netbox/templates/dcim/site.html
+++ b/netbox/templates/dcim/site.html
@@ -127,7 +127,6 @@ <h5 class="card-header">Site</h5>
     </div>
     <div class="col col-md-6">
       {% include 'inc/panels/related_objects.html' with filter_name='site_id' %}
-      {% include 'inc/panels/contacts.html' %}
       <div class="card">
         <h5 class="card-header">Locations</h5>
         <div class='card-body'>
diff --git a/netbox/templates/dcim/sitegroup.html b/netbox/templates/dcim/sitegroup.html
index 2cf8e71684a..819022a34de 100644
--- a/netbox/templates/dcim/sitegroup.html
+++ b/netbox/templates/dcim/sitegroup.html
@@ -42,7 +42,6 @@ <h5 class="card-header">Site Group</h5>
     </div>
     {% include 'inc/panels/tags.html' %}
     {% include 'inc/panels/custom_fields.html' %}
-    {% include 'inc/panels/contacts.html' %}
     {% plugin_left_page object %}
   </div>
 	<div class="col col-md-6">
diff --git a/netbox/templates/inc/panels/contacts.html b/netbox/templates/inc/panels/contacts.html
deleted file mode 100644
index 359ad8d7edb..00000000000
--- a/netbox/templates/inc/panels/contacts.html
+++ /dev/null
@@ -1,63 +0,0 @@
-{% load helpers %}
-
-<div class="card">
-  <h5 class="card-header">Contacts</h5>
-  <div class="card-body">
-    {% with contacts=object.contacts.all %}
-      {% if contacts.exists %}
-        <table class="table table-hover">
-          <tr>
-            <th>Name</th>
-            <th>Role</th>
-            <th>Priority</th>
-            <th>Phone</th>
-            <th>Email</th>
-            <th></th>
-          </tr>
-          {% for contact in contacts %}
-            <tr>
-              <td>{{ contact.contact|linkify }}</td>
-              <td>{{ contact.role|placeholder }}</td>
-              <td>{{ contact.get_priority_display|placeholder }}</td>
-              <td>
-                {% if contact.contact.phone %}
-                  <a href="tel:{{ contact.contact.phone }}">{{ contact.contact.phone }}</a>
-                {% else %}
-                  {{ ''|placeholder }}
-                {% endif %}
-              </td>
-              <td>
-                {% if contact.contact.email %}
-                  <a href="mailto:{{ contact.contact.email }}">{{ contact.contact.email }}</a>
-                {% else %}
-                  {{ ''|placeholder }}
-                {% endif %}
-              </td>
-              <td class="text-end noprint">
-                {% if perms.tenancy.change_contactassignment %}
-                  <a href="{% url 'tenancy:contactassignment_edit' pk=contact.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-warning btn-sm lh-1" title="Edit">
-                    <i class="mdi mdi-pencil" aria-hidden="true"></i>
-                  </a>
-                {% endif %}
-                {% if perms.tenancy.delete_contactassignment %}
-                  <a href="{% url 'tenancy:contactassignment_delete' pk=contact.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-danger btn-sm lh-1" title="Delete">
-                    <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>
-                  </a>
-                {% endif %}
-              </td>
-            </tr>
-          {% endfor %}
-        </table>
-      {% else %}
-        <div class="text-muted">None</div>
-      {% endif %}
-    {% endwith %}
-  </div>
-  {% if perms.tenancy.add_contactassignment %}
-    <div class="card-footer text-end noprint">
-      <a href="{% url 'tenancy:contactassignment_add' %}?content_type={{ object|content_type_id }}&object_id={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm">
-        <i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add a contact
-      </a>
-    </div>
-  {% endif %}
-</div>
diff --git a/netbox/templates/ipam/l2vpn.html b/netbox/templates/ipam/l2vpn.html
index 87050eb2686..8896dd6c250 100644
--- a/netbox/templates/ipam/l2vpn.html
+++ b/netbox/templates/ipam/l2vpn.html
@@ -37,7 +37,6 @@ <h5 class="card-header">L2VPN Attributes</h5>
     {% plugin_left_page object %}
 	</div>
 	<div class="col col-md-6">
-      {% include 'inc/panels/contacts.html' %}
       {% include 'inc/panels/custom_fields.html' %}
       {% include 'inc/panels/comments.html' %}
       {% plugin_right_page object %}
diff --git a/netbox/templates/tenancy/contact_children.html b/netbox/templates/tenancy/contact_children.html
new file mode 100644
index 00000000000..b36dc459528
--- /dev/null
+++ b/netbox/templates/tenancy/contact_children.html
@@ -0,0 +1,27 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+
+{% block extra_controls %}
+    {% if perms.tenancy.add_contactassignment %}
+    <a href="{% url 'tenancy:contactassignment_add' %}?content_type={{ object|content_type_id }}&object_id={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm">
+        <i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add a contact
+    </a>
+  {% endif %}
+{% endblock %}
+
+{% block content %}
+    {% include 'inc/table_controls_htmx.html' with table_modal="ContactTable_config" %}
+    <form method="post">
+        {% csrf_token %}
+        <div class="card">
+            <div class="card-body" id="object_list">
+                {% include 'htmx/table.html' %}
+            </div>
+        </div>
+    </form>
+{% endblock content %}
+
+{% block modals %}
+    {{ block.super }}
+    {% table_config_form table %}
+{% endblock modals %}
diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html
index da48f1ef590..34abe5c01b7 100644
--- a/netbox/templates/tenancy/tenant.html
+++ b/netbox/templates/tenancy/tenant.html
@@ -30,7 +30,6 @@ <h5 class="card-header">Tenant</h5>
       {% include 'inc/panels/custom_fields.html' %}
       {% include 'inc/panels/tags.html' %}
       {% include 'inc/panels/comments.html' %}
-      {% include 'inc/panels/contacts.html' %}
       {% plugin_left_page object %}
     </div>
     <div class="col col-md-5">
diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html
index 3dfef108be3..508bca547a9 100644
--- a/netbox/templates/virtualization/cluster.html
+++ b/netbox/templates/virtualization/cluster.html
@@ -84,7 +84,6 @@ <h5 class="card-header">Allocated Resources</h5>
   </div>
     {% include 'inc/panels/custom_fields.html' %}
     {% include 'inc/panels/tags.html' %}
-    {% include 'inc/panels/contacts.html' %}
     {% plugin_right_page object %}
   </div>
 </div>
diff --git a/netbox/templates/virtualization/clustergroup.html b/netbox/templates/virtualization/clustergroup.html
index 5104330689b..2496ad08522 100644
--- a/netbox/templates/virtualization/clustergroup.html
+++ b/netbox/templates/virtualization/clustergroup.html
@@ -37,7 +37,6 @@ <h5 class="card-header">
 	<div class="col col-md-6">
     {% include 'inc/panels/related_objects.html' %}
     {% include 'inc/panels/custom_fields.html' %}
-    {% include 'inc/panels/contacts.html' %}
     {% plugin_right_page object %}
   </div>
 </div>
diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html
index 5098a2f8ffe..51fd8aa806e 100644
--- a/netbox/templates/virtualization/virtualmachine.html
+++ b/netbox/templates/virtualization/virtualmachine.html
@@ -158,7 +158,6 @@ <h5 class="card-header">Services</h5>
             </div>
           {% endif %}
         </div>
-        {% include 'inc/panels/contacts.html' %}
         {% plugin_right_page object %}
     </div>
 </div>
diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py
index b9ada8640d5..1faeabe8b97 100644
--- a/netbox/tenancy/views.py
+++ b/netbox/tenancy/views.py
@@ -7,17 +7,35 @@
 from ipam.models import Aggregate, ASN, IPAddress, IPRange, L2VPN, Prefix, VLAN, VRF
 from netbox.views import generic
 from utilities.utils import count_related
-from utilities.views import register_model_view
+from utilities.views import register_model_view, ViewTab
 from virtualization.models import VirtualMachine, Cluster
 from wireless.models import WirelessLAN, WirelessLink
 from . import filtersets, forms, tables
 from .models import *
 
 
+class ChildContactView(generic.ObjectChildrenView):
+    child_model = Contact
+    table = tables.ContactTable
+    filterset = filtersets.ContactFilterSet
+    template_name = 'tenancy/contact_children.html'
+    tab = ViewTab(
+        label=_('Contacts'),
+        badge=lambda obj: obj.contacts.count(),
+        permission='tenancy.view_contact',
+        weight=5000
+    )
+
+    def get_children(self, request, parent):
+        return Contact.objects.annotate(
+            assignment_count=count_related(ContactAssignment, 'contact')
+        ).restrict(request.user, 'view').filter(assignments__object_id=parent.pk)
+
 #
 # Tenant groups
 #
 
+
 class TenantGroupListView(generic.ObjectListView):
     queryset = TenantGroup.objects.add_related_count(
         TenantGroup.objects.all(),
@@ -165,6 +183,11 @@ class TenantBulkDeleteView(generic.BulkDeleteView):
     table = tables.TenantTable
 
 
+@register_model_view(Tenant, 'contacts')
+class TenantContactsView(ChildContactView):
+    queryset = Tenant.objects.all()
+
+
 #
 # Contact groups
 #
@@ -342,11 +365,11 @@ class ContactBulkDeleteView(generic.BulkDeleteView):
     filterset = filtersets.ContactFilterSet
     table = tables.ContactTable
 
-
 #
 # Contact assignments
 #
 
+
 class ContactAssignmentListView(generic.ObjectListView):
     queryset = ContactAssignment.objects.all()
     filterset = filtersets.ContactAssignmentFilterSet
diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py
index 9014aa9dd3c..73ef379b371 100644
--- a/netbox/virtualization/views.py
+++ b/netbox/virtualization/views.py
@@ -9,9 +9,10 @@
 from dcim.models import Device
 from dcim.tables import DeviceTable
 from extras.views import ObjectConfigContextView
-from ipam.models import IPAddress, Service
+from ipam.models import IPAddress
 from ipam.tables import InterfaceVLANTable
 from netbox.views import generic
+from tenancy.views import ChildContactView
 from utilities.utils import count_related
 from utilities.views import ViewTab, register_model_view
 from . import filtersets, forms, tables
@@ -140,6 +141,11 @@ class ClusterGroupBulkDeleteView(generic.BulkDeleteView):
     table = tables.ClusterGroupTable
 
 
+@register_model_view(ClusterGroup, 'contacts')
+class ClusterGroupContactsView(ChildContactView):
+    queryset = ClusterGroup.objects.all()
+
+
 #
 # Clusters
 #
@@ -312,6 +318,11 @@ def post(self, request, pk):
         })
 
 
+@register_model_view(Cluster, 'contacts')
+class ClusterContactsView(ChildContactView):
+    queryset = Cluster.objects.all()
+
+
 #
 # Virtual machines
 #
@@ -390,6 +401,11 @@ class VirtualMachineBulkDeleteView(generic.BulkDeleteView):
     table = tables.VirtualMachineTable
 
 
+@register_model_view(VirtualMachine, 'contacts')
+class VirtualMachineContactsView(ChildContactView):
+    queryset = VirtualMachine.objects.all()
+
+
 #
 # VM interfaces
 #


Code review comments:
diff_hunk: @@ -7,17 +7,35 @@
 from ipam.models import Aggregate, ASN, IPAddress, IPRange, L2VPN, Prefix, VLAN, VRF
 from netbox.views import generic
 from utilities.utils import count_related
-from utilities.views import register_model_view
+from utilities.views import register_model_view, ViewTab
 from virtualization.models import VirtualMachine, Cluster
 from wireless.models import WirelessLAN, WirelessLink
 from . import filtersets, forms, tables
 from .models import *
 
 
+class ChildContactView(generic.ObjectChildrenView):
+    child_model = Contact
+    table = tables.ContactTable
+    filterset = filtersets.ContactFilterSet
+    template_name = 'tenancy/contact_children.html'
path: netbox/tenancy/views.py
text: @user1:
Similar to above, we should rename the template to `object_contacts.html`, to be like e.g. `extras/object_changelog.html`.

diff_hunk: @@ -7,17 +7,35 @@
 from ipam.models import Aggregate, ASN, IPAddress, IPRange, L2VPN, Prefix, VLAN, VRF
 from netbox.views import generic
 from utilities.utils import count_related
-from utilities.views import register_model_view
+from utilities.views import register_model_view, ViewTab
 from virtualization.models import VirtualMachine, Cluster
 from wireless.models import WirelessLAN, WirelessLink
 from . import filtersets, forms, tables
 from .models import *
 
 
+class ChildContactView(generic.ObjectChildrenView):
path: netbox/tenancy/views.py
text: @user1:
We should rename this to `ObjectContactsView` to be consistent with similar view classes.