Repository: netbox-community/netbox

Issue:
Make the "Related Objects" pane in the detail view for "Tenant" extensible
### NetBox version

v3.6.1

### Feature type

Change to existing functionality

### Proposed functionality

On the right side of the detail view for a "Tenant" object there is a list of NetBox core models and object counts the tenant is assigned to. Currently the list of models in this view is hardcoded in `tenancy.TenantView.get_extra_content()`.

It would be helpful for plugins supporting tenancy if that list could be extended, e.g. by providing a decorator that could be applied to models using tenancy similar to the mechanism used for the global search index.

### Use case

In many cases it makes sense for plugins to use tenancy to indicate the ownership of or responsibility for objects, making it easier to filter for objects related, for instance, to a specific customer or department. 

While tenancy itself is easy to implement, this does not reflect in the list of related objects for a tenant. The detail view for a tenant only shows NetBox core objects that are assigned to the tenant, not objects of plugin models. 

This is especially annoying when a user wants to delete a tenant object, which is usually related to the target object using the referential integrity option `models.PROTECT`. So even if there are no related objects shown in the tenant view, the tenant still can't be deleted because there are unlisted objects still owned by it in the plugin's models.

### Database changes

Unknown, probably none - depending on how this can be implemented.

### External dependencies

None.


Discussion:
> this does not reflect in the list of related objects for a tenant.

You can use the template content or tab view to inject the additional views which will help with your use case.
Yes, adding a tab or an additional pane via template content (eventually it became the latter as that's less diverting from the original contents of the view) was actually what I had in mind as a workaround. 
Rather than requiring other models to register explicitly, we should be able to infer many-to-one relationships from the Tenant model, and display the object count for each.

Resolving patch:
diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py
--- a/netbox/tenancy/views.py
+++ b/netbox/tenancy/views.py
@@ -2,14 +2,9 @@
 from django.shortcuts import get_object_or_404
 from django.utils.translation import gettext as _
 
-from circuits.models import Circuit
-from dcim.models import Cable, Device, Location, PowerFeed, Rack, RackReservation, Site, VirtualDeviceContext
-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.utils import count_related, get_related_models
 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 *
 
@@ -132,32 +127,8 @@ class TenantView(generic.ObjectView):
 
     def get_extra_context(self, request, instance):
         related_models = [
-            # DCIM
-            (Site.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
-            (Rack.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
-            (RackReservation.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
-            (Location.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
-            (Device.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
-            (VirtualDeviceContext.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
-            (Cable.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
-            (PowerFeed.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
-            # IPAM
-            (VRF.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
-            (Aggregate.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
-            (Prefix.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
-            (IPRange.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
-            (IPAddress.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
-            (ASN.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
-            (VLAN.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
-            (L2VPN.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
-            # Circuits
-            (Circuit.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
-            # Virtualization
-            (VirtualMachine.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
-            (Cluster.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
-            # Wireless
-            (WirelessLAN.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
-            (WirelessLink.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
+            (model.objects.restrict(request.user, 'view').filter(tenant=instance), f'{field}_id')
+            for model, field in get_related_models(Tenant)
         ]
 
         return {
diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py
--- a/netbox/utilities/utils.py
+++ b/netbox/utilities/utils.py
@@ -8,7 +8,7 @@
 import bleach
 from django.contrib.contenttypes.models import ContentType
 from django.core import serializers
-from django.db.models import Count, OuterRef, Subquery
+from django.db.models import Count, ManyToOneRel, OuterRef, Subquery
 from django.db.models.functions import Coalesce
 from django.http import QueryDict
 from django.utils import timezone
@@ -567,3 +567,20 @@ def local_now():
     Return the current date & time in the system timezone.
     """
     return localtime(timezone.now())
+
+
+def get_related_models(model, ordered=True):
+    """
+    Return a list of all models which have a ForeignKey to the given model and the name of the field. For example,
+    `get_related_models(Tenant)` will return all models which have a ForeignKey relationship to Tenant.
+    """
+    related_models = [
+        (field.related_model, field.remote_field.name)
+        for field in model._meta.related_objects
+        if type(field) is ManyToOneRel
+    ]
+
+    if ordered:
+        return sorted(related_models, key=lambda x: x[0]._meta.verbose_name)
+
+    return related_models


Test patch: