Преглед изворни кода

15794 Make "related objects" dynamic (#15876)

* Closes #15794: Make "related objects" dynamic

Instead of hardcoding relationships between models for the detail view,
they are now dynamically generated.

* Fix related models call

* Remove extra related models hook

Instead of providing a rarely used hook method, additional related
models can now be passed directly to the lookup method.

* Fix relations view for ASNs

ASNs have ManyToMany relationships and therefore can't used automatic
resolving. Explicit relations have been restored as before.

* Add method call keywords for clarification

* Cleanup related models

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
Alexander Haase пре 1 година
родитељ
комит
5353f83710

+ 18 - 29
netbox/circuits/views.py

@@ -7,7 +7,7 @@ from netbox.views import generic
 from tenancy.views import ObjectContactsView
 from utilities.forms import ConfirmationForm
 from utilities.query import count_related
-from utilities.views import register_model_view
+from utilities.views import GetRelatedModelsMixin, register_model_view
 from . import filtersets, forms, tables
 from .models import *
 
@@ -26,17 +26,12 @@ class ProviderListView(generic.ObjectListView):
 
 
 @register_model_view(Provider)
-class ProviderView(generic.ObjectView):
+class ProviderView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = Provider.objects.all()
 
     def get_extra_context(self, request, instance):
-        related_models = (
-            (ProviderAccount.objects.restrict(request.user, 'view').filter(provider=instance), 'provider_id'),
-            (Circuit.objects.restrict(request.user, 'view').filter(provider=instance), 'provider_id'),
-        )
-
         return {
-            'related_models': related_models,
+            'related_models': self.get_related_models(request, instance),
         }
 
 
@@ -92,16 +87,12 @@ class ProviderAccountListView(generic.ObjectListView):
 
 
 @register_model_view(ProviderAccount)
-class ProviderAccountView(generic.ObjectView):
+class ProviderAccountView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = ProviderAccount.objects.all()
 
     def get_extra_context(self, request, instance):
-        related_models = (
-            (Circuit.objects.restrict(request.user, 'view').filter(provider_account=instance), 'provider_account_id'),
-        )
-
         return {
-            'related_models': related_models,
+            'related_models': self.get_related_models(request, instance),
         }
 
 
@@ -156,19 +147,21 @@ class ProviderNetworkListView(generic.ObjectListView):
 
 
 @register_model_view(ProviderNetwork)
-class ProviderNetworkView(generic.ObjectView):
+class ProviderNetworkView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = ProviderNetwork.objects.all()
 
     def get_extra_context(self, request, instance):
-        related_models = (
-            (
-                Circuit.objects.restrict(request.user, 'view').filter(terminations__provider_network=instance),
-                'provider_network_id',
-            ),
-        )
-
         return {
-            'related_models': related_models,
+            'related_models': self.get_related_models(
+                request,
+                instance,
+                extra=(
+                    (
+                        Circuit.objects.restrict(request.user, 'view').filter(terminations__provider_network=instance),
+                        'provider_network_id',
+                    ),
+                ),
+            ),
         }
 
 
@@ -215,16 +208,12 @@ class CircuitTypeListView(generic.ObjectListView):
 
 
 @register_model_view(CircuitType)
-class CircuitTypeView(generic.ObjectView):
+class CircuitTypeView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = CircuitType.objects.all()
 
     def get_extra_context(self, request, instance):
-        related_models = (
-            (Circuit.objects.restrict(request.user, 'view').filter(type=instance), 'type_id'),
-        )
-
         return {
-            'related_models': related_models,
+            'related_models': self.get_related_models(request, instance),
         }
 
 

+ 3 - 7
netbox/core/views.py

@@ -32,7 +32,7 @@ from netbox.views.generic.mixins import TableMixin
 from utilities.forms import ConfirmationForm
 from utilities.htmx import htmx_partial
 from utilities.query import count_related
-from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
+from utilities.views import ContentTypePermissionRequiredMixin, GetRelatedModelsMixin, register_model_view
 from . import filtersets, forms, tables
 from .models import *
 
@@ -51,16 +51,12 @@ class DataSourceListView(generic.ObjectListView):
 
 
 @register_model_view(DataSource)
-class DataSourceView(generic.ObjectView):
+class DataSourceView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = DataSource.objects.all()
 
     def get_extra_context(self, request, instance):
-        related_models = (
-            (DataFile.objects.restrict(request.user, 'view').filter(source=instance), 'source_id'),
-        )
-
         return {
-            'related_models': related_models,
+            'related_models': self.get_related_models(request, instance),
         }
 
 

+ 73 - 116
netbox/dcim/views.py

@@ -17,7 +17,7 @@ from jinja2.exceptions import TemplateError
 
 from circuits.models import Circuit, CircuitTermination
 from extras.views import ObjectConfigContextView
-from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup
+from ipam.models import ASN, IPAddress, VLANGroup
 from ipam.tables import InterfaceVLANTable
 from netbox.constants import DEFAULT_ACTION_PERMISSIONS
 from netbox.views import generic
@@ -27,7 +27,9 @@ from utilities.paginator import EnhancedPaginator, get_paginate_count
 from utilities.permissions import get_permission_for_model
 from utilities.query import count_related
 from utilities.query_functions import CollateAsChar
-from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view
+from utilities.views import (
+    GetRelatedModelsMixin, GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view
+)
 from virtualization.filtersets import VirtualMachineFilterSet
 from virtualization.models import VirtualMachine
 from virtualization.tables import VirtualMachineTable
@@ -226,19 +228,21 @@ class RegionListView(generic.ObjectListView):
 
 
 @register_model_view(Region)
-class RegionView(generic.ObjectView):
+class RegionView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = Region.objects.all()
 
     def get_extra_context(self, request, instance):
         regions = instance.get_descendants(include_self=True)
-        related_models = (
-            (Site.objects.restrict(request.user, 'view').filter(region__in=regions), 'region_id'),
-            (Location.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
-            (Rack.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
-        )
 
         return {
-            'related_models': related_models,
+            'related_models': self.get_related_models(
+                request,
+                regions,
+                extra=(
+                    (Location.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
+                    (Rack.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
+                ),
+            ),
         }
 
 
@@ -306,19 +310,21 @@ class SiteGroupListView(generic.ObjectListView):
 
 
 @register_model_view(SiteGroup)
-class SiteGroupView(generic.ObjectView):
+class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = SiteGroup.objects.all()
 
     def get_extra_context(self, request, instance):
         groups = instance.get_descendants(include_self=True)
-        related_models = (
-            (Site.objects.restrict(request.user, 'view').filter(group__in=groups), 'group_id'),
-            (Location.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
-            (Rack.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
-        )
 
         return {
-            'related_models': related_models,
+            'related_models': self.get_related_models(
+                request,
+                groups,
+                extra=(
+                    (Location.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
+                    (Rack.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
+                ),
+            ),
         }
 
 
@@ -380,31 +386,25 @@ class SiteListView(generic.ObjectListView):
 
 
 @register_model_view(Site)
-class SiteView(generic.ObjectView):
+class SiteView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = Site.objects.prefetch_related('tenant__group')
 
     def get_extra_context(self, request, instance):
-        related_models = (
-            # DCIM
-            (Location.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'),
-            (Rack.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'),
-            (Device.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'),
-            # Virtualization
-            (VirtualMachine.objects.restrict(request.user, 'view').filter(cluster__site=instance), 'site_id'),
-            # IPAM
-            (Prefix.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'),
-            (ASN.objects.restrict(request.user, 'view').filter(sites=instance), 'site_id'),
-            (VLANGroup.objects.restrict(request.user, 'view').filter(
-                scope_type=ContentType.objects.get_for_model(Site),
-                scope_id=instance.pk
-            ), 'site'),
-            (VLAN.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'),
-            # Circuits
-            (Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).distinct(), 'site_id'),
-        )
-
         return {
-            'related_models': related_models,
+            'related_models': self.get_related_models(
+                request,
+                instance,
+                [CableTermination, CircuitTermination],
+                (
+                    (VLANGroup.objects.restrict(request.user, 'view').filter(
+                        scope_type=ContentType.objects.get_for_model(Site),
+                        scope_id=instance.pk
+                    ), 'site'),
+                    (ASN.objects.restrict(request.user, 'view').filter(sites=instance), 'site_id'),
+                    (Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).distinct(),
+                     'site_id'),
+                ),
+            ),
         }
 
 
@@ -466,18 +466,13 @@ class LocationListView(generic.ObjectListView):
 
 
 @register_model_view(Location)
-class LocationView(generic.ObjectView):
+class LocationView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = Location.objects.all()
 
     def get_extra_context(self, request, instance):
         locations = instance.get_descendants(include_self=True)
-        related_models = (
-            (Rack.objects.restrict(request.user, 'view').filter(location__in=locations), 'location_id'),
-            (Device.objects.restrict(request.user, 'view').filter(location__in=locations), 'location_id'),
-        )
-
         return {
-            'related_models': related_models,
+            'related_models': self.get_related_models(request, locations, [CableTermination]),
         }
 
 
@@ -541,16 +536,12 @@ class RackRoleListView(generic.ObjectListView):
 
 
 @register_model_view(RackRole)
-class RackRoleView(generic.ObjectView):
+class RackRoleView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = RackRole.objects.all()
 
     def get_extra_context(self, request, instance):
-        related_models = (
-            (Rack.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'),
-        )
-
         return {
-            'related_models': related_models,
+            'related_models': self.get_related_models(request, instance),
         }
 
 
@@ -655,15 +646,10 @@ class RackElevationListView(generic.ObjectListView):
 
 
 @register_model_view(Rack)
-class RackView(generic.ObjectView):
+class RackView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'location', 'role')
 
     def get_extra_context(self, request, instance):
-        related_models = (
-            (Device.objects.restrict(request.user, 'view').filter(rack=instance), 'rack_id'),
-            (PowerFeed.objects.restrict(request.user).filter(rack=instance), 'rack_id'),
-        )
-
         peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site)
 
         if instance.location:
@@ -679,7 +665,7 @@ class RackView(generic.ObjectView):
         ])
 
         return {
-            'related_models': related_models,
+            'related_models': self.get_related_models(request, instance, [CableTermination]),
             'next_rack': next_rack,
             'prev_rack': prev_rack,
             'svg_extra': svg_extra,
@@ -838,19 +824,12 @@ class ManufacturerListView(generic.ObjectListView):
 
 
 @register_model_view(Manufacturer)
-class ManufacturerView(generic.ObjectView):
+class ManufacturerView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = Manufacturer.objects.all()
 
     def get_extra_context(self, request, instance):
-        related_models = (
-            (DeviceType.objects.restrict(request.user, 'view').filter(manufacturer=instance), 'manufacturer_id'),
-            (ModuleType.objects.restrict(request.user, 'view').filter(manufacturer=instance), 'manufacturer_id'),
-            (InventoryItem.objects.restrict(request.user, 'view').filter(manufacturer=instance), 'manufacturer_id'),
-            (Platform.objects.restrict(request.user, 'view').filter(manufacturer=instance), 'manufacturer_id'),
-        )
-
         return {
-            'related_models': related_models,
+            'related_models': self.get_related_models(request, instance, [InventoryItemTemplate]),
         }
 
 
@@ -912,16 +891,16 @@ class DeviceTypeListView(generic.ObjectListView):
 
 
 @register_model_view(DeviceType)
-class DeviceTypeView(generic.ObjectView):
+class DeviceTypeView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = DeviceType.objects.all()
 
     def get_extra_context(self, request, instance):
-        related_models = (
-            (Device.objects.restrict(request.user).filter(device_type=instance), 'device_type_id'),
-        )
-
         return {
-            'related_models': related_models,
+            'related_models': self.get_related_models(request, instance, omit=[
+                ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate,
+                InventoryItemTemplate, InterfaceTemplate, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate,
+                RearPortTemplate,
+            ]),
         }
 
 
@@ -1151,16 +1130,16 @@ class ModuleTypeListView(generic.ObjectListView):
 
 
 @register_model_view(ModuleType)
-class ModuleTypeView(generic.ObjectView):
+class ModuleTypeView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = ModuleType.objects.all()
 
     def get_extra_context(self, request, instance):
-        related_models = (
-            (Module.objects.restrict(request.user).filter(module_type=instance), 'module_type_id'),
-        )
-
         return {
-            'related_models': related_models,
+            'related_models': self.get_related_models(request, instance, omit=[
+                ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate,
+                InventoryItemTemplate, InterfaceTemplate, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate,
+                RearPortTemplate,
+            ]),
         }
 
 
@@ -1711,17 +1690,12 @@ class DeviceRoleListView(generic.ObjectListView):
 
 
 @register_model_view(DeviceRole)
-class DeviceRoleView(generic.ObjectView):
+class DeviceRoleView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = DeviceRole.objects.all()
 
     def get_extra_context(self, request, instance):
-        related_models = (
-            (Device.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'),
-            (VirtualMachine.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'),
-        )
-
         return {
-            'related_models': related_models,
+            'related_models': self.get_related_models(request, instance),
         }
 
 
@@ -1775,17 +1749,12 @@ class PlatformListView(generic.ObjectListView):
 
 
 @register_model_view(Platform)
-class PlatformView(generic.ObjectView):
+class PlatformView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = Platform.objects.all()
 
     def get_extra_context(self, request, instance):
-        related_models = (
-            (Device.objects.restrict(request.user, 'view').filter(platform=instance), 'platform_id'),
-            (VirtualMachine.objects.restrict(request.user, 'view').filter(platform=instance), 'platform_id'),
-        )
-
         return {
-            'related_models': related_models,
+            'related_models': self.get_related_models(request, instance),
         }
 
 
@@ -2157,22 +2126,12 @@ class ModuleListView(generic.ObjectListView):
 
 
 @register_model_view(Module)
-class ModuleView(generic.ObjectView):
+class ModuleView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = Module.objects.all()
 
     def get_extra_context(self, request, instance):
-        related_models = (
-            (Interface.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'),
-            (ConsolePort.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'),
-            (ConsoleServerPort.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'),
-            (PowerPort.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'),
-            (PowerOutlet.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'),
-            (FrontPort.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'),
-            (RearPort.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'),
-        )
-
         return {
-            'related_models': related_models,
+            'related_models': self.get_related_models(request, instance),
         }
 
 
@@ -3552,16 +3511,12 @@ class PowerPanelListView(generic.ObjectListView):
 
 
 @register_model_view(PowerPanel)
-class PowerPanelView(generic.ObjectView):
+class PowerPanelView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = PowerPanel.objects.all()
 
     def get_extra_context(self, request, instance):
-        related_models = (
-            (PowerFeed.objects.restrict(request.user).filter(power_panel=instance), 'power_panel_id'),
-        )
-
         return {
-            'related_models': related_models,
+            'related_models': self.get_related_models(request, instance),
         }
 
 
@@ -3665,16 +3620,18 @@ class VirtualDeviceContextListView(generic.ObjectListView):
 
 
 @register_model_view(VirtualDeviceContext)
-class VirtualDeviceContextView(generic.ObjectView):
+class VirtualDeviceContextView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = VirtualDeviceContext.objects.all()
 
     def get_extra_context(self, request, instance):
-        related_models = (
-            (Interface.objects.restrict(request.user, 'view').filter(vdcs__in=[instance]), 'vdc_id'),
-        )
-
         return {
-            'related_models': related_models,
+            'related_models': self.get_related_models(
+                request,
+                instance,
+                extra=(
+                    (Interface.objects.restrict(request.user, 'view').filter(vdcs__in=[instance]), 'vdc_id'),
+                ),
+            ),
         }
 
 

+ 18 - 35
netbox/ipam/views.py

@@ -12,7 +12,7 @@ from netbox.views import generic
 from tenancy.views import ObjectContactsView
 from utilities.query import count_related
 from utilities.tables import get_table_ordering
-from utilities.views import ViewTab, register_model_view
+from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view
 from virtualization.filtersets import VMInterfaceFilterSet
 from virtualization.models import VMInterface
 from . import filtersets, forms, tables
@@ -34,15 +34,10 @@ class VRFListView(generic.ObjectListView):
 
 
 @register_model_view(VRF)
-class VRFView(generic.ObjectView):
+class VRFView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = VRF.objects.all()
 
     def get_extra_context(self, request, instance):
-        related_models = (
-            (Prefix.objects.restrict(request.user, 'view').filter(vrf=instance), 'vrf_id'),
-            (IPAddress.objects.restrict(request.user, 'view').filter(vrf=instance), 'vrf_id'),
-        )
-
         import_targets_table = tables.RouteTargetTable(
             instance.import_targets.all(),
             orderable=False
@@ -53,7 +48,7 @@ class VRFView(generic.ObjectView):
         )
 
         return {
-            'related_models': related_models,
+            'related_models': self.get_related_models(request, instance, omit=[Interface, VMInterface]),
             'import_targets_table': import_targets_table,
             'export_targets_table': export_targets_table,
         }
@@ -147,16 +142,12 @@ class RIRListView(generic.ObjectListView):
 
 
 @register_model_view(RIR)
-class RIRView(generic.ObjectView):
+class RIRView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = RIR.objects.all()
 
     def get_extra_context(self, request, instance):
-        related_models = (
-            (Aggregate.objects.restrict(request.user, 'view').filter(rir=instance), 'rir_id'),
-        )
-
         return {
-            'related_models': related_models,
+            'related_models': self.get_related_models(request, instance),
         }
 
 
@@ -273,17 +264,19 @@ class ASNListView(generic.ObjectListView):
 
 
 @register_model_view(ASN)
-class ASNView(generic.ObjectView):
+class ASNView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = ASN.objects.all()
 
     def get_extra_context(self, request, instance):
-        related_models = (
-            (Site.objects.restrict(request.user, 'view').filter(asns__in=[instance]), 'asn_id'),
-            (Provider.objects.restrict(request.user, 'view').filter(asns__in=[instance]), 'asn_id'),
-        )
-
         return {
-            'related_models': related_models,
+            'related_models': self.get_related_models(
+                request,
+                instance,
+                extra=(
+                    (Site.objects.restrict(request.user, 'view').filter(asns__in=[instance]), 'asn_id'),
+                    (Provider.objects.restrict(request.user, 'view').filter(asns__in=[instance]), 'asn_id'),
+                ),
+            ),
         }
 
 
@@ -427,18 +420,12 @@ class RoleListView(generic.ObjectListView):
 
 
 @register_model_view(Role)
-class RoleView(generic.ObjectView):
+class RoleView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = Role.objects.all()
 
     def get_extra_context(self, request, instance):
-        related_models = (
-            (Prefix.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'),
-            (IPRange.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'),
-            (VLAN.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'),
-        )
-
         return {
-            'related_models': related_models,
+            'related_models': self.get_related_models(request, instance),
         }
 
 
@@ -926,16 +913,12 @@ class VLANGroupListView(generic.ObjectListView):
 
 
 @register_model_view(VLANGroup)
-class VLANGroupView(generic.ObjectView):
+class VLANGroupView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
 
     def get_extra_context(self, request, instance):
-        related_models = (
-            (VLAN.objects.restrict(request.user, 'view').filter(group=instance), 'group_id'),
-        )
-
         return {
-            'related_models': related_models,
+            'related_models': self.get_related_models(request, instance),
         }
 
 

+ 9 - 25
netbox/tenancy/views.py

@@ -4,8 +4,7 @@ from django.utils.translation import gettext as _
 
 from netbox.views import generic
 from utilities.query import count_related
-from utilities.relations import get_related_models
-from utilities.views import register_model_view, ViewTab
+from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view
 from . import filtersets, forms, tables
 from .models import *
 
@@ -56,17 +55,14 @@ class TenantGroupListView(generic.ObjectListView):
 
 
 @register_model_view(TenantGroup)
-class TenantGroupView(generic.ObjectView):
+class TenantGroupView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = TenantGroup.objects.all()
 
     def get_extra_context(self, request, instance):
         groups = instance.get_descendants(include_self=True)
-        related_models = (
-            (Tenant.objects.restrict(request.user, 'view').filter(group__in=groups), 'group_id'),
-        )
 
         return {
-            'related_models': related_models,
+            'related_models': self.get_related_models(request, groups),
         }
 
 
@@ -123,17 +119,12 @@ class TenantListView(generic.ObjectListView):
 
 
 @register_model_view(Tenant)
-class TenantView(generic.ObjectView):
+class TenantView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = Tenant.objects.all()
 
     def get_extra_context(self, request, instance):
-        related_models = [
-            (model.objects.restrict(request.user, 'view').filter(tenant=instance), f'{field}_id')
-            for model, field in get_related_models(Tenant)
-        ]
-
         return {
-            'related_models': related_models,
+            'related_models': self.get_related_models(request, instance),
         }
 
 
@@ -189,17 +180,14 @@ class ContactGroupListView(generic.ObjectListView):
 
 
 @register_model_view(ContactGroup)
-class ContactGroupView(generic.ObjectView):
+class ContactGroupView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = ContactGroup.objects.all()
 
     def get_extra_context(self, request, instance):
         groups = instance.get_descendants(include_self=True)
-        related_models = (
-            (Contact.objects.restrict(request.user, 'view').filter(group__in=groups), 'group_id'),
-        )
 
         return {
-            'related_models': related_models,
+            'related_models': self.get_related_models(request, groups),
         }
 
 
@@ -256,16 +244,12 @@ class ContactRoleListView(generic.ObjectListView):
 
 
 @register_model_view(ContactRole)
-class ContactRoleView(generic.ObjectView):
+class ContactRoleView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = ContactRole.objects.all()
 
     def get_extra_context(self, request, instance):
-        related_models = (
-            (ContactAssignment.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'),
-        )
-
         return {
-            'related_models': related_models,
+            'related_models': self.get_related_models(request, instance),
         }
 
 

+ 44 - 0
netbox/utilities/views.py

@@ -1,3 +1,5 @@
+from typing import Iterable
+
 from django.contrib.auth.mixins import AccessMixin
 from django.core.exceptions import ImproperlyConfigured
 from django.urls import reverse
@@ -6,10 +8,12 @@ from django.utils.translation import gettext_lazy as _
 
 from netbox.plugins import PluginConfig
 from netbox.registry import registry
+from utilities.relations import get_related_models
 from .permissions import resolve_permission
 
 __all__ = (
     'ContentTypePermissionRequiredMixin',
+    'GetRelatedModelsMixin',
     'GetReturnURLMixin',
     'ObjectPermissionRequiredMixin',
     'ViewTab',
@@ -142,6 +146,46 @@ class GetReturnURLMixin:
         return reverse('home')
 
 
+class GetRelatedModelsMixin:
+    """
+    Provides logic for collecting all related models for the currently viewed model.
+    """
+
+    def get_related_models(self, request, instance, omit=[], extra=[]):
+        """
+        Get related models of the view's `queryset` model without those listed in `omit`. Will be sorted alphabetical.
+
+        Args:
+            request: Current request being processed.
+            instance: The instance related models should be looked up for. A list of instances can be passed to match
+                related objects in this list (e.g. to find sites of a region including child regions).
+            omit: Remove relationships to these models from the result. Needs to be passed, if related models don't
+                provide a `_list` view.
+            extra: Add extra models to the list of automatically determined related models. Can be used to add indirect
+                relationships.
+        """
+        model = self.queryset.model
+        related = filter(
+            lambda m: m[0] is not model and m[0] not in omit,
+            get_related_models(model, False)
+        )
+
+        related_models = [
+            (
+                model.objects.restrict(request.user, 'view').filter(**(
+                    {f'{field}__in': instance}
+                    if isinstance(instance, Iterable)
+                    else {field: instance}
+                )),
+                f'{field}_id'
+            )
+            for model, field in related
+        ]
+        related_models.extend(extra)
+
+        return sorted(related_models, key=lambda x: x[0].model._meta.verbose_name.lower())
+
+
 class ViewTab:
     """
     ViewTabs are used for navigation among multiple object-specific views, such as the changelog or journal for

+ 5 - 13
netbox/virtualization/views.py

@@ -20,7 +20,7 @@ from netbox.views import generic
 from tenancy.views import ObjectContactsView
 from utilities.query import count_related
 from utilities.query_functions import CollateAsChar
-from utilities.views import ViewTab, register_model_view
+from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view
 from . import filtersets, forms, tables
 from .models import *
 
@@ -39,16 +39,12 @@ class ClusterTypeListView(generic.ObjectListView):
 
 
 @register_model_view(ClusterType)
-class ClusterTypeView(generic.ObjectView):
+class ClusterTypeView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = ClusterType.objects.all()
 
     def get_extra_context(self, request, instance):
-        related_models = (
-            (Cluster.objects.restrict(request.user, 'view').filter(type=instance), 'type_id'),
-        )
-
         return {
-            'related_models': related_models,
+            'related_models': self.get_related_models(request, instance),
         }
 
 
@@ -99,16 +95,12 @@ class ClusterGroupListView(generic.ObjectListView):
 
 
 @register_model_view(ClusterGroup)
-class ClusterGroupView(generic.ObjectView):
+class ClusterGroupView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = ClusterGroup.objects.all()
 
     def get_extra_context(self, request, instance):
-        related_models = (
-            (Cluster.objects.restrict(request.user, 'view').filter(group=instance), 'group_id'),
-        )
-
         return {
-            'related_models': related_models,
+            'related_models': self.get_related_models(request, instance),
         }
 
 

+ 3 - 7
netbox/vpn/views.py

@@ -2,7 +2,7 @@ from ipam.tables import RouteTargetTable
 from netbox.views import generic
 from tenancy.views import ObjectContactsView
 from utilities.query import count_related
-from utilities.views import register_model_view
+from utilities.views import GetRelatedModelsMixin, register_model_view
 from . import filtersets, forms, tables
 from .models import *
 
@@ -21,16 +21,12 @@ class TunnelGroupListView(generic.ObjectListView):
 
 
 @register_model_view(TunnelGroup)
-class TunnelGroupView(generic.ObjectView):
+class TunnelGroupView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = TunnelGroup.objects.all()
 
     def get_extra_context(self, request, instance):
-        related_models = (
-            (Tunnel.objects.restrict(request.user, 'view').filter(group=instance), 'group_id'),
-        )
-
         return {
-            'related_models': related_models,
+            'related_models': self.get_related_models(request, instance),
         }
 
 

+ 3 - 6
netbox/wireless/views.py

@@ -1,7 +1,7 @@
 from dcim.models import Interface
 from netbox.views import generic
 from utilities.query import count_related
-from utilities.views import register_model_view
+from utilities.views import GetRelatedModelsMixin, register_model_view
 from . import filtersets, forms, tables
 from .models import *
 
@@ -24,17 +24,14 @@ class WirelessLANGroupListView(generic.ObjectListView):
 
 
 @register_model_view(WirelessLANGroup)
-class WirelessLANGroupView(generic.ObjectView):
+class WirelessLANGroupView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = WirelessLANGroup.objects.all()
 
     def get_extra_context(self, request, instance):
         groups = instance.get_descendants(include_self=True)
-        related_models = (
-            (WirelessLAN.objects.restrict(request.user, 'view').filter(group__in=groups), 'group_id'),
-        )
 
         return {
-            'related_models': related_models,
+            'related_models': self.get_related_models(request, groups),
         }