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

Wrap model detail views with register_model_view()

jeremystretch 3 лет назад
Родитель
Сommit
bfe26b46a6

+ 0 - 27
netbox/dcim/urls.py

@@ -105,16 +105,6 @@ urlpatterns = [
     path('device-types/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'),
     path('device-types/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'),
     path('device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'),
     path('device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'),
     path('device-types/<int:pk>/', views.DeviceTypeView.as_view(), name='devicetype'),
     path('device-types/<int:pk>/', views.DeviceTypeView.as_view(), name='devicetype'),
-    path('device-types/<int:pk>/console-ports/', views.DeviceTypeConsolePortsView.as_view(), name='devicetype_consoleports'),
-    path('device-types/<int:pk>/console-server-ports/', views.DeviceTypeConsoleServerPortsView.as_view(), name='devicetype_consoleserverports'),
-    path('device-types/<int:pk>/power-ports/', views.DeviceTypePowerPortsView.as_view(), name='devicetype_powerports'),
-    path('device-types/<int:pk>/power-outlets/', views.DeviceTypePowerOutletsView.as_view(), name='devicetype_poweroutlets'),
-    path('device-types/<int:pk>/interfaces/', views.DeviceTypeInterfacesView.as_view(), name='devicetype_interfaces'),
-    path('device-types/<int:pk>/front-ports/', views.DeviceTypeFrontPortsView.as_view(), name='devicetype_frontports'),
-    path('device-types/<int:pk>/rear-ports/', views.DeviceTypeRearPortsView.as_view(), name='devicetype_rearports'),
-    path('device-types/<int:pk>/module-bays/', views.DeviceTypeModuleBaysView.as_view(), name='devicetype_modulebays'),
-    path('device-types/<int:pk>/device-bays/', views.DeviceTypeDeviceBaysView.as_view(), name='devicetype_devicebays'),
-    path('device-types/<int:pk>/inventory-items/', views.DeviceTypeInventoryItemsView.as_view(), name='devicetype_inventoryitems'),
     path('device-types/<int:pk>/edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'),
     path('device-types/<int:pk>/edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'),
     path('device-types/<int:pk>/delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'),
     path('device-types/<int:pk>/delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'),
     path('device-types/<int:pk>/', include(get_model_urls('dcim', 'devicetype'))),
     path('device-types/<int:pk>/', include(get_model_urls('dcim', 'devicetype'))),
@@ -126,13 +116,6 @@ urlpatterns = [
     path('module-types/edit/', views.ModuleTypeBulkEditView.as_view(), name='moduletype_bulk_edit'),
     path('module-types/edit/', views.ModuleTypeBulkEditView.as_view(), name='moduletype_bulk_edit'),
     path('module-types/delete/', views.ModuleTypeBulkDeleteView.as_view(), name='moduletype_bulk_delete'),
     path('module-types/delete/', views.ModuleTypeBulkDeleteView.as_view(), name='moduletype_bulk_delete'),
     path('module-types/<int:pk>/', views.ModuleTypeView.as_view(), name='moduletype'),
     path('module-types/<int:pk>/', views.ModuleTypeView.as_view(), name='moduletype'),
-    path('module-types/<int:pk>/console-ports/', views.ModuleTypeConsolePortsView.as_view(), name='moduletype_consoleports'),
-    path('module-types/<int:pk>/console-server-ports/', views.ModuleTypeConsoleServerPortsView.as_view(), name='moduletype_consoleserverports'),
-    path('module-types/<int:pk>/power-ports/', views.ModuleTypePowerPortsView.as_view(), name='moduletype_powerports'),
-    path('module-types/<int:pk>/power-outlets/', views.ModuleTypePowerOutletsView.as_view(), name='moduletype_poweroutlets'),
-    path('module-types/<int:pk>/interfaces/', views.ModuleTypeInterfacesView.as_view(), name='moduletype_interfaces'),
-    path('module-types/<int:pk>/front-ports/', views.ModuleTypeFrontPortsView.as_view(), name='moduletype_frontports'),
-    path('module-types/<int:pk>/rear-ports/', views.ModuleTypeRearPortsView.as_view(), name='moduletype_rearports'),
     path('module-types/<int:pk>/edit/', views.ModuleTypeEditView.as_view(), name='moduletype_edit'),
     path('module-types/<int:pk>/edit/', views.ModuleTypeEditView.as_view(), name='moduletype_edit'),
     path('module-types/<int:pk>/delete/', views.ModuleTypeDeleteView.as_view(), name='moduletype_delete'),
     path('module-types/<int:pk>/delete/', views.ModuleTypeDeleteView.as_view(), name='moduletype_delete'),
     path('module-types/<int:pk>/', include(get_model_urls('dcim', 'moduletype'))),
     path('module-types/<int:pk>/', include(get_model_urls('dcim', 'moduletype'))),
@@ -250,17 +233,7 @@ urlpatterns = [
     path('devices/<int:pk>/', views.DeviceView.as_view(), name='device'),
     path('devices/<int:pk>/', views.DeviceView.as_view(), name='device'),
     path('devices/<int:pk>/edit/', views.DeviceEditView.as_view(), name='device_edit'),
     path('devices/<int:pk>/edit/', views.DeviceEditView.as_view(), name='device_edit'),
     path('devices/<int:pk>/delete/', views.DeviceDeleteView.as_view(), name='device_delete'),
     path('devices/<int:pk>/delete/', views.DeviceDeleteView.as_view(), name='device_delete'),
-    path('devices/<int:pk>/console-ports/', views.DeviceConsolePortsView.as_view(), name='device_consoleports'),
-    path('devices/<int:pk>/console-server-ports/', views.DeviceConsoleServerPortsView.as_view(), name='device_consoleserverports'),
-    path('devices/<int:pk>/power-ports/', views.DevicePowerPortsView.as_view(), name='device_powerports'),
-    path('devices/<int:pk>/power-outlets/', views.DevicePowerOutletsView.as_view(), name='device_poweroutlets'),
-    path('devices/<int:pk>/interfaces/', views.DeviceInterfacesView.as_view(), name='device_interfaces'),
-    path('devices/<int:pk>/front-ports/', views.DeviceFrontPortsView.as_view(), name='device_frontports'),
-    path('devices/<int:pk>/rear-ports/', views.DeviceRearPortsView.as_view(), name='device_rearports'),
-    path('devices/<int:pk>/module-bays/', views.DeviceModuleBaysView.as_view(), name='device_modulebays'),
-    path('devices/<int:pk>/device-bays/', views.DeviceDeviceBaysView.as_view(), name='device_devicebays'),
     path('devices/<int:pk>/inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'),
     path('devices/<int:pk>/inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'),
-    path('devices/<int:pk>/config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'),
     path('devices/<int:pk>/status/', views.DeviceStatusView.as_view(), name='device_status'),
     path('devices/<int:pk>/status/', views.DeviceStatusView.as_view(), name='device_status'),
     path('devices/<int:pk>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
     path('devices/<int:pk>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
     path('devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'),
     path('devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'),

+ 207 - 10
netbox/dcim/views.py

@@ -8,6 +8,7 @@ from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.html import escape
 from django.utils.html import escape
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
+from django.utils.translation import gettext as _
 from django.views.generic import View
 from django.views.generic import View
 
 
 from circuits.models import Circuit, CircuitTermination
 from circuits.models import Circuit, CircuitTermination
@@ -19,7 +20,7 @@ from utilities.forms import ConfirmationForm
 from utilities.paginator import EnhancedPaginator, get_paginate_count
 from utilities.paginator import EnhancedPaginator, get_paginate_count
 from utilities.permissions import get_permission_for_model
 from utilities.permissions import get_permission_for_model
 from utilities.utils import count_related
 from utilities.utils import count_related
-from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin
+from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view
 from virtualization.models import VirtualMachine
 from virtualization.models import VirtualMachine
 from . import filtersets, forms, tables
 from . import filtersets, forms, tables
 from .choices import DeviceFaceChoices
 from .choices import DeviceFaceChoices
@@ -47,7 +48,7 @@ class DeviceComponentsView(generic.ObjectChildrenView):
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         return {
         return {
-            'active_tab': f"{self.child_model._meta.verbose_name_plural.replace(' ', '-')}",
+            'active_tab': f"{self.child_model._meta.verbose_name_plural.replace(' ', '')}",
         }
         }
 
 
 
 
@@ -60,10 +61,11 @@ class DeviceTypeComponentsView(DeviceComponentsView):
         return self.child_model.objects.restrict(request.user, 'view').filter(device_type=parent)
         return self.child_model.objects.restrict(request.user, 'view').filter(device_type=parent)
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
-        context = super().get_extra_context(request, instance)
-        context['return_url'] = reverse(self.viewname, kwargs={'pk': instance.pk})
-
-        return context
+        model_name = self.child_model._meta.verbose_name_plural
+        return {
+            'active_tab': f"{model_name.replace(' ', '').replace('template', '')}",
+            'return_url': reverse(self.viewname, kwargs={'pk': instance.pk}),
+        }
 
 
 
 
 class ModuleTypeComponentsView(DeviceComponentsView):
 class ModuleTypeComponentsView(DeviceComponentsView):
@@ -75,10 +77,11 @@ class ModuleTypeComponentsView(DeviceComponentsView):
         return self.child_model.objects.restrict(request.user, 'view').filter(module_type=parent)
         return self.child_model.objects.restrict(request.user, 'view').filter(module_type=parent)
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
-        context = super().get_extra_context(request, instance)
-        context['return_url'] = reverse(self.viewname, kwargs={'pk': instance.pk})
-
-        return context
+        model_name = self.child_model._meta.verbose_name_plural
+        return {
+            'active_tab': f"{model_name.replace(' ', '').replace('template', '')}",
+            'return_url': reverse(self.viewname, kwargs={'pk': instance.pk}),
+        }
 
 
 
 
 class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
 class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
@@ -857,74 +860,144 @@ class DeviceTypeView(generic.ObjectView):
         }
         }
 
 
 
 
+@register_model_view(DeviceType, 'consoleports', path='console-ports')
 class DeviceTypeConsolePortsView(DeviceTypeComponentsView):
 class DeviceTypeConsolePortsView(DeviceTypeComponentsView):
     child_model = ConsolePortTemplate
     child_model = ConsolePortTemplate
     table = tables.ConsolePortTemplateTable
     table = tables.ConsolePortTemplateTable
     filterset = filtersets.ConsolePortTemplateFilterSet
     filterset = filtersets.ConsolePortTemplateFilterSet
     viewname = 'dcim:devicetype_consoleports'
     viewname = 'dcim:devicetype_consoleports'
+    tab = ViewTab(
+        label=_('Console Ports'),
+        badge=lambda obj: obj.consoleporttemplates.count(),
+        permission='dcim.view_consoleporttemplate',
+        always_display=False
+    )
 
 
 
 
+@register_model_view(DeviceType, 'consoleserverports', path='console-server-ports')
 class DeviceTypeConsoleServerPortsView(DeviceTypeComponentsView):
 class DeviceTypeConsoleServerPortsView(DeviceTypeComponentsView):
     child_model = ConsoleServerPortTemplate
     child_model = ConsoleServerPortTemplate
     table = tables.ConsoleServerPortTemplateTable
     table = tables.ConsoleServerPortTemplateTable
     filterset = filtersets.ConsoleServerPortTemplateFilterSet
     filterset = filtersets.ConsoleServerPortTemplateFilterSet
     viewname = 'dcim:devicetype_consoleserverports'
     viewname = 'dcim:devicetype_consoleserverports'
+    tab = ViewTab(
+        label=_('Console Server Ports'),
+        badge=lambda obj: obj.consoleserverporttemplates.count(),
+        permission='dcim.view_consoleserverporttemplate',
+        always_display=False
+    )
 
 
 
 
+@register_model_view(DeviceType, 'powerports', path='power-ports')
 class DeviceTypePowerPortsView(DeviceTypeComponentsView):
 class DeviceTypePowerPortsView(DeviceTypeComponentsView):
     child_model = PowerPortTemplate
     child_model = PowerPortTemplate
     table = tables.PowerPortTemplateTable
     table = tables.PowerPortTemplateTable
     filterset = filtersets.PowerPortTemplateFilterSet
     filterset = filtersets.PowerPortTemplateFilterSet
     viewname = 'dcim:devicetype_powerports'
     viewname = 'dcim:devicetype_powerports'
+    tab = ViewTab(
+        label=_('Power Ports'),
+        badge=lambda obj: obj.powerporttemplates.count(),
+        permission='dcim.view_powerporttemplate',
+        always_display=False
+    )
 
 
 
 
+@register_model_view(DeviceType, 'poweroutlets', path='power-outlets')
 class DeviceTypePowerOutletsView(DeviceTypeComponentsView):
 class DeviceTypePowerOutletsView(DeviceTypeComponentsView):
     child_model = PowerOutletTemplate
     child_model = PowerOutletTemplate
     table = tables.PowerOutletTemplateTable
     table = tables.PowerOutletTemplateTable
     filterset = filtersets.PowerOutletTemplateFilterSet
     filterset = filtersets.PowerOutletTemplateFilterSet
     viewname = 'dcim:devicetype_poweroutlets'
     viewname = 'dcim:devicetype_poweroutlets'
+    tab = ViewTab(
+        label=_('Power Outlets'),
+        badge=lambda obj: obj.poweroutlettemplates.count(),
+        permission='dcim.view_poweroutlettemplate',
+        always_display=False
+    )
 
 
 
 
+@register_model_view(DeviceType, 'interfaces')
 class DeviceTypeInterfacesView(DeviceTypeComponentsView):
 class DeviceTypeInterfacesView(DeviceTypeComponentsView):
     child_model = InterfaceTemplate
     child_model = InterfaceTemplate
     table = tables.InterfaceTemplateTable
     table = tables.InterfaceTemplateTable
     filterset = filtersets.InterfaceTemplateFilterSet
     filterset = filtersets.InterfaceTemplateFilterSet
     viewname = 'dcim:devicetype_interfaces'
     viewname = 'dcim:devicetype_interfaces'
+    tab = ViewTab(
+        label=_('Interfaces'),
+        badge=lambda obj: obj.interfacetemplates.count(),
+        permission='dcim.view_interfacetemplate',
+        always_display=False
+    )
 
 
 
 
+@register_model_view(DeviceType, 'frontports', path='front-ports')
 class DeviceTypeFrontPortsView(DeviceTypeComponentsView):
 class DeviceTypeFrontPortsView(DeviceTypeComponentsView):
     child_model = FrontPortTemplate
     child_model = FrontPortTemplate
     table = tables.FrontPortTemplateTable
     table = tables.FrontPortTemplateTable
     filterset = filtersets.FrontPortTemplateFilterSet
     filterset = filtersets.FrontPortTemplateFilterSet
     viewname = 'dcim:devicetype_frontports'
     viewname = 'dcim:devicetype_frontports'
+    tab = ViewTab(
+        label=_('Front Ports'),
+        badge=lambda obj: obj.frontporttemplates.count(),
+        permission='dcim.view_frontporttemplate',
+        always_display=False
+    )
 
 
 
 
+@register_model_view(DeviceType, 'rearports', path='rear-ports')
 class DeviceTypeRearPortsView(DeviceTypeComponentsView):
 class DeviceTypeRearPortsView(DeviceTypeComponentsView):
     child_model = RearPortTemplate
     child_model = RearPortTemplate
     table = tables.RearPortTemplateTable
     table = tables.RearPortTemplateTable
     filterset = filtersets.RearPortTemplateFilterSet
     filterset = filtersets.RearPortTemplateFilterSet
     viewname = 'dcim:devicetype_rearports'
     viewname = 'dcim:devicetype_rearports'
+    tab = ViewTab(
+        label=_('Rear Ports'),
+        badge=lambda obj: obj.rearporttemplates.count(),
+        permission='dcim.view_rearporttemplate',
+        always_display=False
+    )
 
 
 
 
+@register_model_view(DeviceType, 'modulebays', path='module-bays')
 class DeviceTypeModuleBaysView(DeviceTypeComponentsView):
 class DeviceTypeModuleBaysView(DeviceTypeComponentsView):
     child_model = ModuleBayTemplate
     child_model = ModuleBayTemplate
     table = tables.ModuleBayTemplateTable
     table = tables.ModuleBayTemplateTable
     filterset = filtersets.ModuleBayTemplateFilterSet
     filterset = filtersets.ModuleBayTemplateFilterSet
     viewname = 'dcim:devicetype_modulebays'
     viewname = 'dcim:devicetype_modulebays'
+    tab = ViewTab(
+        label=_('Module Bays'),
+        badge=lambda obj: obj.modulebaytemplates.count(),
+        permission='dcim.view_modulebaytemplate',
+        always_display=False
+    )
 
 
 
 
+@register_model_view(DeviceType, 'devicebays', path='device-bays')
 class DeviceTypeDeviceBaysView(DeviceTypeComponentsView):
 class DeviceTypeDeviceBaysView(DeviceTypeComponentsView):
     child_model = DeviceBayTemplate
     child_model = DeviceBayTemplate
     table = tables.DeviceBayTemplateTable
     table = tables.DeviceBayTemplateTable
     filterset = filtersets.DeviceBayTemplateFilterSet
     filterset = filtersets.DeviceBayTemplateFilterSet
     viewname = 'dcim:devicetype_devicebays'
     viewname = 'dcim:devicetype_devicebays'
+    tab = ViewTab(
+        label=_('Device Bays'),
+        badge=lambda obj: obj.devicebaytemplates.count(),
+        permission='dcim.view_devicebaytemplate',
+        always_display=False
+    )
 
 
 
 
+@register_model_view(DeviceType, 'inventoryitems', path='inventory-items')
 class DeviceTypeInventoryItemsView(DeviceTypeComponentsView):
 class DeviceTypeInventoryItemsView(DeviceTypeComponentsView):
     child_model = InventoryItemTemplate
     child_model = InventoryItemTemplate
     table = tables.InventoryItemTemplateTable
     table = tables.InventoryItemTemplateTable
     filterset = filtersets.InventoryItemTemplateFilterSet
     filterset = filtersets.InventoryItemTemplateFilterSet
     viewname = 'dcim:devicetype_inventoryitems'
     viewname = 'dcim:devicetype_inventoryitems'
+    tab = ViewTab(
+        label=_('Inventory Items'),
+        badge=lambda obj: obj.inventoryitemtemplates.count(),
+        permission='dcim.view_invenotryitemtemplate',
+        always_display=False
+    )
 
 
 
 
 class DeviceTypeEditView(generic.ObjectEditView):
 class DeviceTypeEditView(generic.ObjectEditView):
@@ -1011,53 +1084,102 @@ class ModuleTypeView(generic.ObjectView):
         }
         }
 
 
 
 
+@register_model_view(ModuleType, 'consoleports', path='console-ports')
 class ModuleTypeConsolePortsView(ModuleTypeComponentsView):
 class ModuleTypeConsolePortsView(ModuleTypeComponentsView):
     child_model = ConsolePortTemplate
     child_model = ConsolePortTemplate
     table = tables.ConsolePortTemplateTable
     table = tables.ConsolePortTemplateTable
     filterset = filtersets.ConsolePortTemplateFilterSet
     filterset = filtersets.ConsolePortTemplateFilterSet
     viewname = 'dcim:moduletype_consoleports'
     viewname = 'dcim:moduletype_consoleports'
+    tab = ViewTab(
+        label=_('Console Ports'),
+        badge=lambda obj: obj.consoleporttemplates.count(),
+        permission='dcim.view_consoleporttemplate',
+        always_display=False
+    )
 
 
 
 
+@register_model_view(ModuleType, 'consoleserverports', path='console-server-ports')
 class ModuleTypeConsoleServerPortsView(ModuleTypeComponentsView):
 class ModuleTypeConsoleServerPortsView(ModuleTypeComponentsView):
     child_model = ConsoleServerPortTemplate
     child_model = ConsoleServerPortTemplate
     table = tables.ConsoleServerPortTemplateTable
     table = tables.ConsoleServerPortTemplateTable
     filterset = filtersets.ConsoleServerPortTemplateFilterSet
     filterset = filtersets.ConsoleServerPortTemplateFilterSet
     viewname = 'dcim:moduletype_consoleserverports'
     viewname = 'dcim:moduletype_consoleserverports'
+    tab = ViewTab(
+        label=_('Console Server Ports'),
+        badge=lambda obj: obj.consoleserverporttemplates.count(),
+        permission='dcim.view_consoleserverporttemplate',
+        always_display=False
+    )
 
 
 
 
+@register_model_view(ModuleType, 'powerports', path='power-ports')
 class ModuleTypePowerPortsView(ModuleTypeComponentsView):
 class ModuleTypePowerPortsView(ModuleTypeComponentsView):
     child_model = PowerPortTemplate
     child_model = PowerPortTemplate
     table = tables.PowerPortTemplateTable
     table = tables.PowerPortTemplateTable
     filterset = filtersets.PowerPortTemplateFilterSet
     filterset = filtersets.PowerPortTemplateFilterSet
     viewname = 'dcim:moduletype_powerports'
     viewname = 'dcim:moduletype_powerports'
+    tab = ViewTab(
+        label=_('Power Ports'),
+        badge=lambda obj: obj.powerporttemplates.count(),
+        permission='dcim.view_powerporttemplate',
+        always_display=False
+    )
 
 
 
 
+@register_model_view(ModuleType, 'poweroutlets', path='power-outlets')
 class ModuleTypePowerOutletsView(ModuleTypeComponentsView):
 class ModuleTypePowerOutletsView(ModuleTypeComponentsView):
     child_model = PowerOutletTemplate
     child_model = PowerOutletTemplate
     table = tables.PowerOutletTemplateTable
     table = tables.PowerOutletTemplateTable
     filterset = filtersets.PowerOutletTemplateFilterSet
     filterset = filtersets.PowerOutletTemplateFilterSet
     viewname = 'dcim:moduletype_poweroutlets'
     viewname = 'dcim:moduletype_poweroutlets'
+    tab = ViewTab(
+        label=_('Power Outlets'),
+        badge=lambda obj: obj.poweroutlettemplates.count(),
+        permission='dcim.view_poweroutlettemplate',
+        always_display=False
+    )
 
 
 
 
+@register_model_view(ModuleType, 'interfaces')
 class ModuleTypeInterfacesView(ModuleTypeComponentsView):
 class ModuleTypeInterfacesView(ModuleTypeComponentsView):
     child_model = InterfaceTemplate
     child_model = InterfaceTemplate
     table = tables.InterfaceTemplateTable
     table = tables.InterfaceTemplateTable
     filterset = filtersets.InterfaceTemplateFilterSet
     filterset = filtersets.InterfaceTemplateFilterSet
     viewname = 'dcim:moduletype_interfaces'
     viewname = 'dcim:moduletype_interfaces'
+    tab = ViewTab(
+        label=_('Interfaces'),
+        badge=lambda obj: obj.interfacetemplates.count(),
+        permission='dcim.view_interfacetemplate',
+        always_display=False
+    )
 
 
 
 
+@register_model_view(ModuleType, 'frontports', path='front-ports')
 class ModuleTypeFrontPortsView(ModuleTypeComponentsView):
 class ModuleTypeFrontPortsView(ModuleTypeComponentsView):
     child_model = FrontPortTemplate
     child_model = FrontPortTemplate
     table = tables.FrontPortTemplateTable
     table = tables.FrontPortTemplateTable
     filterset = filtersets.FrontPortTemplateFilterSet
     filterset = filtersets.FrontPortTemplateFilterSet
     viewname = 'dcim:moduletype_frontports'
     viewname = 'dcim:moduletype_frontports'
+    tab = ViewTab(
+        label=_('Front Ports'),
+        badge=lambda obj: obj.frontporttemplates.count(),
+        permission='dcim.view_frontporttemplate',
+        always_display=False
+    )
 
 
 
 
+@register_model_view(ModuleType, 'rearports', path='rear-ports')
 class ModuleTypeRearPortsView(ModuleTypeComponentsView):
 class ModuleTypeRearPortsView(ModuleTypeComponentsView):
     child_model = RearPortTemplate
     child_model = RearPortTemplate
     table = tables.RearPortTemplateTable
     table = tables.RearPortTemplateTable
     filterset = filtersets.RearPortTemplateFilterSet
     filterset = filtersets.RearPortTemplateFilterSet
     viewname = 'dcim:moduletype_rearports'
     viewname = 'dcim:moduletype_rearports'
+    tab = ViewTab(
+        label=_('Rear Ports'),
+        badge=lambda obj: obj.rearporttemplates.count(),
+        permission='dcim.view_rearporttemplate',
+        always_display=False
+    )
 
 
 
 
 class ModuleTypeEditView(generic.ObjectEditView):
 class ModuleTypeEditView(generic.ObjectEditView):
@@ -1620,39 +1742,74 @@ class DeviceView(generic.ObjectView):
         }
         }
 
 
 
 
+@register_model_view(Device, 'consoleports', path='console-ports')
 class DeviceConsolePortsView(DeviceComponentsView):
 class DeviceConsolePortsView(DeviceComponentsView):
     child_model = ConsolePort
     child_model = ConsolePort
     table = tables.DeviceConsolePortTable
     table = tables.DeviceConsolePortTable
     filterset = filtersets.ConsolePortFilterSet
     filterset = filtersets.ConsolePortFilterSet
     template_name = 'dcim/device/consoleports.html'
     template_name = 'dcim/device/consoleports.html'
+    tab = ViewTab(
+        label=_('Console Ports'),
+        badge=lambda obj: obj.consoleports.count(),
+        permission='dcim.view_consoleport',
+        always_display=False
+    )
 
 
 
 
+@register_model_view(Device, 'consoleserverports', path='console-server-ports')
 class DeviceConsoleServerPortsView(DeviceComponentsView):
 class DeviceConsoleServerPortsView(DeviceComponentsView):
     child_model = ConsoleServerPort
     child_model = ConsoleServerPort
     table = tables.DeviceConsoleServerPortTable
     table = tables.DeviceConsoleServerPortTable
     filterset = filtersets.ConsoleServerPortFilterSet
     filterset = filtersets.ConsoleServerPortFilterSet
     template_name = 'dcim/device/consoleserverports.html'
     template_name = 'dcim/device/consoleserverports.html'
+    tab = ViewTab(
+        label=_('Console Server Ports'),
+        badge=lambda obj: obj.consoleserverports.count(),
+        permission='dcim.view_consoleserverport',
+        always_display=False
+    )
 
 
 
 
+@register_model_view(Device, 'powerports', path='power-ports')
 class DevicePowerPortsView(DeviceComponentsView):
 class DevicePowerPortsView(DeviceComponentsView):
     child_model = PowerPort
     child_model = PowerPort
     table = tables.DevicePowerPortTable
     table = tables.DevicePowerPortTable
     filterset = filtersets.PowerPortFilterSet
     filterset = filtersets.PowerPortFilterSet
     template_name = 'dcim/device/powerports.html'
     template_name = 'dcim/device/powerports.html'
+    tab = ViewTab(
+        label=_('Power Ports'),
+        badge=lambda obj: obj.powerports.count(),
+        permission='dcim.view_powerport',
+        always_display=False
+    )
 
 
 
 
+@register_model_view(Device, 'poweroutlets', path='power-outlets')
 class DevicePowerOutletsView(DeviceComponentsView):
 class DevicePowerOutletsView(DeviceComponentsView):
     child_model = PowerOutlet
     child_model = PowerOutlet
     table = tables.DevicePowerOutletTable
     table = tables.DevicePowerOutletTable
     filterset = filtersets.PowerOutletFilterSet
     filterset = filtersets.PowerOutletFilterSet
     template_name = 'dcim/device/poweroutlets.html'
     template_name = 'dcim/device/poweroutlets.html'
+    tab = ViewTab(
+        label=_('Power Outlets'),
+        badge=lambda obj: obj.poweroutlets.count(),
+        permission='dcim.view_poweroutlet',
+        always_display=False
+    )
 
 
 
 
+@register_model_view(Device, 'interfaces')
 class DeviceInterfacesView(DeviceComponentsView):
 class DeviceInterfacesView(DeviceComponentsView):
     child_model = Interface
     child_model = Interface
     table = tables.DeviceInterfaceTable
     table = tables.DeviceInterfaceTable
     filterset = filtersets.InterfaceFilterSet
     filterset = filtersets.InterfaceFilterSet
     template_name = 'dcim/device/interfaces.html'
     template_name = 'dcim/device/interfaces.html'
+    tab = ViewTab(
+        label=_('Interfaces'),
+        badge=lambda obj: obj.interfaces.count(),
+        permission='dcim.view_interface',
+        always_display=False
+    )
 
 
     def get_children(self, request, parent):
     def get_children(self, request, parent):
         return parent.vc_interfaces().restrict(request.user, 'view').prefetch_related(
         return parent.vc_interfaces().restrict(request.user, 'view').prefetch_related(
@@ -1661,39 +1818,74 @@ class DeviceInterfacesView(DeviceComponentsView):
         )
         )
 
 
 
 
+@register_model_view(Device, 'frontports', path='front-ports')
 class DeviceFrontPortsView(DeviceComponentsView):
 class DeviceFrontPortsView(DeviceComponentsView):
     child_model = FrontPort
     child_model = FrontPort
     table = tables.DeviceFrontPortTable
     table = tables.DeviceFrontPortTable
     filterset = filtersets.FrontPortFilterSet
     filterset = filtersets.FrontPortFilterSet
     template_name = 'dcim/device/frontports.html'
     template_name = 'dcim/device/frontports.html'
+    tab = ViewTab(
+        label=_('Front Ports'),
+        badge=lambda obj: obj.frontports.count(),
+        permission='dcim.view_frontport',
+        always_display=False
+    )
 
 
 
 
+@register_model_view(Device, 'rearports', path='rear-ports')
 class DeviceRearPortsView(DeviceComponentsView):
 class DeviceRearPortsView(DeviceComponentsView):
     child_model = RearPort
     child_model = RearPort
     table = tables.DeviceRearPortTable
     table = tables.DeviceRearPortTable
     filterset = filtersets.RearPortFilterSet
     filterset = filtersets.RearPortFilterSet
     template_name = 'dcim/device/rearports.html'
     template_name = 'dcim/device/rearports.html'
+    tab = ViewTab(
+        label=_('Rear Ports'),
+        badge=lambda obj: obj.rearports.count(),
+        permission='dcim.view_rearport',
+        always_display=False
+    )
 
 
 
 
+@register_model_view(Device, 'modulebays', path='module-bays')
 class DeviceModuleBaysView(DeviceComponentsView):
 class DeviceModuleBaysView(DeviceComponentsView):
     child_model = ModuleBay
     child_model = ModuleBay
     table = tables.DeviceModuleBayTable
     table = tables.DeviceModuleBayTable
     filterset = filtersets.ModuleBayFilterSet
     filterset = filtersets.ModuleBayFilterSet
     template_name = 'dcim/device/modulebays.html'
     template_name = 'dcim/device/modulebays.html'
+    tab = ViewTab(
+        label=_('Module Bays'),
+        badge=lambda obj: obj.modulebays.count(),
+        permission='dcim.view_modulebay',
+        always_display=False
+    )
 
 
 
 
+@register_model_view(Device, 'devicebays', path='device-bays')
 class DeviceDeviceBaysView(DeviceComponentsView):
 class DeviceDeviceBaysView(DeviceComponentsView):
     child_model = DeviceBay
     child_model = DeviceBay
     table = tables.DeviceDeviceBayTable
     table = tables.DeviceDeviceBayTable
     filterset = filtersets.DeviceBayFilterSet
     filterset = filtersets.DeviceBayFilterSet
     template_name = 'dcim/device/devicebays.html'
     template_name = 'dcim/device/devicebays.html'
+    tab = ViewTab(
+        label=_('Device Bays'),
+        badge=lambda obj: obj.devicebays.count(),
+        permission='dcim.view_devicebay',
+        always_display=False
+    )
 
 
 
 
+@register_model_view(Device, 'inventory')
 class DeviceInventoryView(DeviceComponentsView):
 class DeviceInventoryView(DeviceComponentsView):
     child_model = InventoryItem
     child_model = InventoryItem
     table = tables.DeviceInventoryItemTable
     table = tables.DeviceInventoryItemTable
     filterset = filtersets.InventoryItemFilterSet
     filterset = filtersets.InventoryItemFilterSet
     template_name = 'dcim/device/inventory.html'
     template_name = 'dcim/device/inventory.html'
+    tab = ViewTab(
+        label=_('Inventory Items'),
+        badge=lambda obj: obj.inventoryitems.count(),
+        permission='dcim.view_inventoryitem',
+        always_display=False
+    )
 
 
 
 
 class DeviceStatusView(generic.ObjectView):
 class DeviceStatusView(generic.ObjectView):
@@ -1736,9 +1928,14 @@ class DeviceConfigView(generic.ObjectView):
         }
         }
 
 
 
 
+@register_model_view(Device, 'configcontext', path='config-context')
 class DeviceConfigContextView(ObjectConfigContextView):
 class DeviceConfigContextView(ObjectConfigContextView):
     queryset = Device.objects.annotate_config_context_data()
     queryset = Device.objects.annotate_config_context_data()
     base_template = 'dcim/device/base.html'
     base_template = 'dcim/device/base.html'
+    tab = ViewTab(
+        label=_('Config Context'),
+        permission='extras.view_configcontext'
+    )
 
 
 
 
 class DeviceEditView(generic.ObjectEditView):
 class DeviceEditView(generic.ObjectEditView):

+ 1 - 1
netbox/extras/views.py

@@ -352,7 +352,7 @@ class ObjectConfigContextView(generic.ObjectView):
             'source_contexts': source_contexts,
             'source_contexts': source_contexts,
             'format': format,
             'format': format,
             'base_template': self.base_template,
             'base_template': self.base_template,
-            'active_tab': 'config-context',
+            'active_tab': 'configcontext',
         }
         }
 
 
 
 

+ 0 - 5
netbox/ipam/urls.py

@@ -57,7 +57,6 @@ urlpatterns = [
     path('aggregates/edit/', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'),
     path('aggregates/edit/', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'),
     path('aggregates/delete/', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'),
     path('aggregates/delete/', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'),
     path('aggregates/<int:pk>/', views.AggregateView.as_view(), name='aggregate'),
     path('aggregates/<int:pk>/', views.AggregateView.as_view(), name='aggregate'),
-    path('aggregates/<int:pk>/prefixes/', views.AggregatePrefixesView.as_view(), name='aggregate_prefixes'),
     path('aggregates/<int:pk>/edit/', views.AggregateEditView.as_view(), name='aggregate_edit'),
     path('aggregates/<int:pk>/edit/', views.AggregateEditView.as_view(), name='aggregate_edit'),
     path('aggregates/<int:pk>/delete/', views.AggregateDeleteView.as_view(), name='aggregate_delete'),
     path('aggregates/<int:pk>/delete/', views.AggregateDeleteView.as_view(), name='aggregate_delete'),
     path('aggregates/<int:pk>/', include(get_model_urls('ipam', 'aggregate'))),
     path('aggregates/<int:pk>/', include(get_model_urls('ipam', 'aggregate'))),
@@ -82,9 +81,6 @@ urlpatterns = [
     path('prefixes/<int:pk>/', views.PrefixView.as_view(), name='prefix'),
     path('prefixes/<int:pk>/', views.PrefixView.as_view(), name='prefix'),
     path('prefixes/<int:pk>/edit/', views.PrefixEditView.as_view(), name='prefix_edit'),
     path('prefixes/<int:pk>/edit/', views.PrefixEditView.as_view(), name='prefix_edit'),
     path('prefixes/<int:pk>/delete/', views.PrefixDeleteView.as_view(), name='prefix_delete'),
     path('prefixes/<int:pk>/delete/', views.PrefixDeleteView.as_view(), name='prefix_delete'),
-    path('prefixes/<int:pk>/prefixes/', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'),
-    path('prefixes/<int:pk>/ip-ranges/', views.PrefixIPRangesView.as_view(), name='prefix_ipranges'),
-    path('prefixes/<int:pk>/ip-addresses/', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'),
     path('prefixes/<int:pk>/', include(get_model_urls('ipam', 'prefix'))),
     path('prefixes/<int:pk>/', include(get_model_urls('ipam', 'prefix'))),
 
 
     # IP ranges
     # IP ranges
@@ -96,7 +92,6 @@ urlpatterns = [
     path('ip-ranges/<int:pk>/', views.IPRangeView.as_view(), name='iprange'),
     path('ip-ranges/<int:pk>/', views.IPRangeView.as_view(), name='iprange'),
     path('ip-ranges/<int:pk>/edit/', views.IPRangeEditView.as_view(), name='iprange_edit'),
     path('ip-ranges/<int:pk>/edit/', views.IPRangeEditView.as_view(), name='iprange_edit'),
     path('ip-ranges/<int:pk>/delete/', views.IPRangeDeleteView.as_view(), name='iprange_delete'),
     path('ip-ranges/<int:pk>/delete/', views.IPRangeDeleteView.as_view(), name='iprange_delete'),
-    path('ip-ranges/<int:pk>/ip-addresses/', views.IPRangeIPAddressesView.as_view(), name='iprange_ipaddresses'),
     path('ip-ranges/<int:pk>/', include(get_model_urls('ipam', 'iprange'))),
     path('ip-ranges/<int:pk>/', include(get_model_urls('ipam', 'iprange'))),
 
 
     # IP addresses
     # IP addresses

+ 35 - 3
netbox/ipam/views.py

@@ -3,6 +3,7 @@ from django.db.models import Prefetch
 from django.db.models.expressions import RawSQL
 from django.db.models.expressions import RawSQL
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 from django.urls import reverse
+from django.utils.translation import gettext as _
 
 
 from circuits.models import Provider, Circuit
 from circuits.models import Provider, Circuit
 from circuits.tables import ProviderTable
 from circuits.tables import ProviderTable
@@ -11,6 +12,7 @@ from dcim.models import Interface, Site, Device
 from dcim.tables import SiteTable
 from dcim.tables import SiteTable
 from netbox.views import generic
 from netbox.views import generic
 from utilities.utils import count_related
 from utilities.utils import count_related
+from utilities.views import ViewTab, register_model_view
 from virtualization.filtersets import VMInterfaceFilterSet
 from virtualization.filtersets import VMInterfaceFilterSet
 from virtualization.models import VMInterface, VirtualMachine
 from virtualization.models import VMInterface, VirtualMachine
 from . import filtersets, forms, tables
 from . import filtersets, forms, tables
@@ -289,12 +291,18 @@ class AggregateView(generic.ObjectView):
     queryset = Aggregate.objects.all()
     queryset = Aggregate.objects.all()
 
 
 
 
+@register_model_view(Aggregate, 'prefixes')
 class AggregatePrefixesView(generic.ObjectChildrenView):
 class AggregatePrefixesView(generic.ObjectChildrenView):
     queryset = Aggregate.objects.all()
     queryset = Aggregate.objects.all()
     child_model = Prefix
     child_model = Prefix
     table = tables.PrefixTable
     table = tables.PrefixTable
     filterset = filtersets.PrefixFilterSet
     filterset = filtersets.PrefixFilterSet
     template_name = 'ipam/aggregate/prefixes.html'
     template_name = 'ipam/aggregate/prefixes.html'
+    tab = ViewTab(
+        label=_('Prefixes'),
+        badge=lambda x: x.get_child_prefixes().count(),
+        permission='ipam.view_prefix'
+    )
 
 
     def get_children(self, request, parent):
     def get_children(self, request, parent):
         return Prefix.objects.restrict(request.user, 'view').filter(
         return Prefix.objects.restrict(request.user, 'view').filter(
@@ -466,12 +474,18 @@ class PrefixView(generic.ObjectView):
         }
         }
 
 
 
 
+@register_model_view(Prefix, 'prefixes')
 class PrefixPrefixesView(generic.ObjectChildrenView):
 class PrefixPrefixesView(generic.ObjectChildrenView):
     queryset = Prefix.objects.all()
     queryset = Prefix.objects.all()
     child_model = Prefix
     child_model = Prefix
     table = tables.PrefixTable
     table = tables.PrefixTable
     filterset = filtersets.PrefixFilterSet
     filterset = filtersets.PrefixFilterSet
     template_name = 'ipam/prefix/prefixes.html'
     template_name = 'ipam/prefix/prefixes.html'
+    tab = ViewTab(
+        label=_('Child Prefixes'),
+        badge=lambda x: x.get_child_prefixes().count(),
+        permission='ipam.view_prefix'
+    )
 
 
     def get_children(self, request, parent):
     def get_children(self, request, parent):
         return parent.get_child_prefixes().restrict(request.user, 'view').prefetch_related(
         return parent.get_child_prefixes().restrict(request.user, 'view').prefetch_related(
@@ -495,12 +509,18 @@ class PrefixPrefixesView(generic.ObjectChildrenView):
         }
         }
 
 
 
 
+@register_model_view(Prefix, 'ipranges', path='ip-ranges')
 class PrefixIPRangesView(generic.ObjectChildrenView):
 class PrefixIPRangesView(generic.ObjectChildrenView):
     queryset = Prefix.objects.all()
     queryset = Prefix.objects.all()
     child_model = IPRange
     child_model = IPRange
     table = tables.IPRangeTable
     table = tables.IPRangeTable
     filterset = filtersets.IPRangeFilterSet
     filterset = filtersets.IPRangeFilterSet
     template_name = 'ipam/prefix/ip_ranges.html'
     template_name = 'ipam/prefix/ip_ranges.html'
+    tab = ViewTab(
+        label=_('Child Ranges'),
+        badge=lambda x: x.get_child_ranges().count(),
+        permission='ipam.view_iprange'
+    )
 
 
     def get_children(self, request, parent):
     def get_children(self, request, parent):
         return parent.get_child_ranges().restrict(request.user, 'view').prefetch_related(
         return parent.get_child_ranges().restrict(request.user, 'view').prefetch_related(
@@ -510,17 +530,23 @@ class PrefixIPRangesView(generic.ObjectChildrenView):
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         return {
         return {
             'bulk_querystring': f"vrf_id={instance.vrf.pk if instance.vrf else '0'}&parent={instance.prefix}",
             'bulk_querystring': f"vrf_id={instance.vrf.pk if instance.vrf else '0'}&parent={instance.prefix}",
-            'active_tab': 'ip-ranges',
+            'active_tab': 'ipranges',
             'first_available_ip': instance.get_first_available_ip(),
             'first_available_ip': instance.get_first_available_ip(),
         }
         }
 
 
 
 
+@register_model_view(Prefix, 'ipaddresses', path='ip-addresses')
 class PrefixIPAddressesView(generic.ObjectChildrenView):
 class PrefixIPAddressesView(generic.ObjectChildrenView):
     queryset = Prefix.objects.all()
     queryset = Prefix.objects.all()
     child_model = IPAddress
     child_model = IPAddress
     table = tables.IPAddressTable
     table = tables.IPAddressTable
     filterset = filtersets.IPAddressFilterSet
     filterset = filtersets.IPAddressFilterSet
     template_name = 'ipam/prefix/ip_addresses.html'
     template_name = 'ipam/prefix/ip_addresses.html'
+    tab = ViewTab(
+        label=_('IP Addresses'),
+        badge=lambda x: x.get_child_ips().count(),
+        permission='ipam.view_ipaddress'
+    )
 
 
     def get_children(self, request, parent):
     def get_children(self, request, parent):
         return parent.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf', 'tenant', 'tenant__group')
         return parent.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf', 'tenant', 'tenant__group')
@@ -533,7 +559,7 @@ class PrefixIPAddressesView(generic.ObjectChildrenView):
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         return {
         return {
             'bulk_querystring': f"vrf_id={instance.vrf.pk if instance.vrf else '0'}&parent={instance.prefix}",
             'bulk_querystring': f"vrf_id={instance.vrf.pk if instance.vrf else '0'}&parent={instance.prefix}",
-            'active_tab': 'ip-addresses',
+            'active_tab': 'ipaddresses',
             'first_available_ip': instance.get_first_available_ip(),
             'first_available_ip': instance.get_first_available_ip(),
         }
         }
 
 
@@ -581,19 +607,25 @@ class IPRangeView(generic.ObjectView):
     queryset = IPRange.objects.all()
     queryset = IPRange.objects.all()
 
 
 
 
+@register_model_view(IPRange, 'ipaddresses', path='ip-addresses')
 class IPRangeIPAddressesView(generic.ObjectChildrenView):
 class IPRangeIPAddressesView(generic.ObjectChildrenView):
     queryset = IPRange.objects.all()
     queryset = IPRange.objects.all()
     child_model = IPAddress
     child_model = IPAddress
     table = tables.IPAddressTable
     table = tables.IPAddressTable
     filterset = filtersets.IPAddressFilterSet
     filterset = filtersets.IPAddressFilterSet
     template_name = 'ipam/iprange/ip_addresses.html'
     template_name = 'ipam/iprange/ip_addresses.html'
+    tab = ViewTab(
+        label=_('IP Addresses'),
+        badge=lambda x: x.get_child_ips().count(),
+        permission='ipam.view_ipaddress'
+    )
 
 
     def get_children(self, request, parent):
     def get_children(self, request, parent):
         return parent.get_child_ips().restrict(request.user, 'view')
         return parent.get_child_ips().restrict(request.user, 'view')
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         return {
         return {
-            'active_tab': 'ip-addresses',
+            'active_tab': 'ipaddresses',
         }
         }
 
 
 
 

+ 3 - 5
netbox/netbox/models/features.py

@@ -13,7 +13,7 @@ from extras.utils import is_taggable, register_features
 from netbox.signals import post_clean
 from netbox.signals import post_clean
 from utilities.json import CustomFieldJSONEncoder
 from utilities.json import CustomFieldJSONEncoder
 from utilities.utils import serialize_object
 from utilities.utils import serialize_object
-from utilities.views import ViewTab, register_model_view
+from utilities.views import register_model_view
 
 
 __all__ = (
 __all__ = (
     'ChangeLoggingMixin',
     'ChangeLoggingMixin',
@@ -299,13 +299,11 @@ def _register_features(sender, **kwargs):
         register_model_view(
         register_model_view(
             sender,
             sender,
             'journal',
             'journal',
-            'netbox.views.generic.ObjectJournalView',
             kwargs={'model': sender}
             kwargs={'model': sender}
-        )
+        )('netbox.views.generic.ObjectJournalView')
     if issubclass(sender, ChangeLoggingMixin):
     if issubclass(sender, ChangeLoggingMixin):
         register_model_view(
         register_model_view(
             sender,
             sender,
             'changelog',
             'changelog',
-            'netbox.views.generic.ObjectChangeLogView',
             kwargs={'model': sender}
             kwargs={'model': sender}
-        )
+        )('netbox.views.generic.ObjectChangeLogView')

+ 0 - 89
netbox/templates/dcim/device/base.html

@@ -56,87 +56,6 @@
 {% endblock %}
 {% endblock %}
 
 
 {% block extra_tabs %}
 {% block extra_tabs %}
-    {% with tab_name='device-bays' devicebay_count=object.devicebays.count %}
-        {% if active_tab == tab_name or devicebay_count %}
-            <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:device_devicebays' pk=object.pk %}">Device Bays {% badge devicebay_count %}</a>
-            </li>
-        {% endif %}
-    {% endwith %}
-
-    {% with tab_name='module-bays' modulebay_count=object.modulebays.count %}
-        {% if active_tab == tab_name or modulebay_count %}
-            <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:device_modulebays' pk=object.pk %}">Module Bays {% badge modulebay_count %}</a>
-            </li>
-        {% endif %}
-    {% endwith %}
-
-    {% with tab_name='interfaces' interface_count=object.interfaces_count %}
-        {% if active_tab == tab_name or interface_count %}
-            <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:device_interfaces' pk=object.pk %}">Interfaces {% badge interface_count %}</a>
-            </li>
-        {% endif %}
-    {% endwith %}
-
-    {% with tab_name='front-ports' frontport_count=object.frontports.count %}
-        {% if active_tab == tab_name or frontport_count %}
-            <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:device_frontports' pk=object.pk %}">Front Ports {% badge frontport_count %}</a>
-            </li>
-        {% endif %}
-    {% endwith %}
-
-    {% with tab_name='rear-ports' rearport_count=object.rearports.count %}
-        {% if active_tab == tab_name or rearport_count %}
-            <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:device_rearports' pk=object.pk %}">Rear Ports {% badge rearport_count %}</a>
-            </li>
-        {% endif %}
-    {% endwith %}
-
-    {% with tab_name='console-ports' consoleport_count=object.consoleports.count %}
-        {% if active_tab == tab_name or consoleport_count %}
-            <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:device_consoleports' pk=object.pk %}">Console Ports {% badge consoleport_count %}</a>
-            </li>
-        {% endif %}
-    {% endwith %}
-
-    {% with tab_name='console-server-ports' consoleserverport_count=object.consoleserverports.count %}
-        {% if active_tab == tab_name or consoleserverport_count %}
-            <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:device_consoleserverports' pk=object.pk %}">Console Server Ports {% badge consoleserverport_count %}</a>
-            </li>
-        {% endif %}
-    {% endwith %}
-
-    {% with tab_name='power-ports' powerport_count=object.powerports.count %}
-        {% if active_tab == tab_name or powerport_count %}
-            <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:device_powerports' pk=object.pk %}">Power Ports {% badge powerport_count %}</a>
-            </li>
-        {% endif %}
-    {% endwith %}
-
-    {% with tab_name='power-outlets' poweroutlet_count=object.poweroutlets.count %}
-        {% if active_tab == tab_name or poweroutlet_count %}
-            <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:device_poweroutlets' pk=object.pk %}">Power Outlets {% badge poweroutlet_count %}</a>
-            </li>
-        {% endif %}
-    {% endwith %}
-
-
-    {% with tab_name='inventory-items' inventoryitem_count=object.inventoryitems.count %}
-        {% if active_tab == tab_name or inventoryitem_count %}
-            <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:device_inventory' pk=object.pk %}">Inventory {% badge inventoryitem_count %}</a>
-            </li>
-        {% endif %}
-    {% endwith %}
-
     {% if perms.dcim.napalm_read_device and object.status == 'active' and object.primary_ip and object.platform.napalm_driver %}
     {% if perms.dcim.napalm_read_device and object.status == 'active' and object.primary_ip and object.platform.napalm_driver %}
         {# NAPALM-enabled tabs #}
         {# NAPALM-enabled tabs #}
         <li role="presentation" class="nav-item">
         <li role="presentation" class="nav-item">
@@ -155,12 +74,4 @@
             </a>
             </a>
         </li>
         </li>
     {% endif %}
     {% endif %}
-    
-    {% if perms.extras.view_configcontext %}
-        <li role="presentation" class="nav-item">
-            <a href="{% url 'dcim:device_configcontext' pk=object.pk %}" class="nav-link{% if active_tab == 'config-context' %} active{% endif %}">
-                Config Context
-            </a>
-        </li>
-    {% endif %}
 {% endblock %}
 {% endblock %}

+ 0 - 82
netbox/templates/dcim/devicetype/base.html

@@ -51,85 +51,3 @@
     </div>
     </div>
   {% endif %}
   {% endif %}
 {% endblock %}
 {% endblock %}
-
-{% block extra_tabs %}
-    {% with tab_name='device-bay-templates' devicebay_count=object.devicebaytemplates.count %}
-        {% if active_tab == tab_name or devicebay_count %}
-            <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:devicetype_devicebays' pk=object.pk %}">Device Bays {% badge devicebay_count %}</a>
-            </li>
-        {% endif %}
-    {% endwith %}
-
-    {% with tab_name='module-bay-templates' modulebay_count=object.modulebaytemplates.count %}
-        {% if active_tab == tab_name or modulebay_count %}
-            <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:devicetype_modulebays' pk=object.pk %}">Module Bays {% badge modulebay_count %}</a>
-            </li>
-        {% endif %}
-    {% endwith %}
-
-    {% with tab_name='interface-templates' interface_count=object.interfacetemplates.count %}
-        {% if active_tab == tab_name or interface_count %}
-            <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:devicetype_interfaces' pk=object.pk %}">Interfaces {% badge interface_count %}</a>
-            </li>
-        {% endif %}
-    {% endwith %}
-
-    {% with tab_name='front-port-templates' frontport_count=object.frontporttemplates.count %}
-        {% if active_tab == tab_name or frontport_count %}
-            <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:devicetype_frontports' pk=object.pk %}">Front Ports {% badge frontport_count %}</a>
-            </li>
-        {% endif %}
-    {% endwith %}
-
-    {% with tab_name='rear-port-templates' rearport_count=object.rearporttemplates.count %}
-        {% if active_tab == tab_name or rearport_count %}
-            <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:devicetype_rearports' pk=object.pk %}">Rear Ports {% badge rearport_count %}</a>
-            </li>
-        {% endif %}
-    {% endwith %}
-
-    {% with tab_name='console-port-templates' consoleport_count=object.consoleporttemplates.count %}
-        {% if active_tab == tab_name or consoleport_count %}
-            <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:devicetype_consoleports' pk=object.pk %}">Console Ports {% badge consoleport_count %}</a>
-            </li>
-        {% endif %}
-    {% endwith %}
-
-    {% with tab_name='console-server-port-templates' consoleserverport_count=object.consoleserverporttemplates.count %}
-        {% if active_tab == tab_name or consoleserverport_count %}
-            <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:devicetype_consoleserverports' pk=object.pk %}">Console Server Ports {% badge consoleserverport_count %}</a>
-            </li>
-        {% endif %}
-    {% endwith %}
-
-    {% with tab_name='power-port-templates' powerport_count=object.powerporttemplates.count %}
-        {% if active_tab == tab_name or powerport_count %}
-            <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:devicetype_powerports' pk=object.pk %}">Power Ports {% badge powerport_count %}</a>
-            </li>
-        {% endif %}
-    {% endwith %}
-
-    {% with tab_name='power-outlet-templates' poweroutlet_count=object.poweroutlettemplates.count %}
-        {% if active_tab == tab_name or poweroutlet_count %}
-            <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:devicetype_poweroutlets' pk=object.pk %}">Power Outlets {% badge poweroutlet_count %}</a>
-            </li>
-        {% endif %}
-    {% endwith %}
-
-    {% with tab_name='inventory-item-templates' inventoryitem_count=object.inventoryitemtemplates.count %}
-        {% if active_tab == tab_name or inventoryitem_count %}
-            <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:devicetype_inventoryitems' pk=object.pk %}">Inventory Items {% badge inventoryitem_count %}</a>
-            </li>
-        {% endif %}
-    {% endwith %}
-{% endblock %}

+ 0 - 58
netbox/templates/dcim/moduletype/base.html

@@ -42,61 +42,3 @@
     </div>
     </div>
   {% endif %}
   {% endif %}
 {% endblock %}
 {% endblock %}
-
-{% block extra_tabs %}
-    {% with interface_count=object.interfacetemplates.count %}
-        {% if interface_count %}
-            <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == 'interface-templates' %} active{% endif %}" href="{% url 'dcim:moduletype_interfaces' pk=object.pk %}">Interfaces {% badge interface_count %}</a>
-            </li>
-        {% endif %}
-    {% endwith %}
-
-    {% with frontport_count=object.frontporttemplates.count %}
-        {% if frontport_count %}
-            <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == 'front-port-templates' %} active{% endif %}" href="{% url 'dcim:moduletype_frontports' pk=object.pk %}">Front Ports {% badge frontport_count %}</a>
-            </li>
-        {% endif %}
-    {% endwith %}
-
-    {% with rearport_count=object.rearporttemplates.count %}
-        {% if rearport_count %}
-            <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == 'rear-port-templates' %} active{% endif %}" href="{% url 'dcim:moduletype_rearports' pk=object.pk %}">Rear Ports {% badge rearport_count %}</a>
-            </li>
-        {% endif %}
-    {% endwith %}
-
-    {% with consoleport_count=object.consoleporttemplates.count %}
-        {% if consoleport_count %}
-            <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == 'console-port-templates' %} active{% endif %}" href="{% url 'dcim:moduletype_consoleports' pk=object.pk %}">Console Ports {% badge consoleport_count %}</a>
-            </li>
-        {% endif %}
-    {% endwith %}
-
-    {% with consoleserverport_count=object.consoleserverporttemplates.count %}
-        {% if consoleserverport_count %}
-            <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == 'console-server-port-templates' %} active{% endif %}" href="{% url 'dcim:moduletype_consoleserverports' pk=object.pk %}">Console Server Ports {% badge consoleserverport_count %}</a>
-            </li>
-        {% endif %}
-    {% endwith %}
-
-    {% with powerport_count=object.powerporttemplates.count %}
-        {% if powerport_count %}
-            <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == 'power-port-templates' %} active{% endif %}" href="{% url 'dcim:moduletype_powerports' pk=object.pk %}">Power Ports {% badge powerport_count %}</a>
-            </li>
-        {% endif %}
-    {% endwith %}
-
-    {% with poweroutlet_count=object.poweroutlettemplates.count %}
-        {% if poweroutlet_count %}
-            <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == 'power-outlet-templates' %} active{% endif %}" href="{% url 'dcim:moduletype_poweroutlets' pk=object.pk %}">Power Outlets {% badge poweroutlet_count %}</a>
-            </li>
-        {% endif %}
-    {% endwith %}
-{% endblock %}

+ 0 - 10
netbox/templates/ipam/aggregate/base.html

@@ -6,13 +6,3 @@
   {{ block.super }}
   {{ block.super }}
   <li class="breadcrumb-item"><a href="{% url 'ipam:aggregate_list' %}?rir_id={{ object.rir.pk }}">{{ object.rir }}</a></li>
   <li class="breadcrumb-item"><a href="{% url 'ipam:aggregate_list' %}?rir_id={{ object.rir.pk }}">{{ object.rir }}</a></li>
 {% endblock %}
 {% endblock %}
-
-{% block extra_tabs %}
-  {% if perms.ipam.view_prefix %}
-    <li role="presentation" class="nav-item">
-      <a class="nav-link{% if active_tab == 'prefixes' %} active{% endif %}" href="{% url 'ipam:aggregate_prefixes' pk=object.pk %}">
-        Prefixes {% badge object.get_child_prefixes.count %}
-      </a>
-    </li>
-  {% endif %}
-{% endblock %}

+ 0 - 10
netbox/templates/ipam/iprange/base.html

@@ -8,13 +8,3 @@
     <li class="breadcrumb-item"><a href="{% url 'ipam:iprange_list' %}?vrf_id={{ object.vrf.pk }}">{{ object.vrf }}</a></li>
     <li class="breadcrumb-item"><a href="{% url 'ipam:iprange_list' %}?vrf_id={{ object.vrf.pk }}">{{ object.vrf }}</a></li>
   {% endif %}
   {% endif %}
 {% endblock %}
 {% endblock %}
-
-{% block extra_tabs %}
-  {% if perms.ipam.view_ipaddress %}
-    <li role="presentation" class="nav-item">
-      <a class="nav-link{% if active_tab == 'ip-addresses' %} active{% endif %}" href="{% url 'ipam:iprange_ipaddresses' pk=object.pk %}">
-        IP Addresses {% badge object.get_child_ips.count %}
-      </a>
-    </li>
-  {% endif %}
-{% endblock %}

+ 0 - 18
netbox/templates/ipam/prefix/base.html

@@ -8,21 +8,3 @@
     <li class="breadcrumb-item"><a href="{% url 'ipam:prefix_list' %}?vrf_id={{ object.vrf.pk }}">{{ object.vrf }}</a></li>
     <li class="breadcrumb-item"><a href="{% url 'ipam:prefix_list' %}?vrf_id={{ object.vrf.pk }}">{{ object.vrf }}</a></li>
   {% endif %}
   {% endif %}
 {% endblock %}
 {% endblock %}
-
-{% block extra_tabs %}
-  <li role="presentation" class="nav-item">
-    <a class="nav-link{% if active_tab == 'prefixes' %} active{% endif %}" href="{% url 'ipam:prefix_prefixes' pk=object.pk %}">
-      Child Prefixes {% badge object.get_child_prefixes.count %}
-    </a>
-  </li>
-  <li role="presentation" class="nav-item">
-    <a class="nav-link{% if active_tab == 'ip-ranges' %} active{% endif %}" href="{% url 'ipam:prefix_ipranges' pk=object.pk %}">
-      Child Ranges {% badge object.get_child_ranges.count %}
-    </a>
-  </li>
-  <li role="presentation" class="nav-item">
-    <a class="nav-link{% if active_tab == 'ip-addresses' %} active{% endif %}" href="{% url 'ipam:prefix_ipaddresses' pk=object.pk %}">
-      IP Addresses {% badge object.get_child_ips.count %}
-    </a>
-  </li>
-{% endblock %}

+ 0 - 17
netbox/templates/virtualization/cluster/base.html

@@ -24,20 +24,3 @@
     </a>
     </a>
   {% endif %}
   {% endif %}
 {% endblock %}
 {% endblock %}
-
-{% block extra_tabs %}
-  {% with virtualmachine_count=object.virtual_machines.count %}
-    <li role="presentation" class="nav-item">
-      <a href="{% url 'virtualization:cluster_virtualmachines' pk=object.pk %}" class="nav-link{% if active_tab == 'virtual-machines' %} active{% endif %}">
-        Virtual Machines {% badge virtualmachine_count %}
-      </a>
-    </li>
-  {% endwith %}
-  {% with device_count=object.devices.count %}
-    <li role="presentation" class="nav-item">
-      <a href="{% url 'virtualization:cluster_devices' pk=object.pk %}" class="nav-link{% if active_tab == 'devices' %} active{% endif %}">
-        Devices {% badge device_count %}
-      </a>
-    </li>
-  {% endwith %}
-{% endblock %}

+ 0 - 15
netbox/templates/virtualization/virtualmachine/base.html

@@ -21,18 +21,3 @@
     </a>
     </a>
   {% endif %}
   {% endif %}
 {% endblock %}
 {% endblock %}
-
-{% block extra_tabs %}
-  {% with interface_count=object.interfaces.count %}
-      {% if interface_count %}
-          <li class="nav-item" role="presentation">
-              <a class="nav-link{% if active_tab == 'interfaces' %} active{% endif %}" href="{% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}">Interfaces {% badge interface_count %}</a>
-          </li>
-      {% endif %}
-  {% endwith %}
-  {% if perms.extras.view_configcontext %}
-    <li class="nav-item" role="presentation">
-      <a class="nav-link{% if active_tab == 'config-context' %} active{% endif %}" href="{% url 'virtualization:virtualmachine_configcontext' pk=object.pk %}">Config Context</a>
-    </li>
-  {% endif %}
-{% endblock %}

+ 16 - 12
netbox/utilities/templatetags/tabs.py

@@ -1,6 +1,6 @@
 from django import template
 from django import template
-from django.core.exceptions import ImproperlyConfigured
 from django.urls import reverse
 from django.urls import reverse
+from django.utils.module_loading import import_string
 
 
 from extras.registry import registry
 from extras.registry import registry
 
 
@@ -26,23 +26,27 @@ def model_view_tabs(context, instance):
         views = []
         views = []
 
 
     # Compile a list of tabs to be displayed in the UI
     # Compile a list of tabs to be displayed in the UI
-    for view in views:
-        if view['tab_label'] and (not view['tab_permission'] or user.has_perm(view['tab_permission'])):
+    for config in views:
+        view = import_string(config['view']) if type(config['view']) is str else config['view']
+        if tab := getattr(view, 'tab', None):
+            if tab.permission and not user.has_perm(tab.permission):
+                continue
 
 
             # Determine the value of the tab's badge (if any)
             # Determine the value of the tab's badge (if any)
-            if view['tab_badge'] and callable(view['tab_badge']):
-                badge_value = view['tab_badge'](instance)
-            elif view['tab_badge']:
-                badge_value = view['tab_badge']
+            if tab.badge and callable(tab.badge):
+                badge_value = tab.badge(instance)
             else:
             else:
-                badge_value = None
+                badge_value = tab.badge
+
+            if not tab.always_display and not badge_value:
+                continue
 
 
             tabs.append({
             tabs.append({
-                'name': view['name'],
-                'url': reverse(f"{app_label}:{model_name}_{view['name']}", args=[instance.pk]),
-                'label': view['tab_label'],
+                'name': config['name'],
+                'url': reverse(f"{app_label}:{model_name}_{config['name']}", args=[instance.pk]),
+                'label': tab.label,
                 'badge_value': badge_value,
                 'badge_value': badge_value,
-                'is_active': context.get('active_tab') == view['name'],
+                'is_active': context.get('active_tab') == config['name'],
             })
             })
 
 
     return {
     return {

+ 2 - 1
netbox/utilities/urls.py

@@ -30,9 +30,10 @@ def get_model_urls(app_label, model_name):
             view_ = config['view']
             view_ = config['view']
         if issubclass(view_, View):
         if issubclass(view_, View):
             view_ = view_.as_view()
             view_ = view_.as_view()
+
         # Create a path to the view
         # Create a path to the view
         paths.append(
         paths.append(
-            path(f"{config['name']}/", view_, name=f"{model_name}_{config['name']}", kwargs=config['kwargs'])
+            path(f"{config['path']}/", view_, name=f"{model_name}_{config['name']}", kwargs=config['kwargs'])
         )
         )
 
 
     return paths
     return paths

+ 30 - 15
netbox/utilities/views.py

@@ -142,24 +142,39 @@ class ViewTab:
         self.always_display = always_display
         self.always_display = always_display
 
 
 
 
-def register_model_view(model, name, view, kwargs=None):
+def register_model_view(model, name, path=None, kwargs=None):
     """
     """
-    Register a subview for a core model.
+    This decorator can be used to "attach" a view to any model in NetBox. This is typically used to inject
+    additional tabs within a model's detail view. For example, to add a custom tab to NetBox's dcim.Site model:
+
+        @netbox_model_view(Site, 'myview', path='my-custom-view')
+        class MyView(ObjectView):
+            ...
+
+    This will automatically create a URL path for MyView at `/dcim/sites/<id>/my-custom-view/` which can be
+    resolved using the view name `dcim:site_myview'.
 
 
     Args:
     Args:
-        model: The Django model class with which this view will be associated
-        name: The name to register when creating a URL path
-        view: A class-based or function view, or the dotted path to it (e.g. 'myplugin.views.FooView')
-        kwargs: A dictionary of keyword arguments to send to the view (optional)
+        model: The Django model class with which this view will be associated.
+        name: The string used to form the view's name for URL resolution (e.g. via `reverse()`). This will be appended
+            to the name of the base view for the model using an underscore.
+        path: The URL path by which the view can be reached (optional). If not provided, `name` will be used.
+        kwargs: A dictionary of keyword arguments for the view to include when registering its URL path (optional)
     """
     """
-    app_label = model._meta.app_label
-    model_name = model._meta.model_name
+    def _wrapper(cls):
+        app_label = model._meta.app_label
+        model_name = model._meta.model_name
+
+        if model_name not in registry['views'][app_label]:
+            registry['views'][app_label][model_name] = []
+
+        registry['views'][app_label][model_name].append({
+            'name': name,
+            'view': cls,
+            'path': path or name,
+            'kwargs': kwargs or {},
+        })
 
 
-    if model_name not in registry['views'][app_label]:
-        registry['views'][app_label][model_name] = []
+        return cls
 
 
-    registry['views'][app_label][model_name].append({
-        'name': name,
-        'view': view,
-        'kwargs': kwargs or {},
-    })
+    return _wrapper

+ 0 - 4
netbox/virtualization/urls.py

@@ -35,8 +35,6 @@ urlpatterns = [
     path('clusters/edit/', views.ClusterBulkEditView.as_view(), name='cluster_bulk_edit'),
     path('clusters/edit/', views.ClusterBulkEditView.as_view(), name='cluster_bulk_edit'),
     path('clusters/delete/', views.ClusterBulkDeleteView.as_view(), name='cluster_bulk_delete'),
     path('clusters/delete/', views.ClusterBulkDeleteView.as_view(), name='cluster_bulk_delete'),
     path('clusters/<int:pk>/', views.ClusterView.as_view(), name='cluster'),
     path('clusters/<int:pk>/', views.ClusterView.as_view(), name='cluster'),
-    path('clusters/<int:pk>/devices/', views.ClusterDevicesView.as_view(), name='cluster_devices'),
-    path('clusters/<int:pk>/virtual-machines/', views.ClusterVirtualMachinesView.as_view(), name='cluster_virtualmachines'),
     path('clusters/<int:pk>/edit/', views.ClusterEditView.as_view(), name='cluster_edit'),
     path('clusters/<int:pk>/edit/', views.ClusterEditView.as_view(), name='cluster_edit'),
     path('clusters/<int:pk>/delete/', views.ClusterDeleteView.as_view(), name='cluster_delete'),
     path('clusters/<int:pk>/delete/', views.ClusterDeleteView.as_view(), name='cluster_delete'),
     path('clusters/<int:pk>/devices/add/', views.ClusterAddDevicesView.as_view(), name='cluster_add_devices'),
     path('clusters/<int:pk>/devices/add/', views.ClusterAddDevicesView.as_view(), name='cluster_add_devices'),
@@ -50,10 +48,8 @@ urlpatterns = [
     path('virtual-machines/edit/', views.VirtualMachineBulkEditView.as_view(), name='virtualmachine_bulk_edit'),
     path('virtual-machines/edit/', views.VirtualMachineBulkEditView.as_view(), name='virtualmachine_bulk_edit'),
     path('virtual-machines/delete/', views.VirtualMachineBulkDeleteView.as_view(), name='virtualmachine_bulk_delete'),
     path('virtual-machines/delete/', views.VirtualMachineBulkDeleteView.as_view(), name='virtualmachine_bulk_delete'),
     path('virtual-machines/<int:pk>/', views.VirtualMachineView.as_view(), name='virtualmachine'),
     path('virtual-machines/<int:pk>/', views.VirtualMachineView.as_view(), name='virtualmachine'),
-    path('virtual-machines/<int:pk>/interfaces/', views.VirtualMachineInterfacesView.as_view(), name='virtualmachine_interfaces'),
     path('virtual-machines/<int:pk>/edit/', views.VirtualMachineEditView.as_view(), name='virtualmachine_edit'),
     path('virtual-machines/<int:pk>/edit/', views.VirtualMachineEditView.as_view(), name='virtualmachine_edit'),
     path('virtual-machines/<int:pk>/delete/', views.VirtualMachineDeleteView.as_view(), name='virtualmachine_delete'),
     path('virtual-machines/<int:pk>/delete/', views.VirtualMachineDeleteView.as_view(), name='virtualmachine_delete'),
-    path('virtual-machines/<int:pk>/config-context/', views.VirtualMachineConfigContextView.as_view(), name='virtualmachine_configcontext'),
     path('virtual-machines/<int:pk>/', include(get_model_urls('virtualization', 'virtualmachine'))),
     path('virtual-machines/<int:pk>/', include(get_model_urls('virtualization', 'virtualmachine'))),
 
 
     # VM interfaces
     # VM interfaces

+ 26 - 1
netbox/virtualization/views.py

@@ -3,6 +3,7 @@ from django.db import transaction
 from django.db.models import Prefetch
 from django.db.models import Prefetch
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 from django.urls import reverse
+from django.utils.translation import gettext as _
 
 
 from dcim.filtersets import DeviceFilterSet
 from dcim.filtersets import DeviceFilterSet
 from dcim.models import Device
 from dcim.models import Device
@@ -12,6 +13,7 @@ from ipam.models import IPAddress, Service
 from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable
 from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable
 from netbox.views import generic
 from netbox.views import generic
 from utilities.utils import count_related
 from utilities.utils import count_related
+from utilities.views import ViewTab, register_model_view
 from . import filtersets, forms, tables
 from . import filtersets, forms, tables
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 
 
@@ -161,28 +163,40 @@ class ClusterView(generic.ObjectView):
     queryset = Cluster.objects.all()
     queryset = Cluster.objects.all()
 
 
 
 
+@register_model_view(Cluster, 'virtualmachines', path='virtual-machines')
 class ClusterVirtualMachinesView(generic.ObjectChildrenView):
 class ClusterVirtualMachinesView(generic.ObjectChildrenView):
     queryset = Cluster.objects.all()
     queryset = Cluster.objects.all()
     child_model = VirtualMachine
     child_model = VirtualMachine
     table = tables.VirtualMachineTable
     table = tables.VirtualMachineTable
     filterset = filtersets.VirtualMachineFilterSet
     filterset = filtersets.VirtualMachineFilterSet
     template_name = 'virtualization/cluster/virtual_machines.html'
     template_name = 'virtualization/cluster/virtual_machines.html'
+    tab = ViewTab(
+        label=_('Virtual Machines'),
+        badge=lambda obj: obj.virtual_machines.count(),
+        permission='virtualization.view_virtualmachine'
+    )
 
 
     def get_children(self, request, parent):
     def get_children(self, request, parent):
         return VirtualMachine.objects.restrict(request.user, 'view').filter(cluster=parent)
         return VirtualMachine.objects.restrict(request.user, 'view').filter(cluster=parent)
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         return {
         return {
-            'active_tab': 'virtual-machines',
+            'active_tab': 'virtualmachines',
         }
         }
 
 
 
 
+@register_model_view(Cluster, 'devices')
 class ClusterDevicesView(generic.ObjectChildrenView):
 class ClusterDevicesView(generic.ObjectChildrenView):
     queryset = Cluster.objects.all()
     queryset = Cluster.objects.all()
     child_model = Device
     child_model = Device
     table = DeviceTable
     table = DeviceTable
     filterset = DeviceFilterSet
     filterset = DeviceFilterSet
     template_name = 'virtualization/cluster/devices.html'
     template_name = 'virtualization/cluster/devices.html'
+    tab = ViewTab(
+        label=_('Devices'),
+        badge=lambda obj: obj.devices.count(),
+        permission='virtualization.view_virtualmachine'
+    )
 
 
     def get_children(self, request, parent):
     def get_children(self, request, parent):
         return Device.objects.restrict(request.user, 'view').filter(cluster=parent)
         return Device.objects.restrict(request.user, 'view').filter(cluster=parent)
@@ -344,12 +358,18 @@ class VirtualMachineView(generic.ObjectView):
         }
         }
 
 
 
 
+@register_model_view(VirtualMachine, 'interfaces')
 class VirtualMachineInterfacesView(generic.ObjectChildrenView):
 class VirtualMachineInterfacesView(generic.ObjectChildrenView):
     queryset = VirtualMachine.objects.all()
     queryset = VirtualMachine.objects.all()
     child_model = VMInterface
     child_model = VMInterface
     table = tables.VirtualMachineVMInterfaceTable
     table = tables.VirtualMachineVMInterfaceTable
     filterset = filtersets.VMInterfaceFilterSet
     filterset = filtersets.VMInterfaceFilterSet
     template_name = 'virtualization/virtualmachine/interfaces.html'
     template_name = 'virtualization/virtualmachine/interfaces.html'
+    tab = ViewTab(
+        label=_('Interfaces'),
+        badge=lambda obj: obj.interfaces.count(),
+        permission='virtualization.view_vminterface'
+    )
 
 
     def get_children(self, request, parent):
     def get_children(self, request, parent):
         return parent.interfaces.restrict(request.user, 'view').prefetch_related(
         return parent.interfaces.restrict(request.user, 'view').prefetch_related(
@@ -363,9 +383,14 @@ class VirtualMachineInterfacesView(generic.ObjectChildrenView):
         }
         }
 
 
 
 
+@register_model_view(VirtualMachine, 'configcontext', path='config-context')
 class VirtualMachineConfigContextView(ObjectConfigContextView):
 class VirtualMachineConfigContextView(ObjectConfigContextView):
     queryset = VirtualMachine.objects.annotate_config_context_data()
     queryset = VirtualMachine.objects.annotate_config_context_data()
     base_template = 'virtualization/virtualmachine.html'
     base_template = 'virtualization/virtualmachine.html'
+    tab = ViewTab(
+        label=_('Config Context'),
+        permission='extras.view_configcontext'
+    )
 
 
 
 
 class VirtualMachineEditView(generic.ObjectEditView):
 class VirtualMachineEditView(generic.ObjectEditView):