Bladeren bron

Merge pull request #10597 from netbox-community/9072-plugin-view-tabs

#9072: Custom model view tabs for plugins
Jeremy Stretch 3 jaren geleden
bovenliggende
commit
a3dbf4023c

+ 26 - 0
docs/plugins/development/views.md

@@ -148,6 +148,32 @@ These views are provided to enable or enhance certain NetBox model features, suc
 
 ## Extending Core Views
 
+### Additional Tabs
+
+Plugins can "attach" a custom view to a core NetBox model by registering it with `register_model_view()`. To include a tab for this view within the NetBox UI, declare a TabView instance named `tab`:
+
+```python
+from dcim.models import Site
+from myplugin.models import Stuff
+from netbox.views import generic
+from utilities.views import ViewTab, register_model_view
+
+@register_model_view(Site, 'mview', path='some-other-stuff')
+class MyView(generic.ObjectView):
+    ...
+    tab = ViewTab(
+        label='Other Stuff',
+        badge=lambda obj: Stuff.objects.filter(site=obj).count(),
+        permission='myplugin.view_stuff'
+    )
+```
+
+::: utilities.views.register_model_view
+
+::: utilities.views.ViewTab
+
+### Extra Template Content
+
 Plugins can inject custom content into certain areas of the detail views of applicable models. This is accomplished by subclassing `PluginTemplateExtension`, designating a particular NetBox model, and defining the desired methods to render custom content. Four methods are available:
 
 * `left_page()` - Inject content on the left side of the page

+ 1 - 0
docs/release-notes/version-3.4.md

@@ -27,6 +27,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a
 ### Plugins API
 
 * [#9071](https://github.com/netbox-community/netbox/issues/9071) - Introduce `PluginMenu` for top-level plugin navigation menus
+* [#9072](https://github.com/netbox-community/netbox/issues/9072) - Enable registration of tabbed plugin views for core NetBox models
 * [#9880](https://github.com/netbox-community/netbox/issues/9880) - Introduce `django_apps` plugin configuration parameter
 * [#10314](https://github.com/netbox-community/netbox/issues/10314) - Move `clone()` method from NetBoxModel to CloningMixin
 

+ 7 - 10
netbox/circuits/urls.py

@@ -1,9 +1,9 @@
-from django.urls import path
+from django.urls import include, path
 
 from dcim.views import PathTraceView
-from netbox.views.generic import ObjectChangeLogView, ObjectJournalView
+from utilities.urls import get_model_urls
 from . import views
-from .models import *
+from .models import CircuitTermination
 
 app_name = 'circuits'
 urlpatterns = [
@@ -17,8 +17,7 @@ urlpatterns = [
     path('providers/<int:pk>/', views.ProviderView.as_view(), name='provider'),
     path('providers/<int:pk>/edit/', views.ProviderEditView.as_view(), name='provider_edit'),
     path('providers/<int:pk>/delete/', views.ProviderDeleteView.as_view(), name='provider_delete'),
-    path('providers/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}),
-    path('providers/<int:pk>/journal/', ObjectJournalView.as_view(), name='provider_journal', kwargs={'model': Provider}),
+    path('providers/<int:pk>/', include(get_model_urls('circuits', 'provider'))),
 
     # Provider networks
     path('provider-networks/', views.ProviderNetworkListView.as_view(), name='providernetwork_list'),
@@ -29,8 +28,7 @@ urlpatterns = [
     path('provider-networks/<int:pk>/', views.ProviderNetworkView.as_view(), name='providernetwork'),
     path('provider-networks/<int:pk>/edit/', views.ProviderNetworkEditView.as_view(), name='providernetwork_edit'),
     path('provider-networks/<int:pk>/delete/', views.ProviderNetworkDeleteView.as_view(), name='providernetwork_delete'),
-    path('provider-networks/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='providernetwork_changelog', kwargs={'model': ProviderNetwork}),
-    path('provider-networks/<int:pk>/journal/', ObjectJournalView.as_view(), name='providernetwork_journal', kwargs={'model': ProviderNetwork}),
+    path('provider-networks/<int:pk>/', include(get_model_urls('circuits', 'providernetwork'))),
 
     # Circuit types
     path('circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'),
@@ -41,7 +39,7 @@ urlpatterns = [
     path('circuit-types/<int:pk>/', views.CircuitTypeView.as_view(), name='circuittype'),
     path('circuit-types/<int:pk>/edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),
     path('circuit-types/<int:pk>/delete/', views.CircuitTypeDeleteView.as_view(), name='circuittype_delete'),
-    path('circuit-types/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}),
+    path('circuit-types/<int:pk>/', include(get_model_urls('circuits', 'circuittype'))),
 
     # Circuits
     path('circuits/', views.CircuitListView.as_view(), name='circuit_list'),
@@ -52,9 +50,8 @@ urlpatterns = [
     path('circuits/<int:pk>/', views.CircuitView.as_view(), name='circuit'),
     path('circuits/<int:pk>/edit/', views.CircuitEditView.as_view(), name='circuit_edit'),
     path('circuits/<int:pk>/delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'),
-    path('circuits/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}),
-    path('circuits/<int:pk>/journal/', ObjectJournalView.as_view(), name='circuit_journal', kwargs={'model': Circuit}),
     path('circuits/<int:pk>/terminations/swap/', views.CircuitSwapTerminations.as_view(), name='circuit_terminations_swap'),
+    path('circuits/<int:pk>/', include(get_model_urls('circuits', 'circuit'))),
 
     # Circuit terminations
     path('circuit-terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'),

+ 35 - 75
netbox/dcim/urls.py

@@ -1,8 +1,10 @@
-from django.urls import path
+from django.urls import include, path
 
-from netbox.views.generic import ObjectChangeLogView, ObjectJournalView
+from utilities.urls import get_model_urls
 from . import views
-from .models import *
+from .models import (
+    ConsolePort, ConsoleServerPort, FrontPort, Interface, PowerFeed, PowerPort, PowerOutlet, RearPort,
+)
 
 app_name = 'dcim'
 urlpatterns = [
@@ -16,7 +18,7 @@ urlpatterns = [
     path('regions/<int:pk>/', views.RegionView.as_view(), name='region'),
     path('regions/<int:pk>/edit/', views.RegionEditView.as_view(), name='region_edit'),
     path('regions/<int:pk>/delete/', views.RegionDeleteView.as_view(), name='region_delete'),
-    path('regions/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}),
+    path('regions/<int:pk>/', include(get_model_urls('dcim', 'region'))),
 
     # Site groups
     path('site-groups/', views.SiteGroupListView.as_view(), name='sitegroup_list'),
@@ -27,7 +29,7 @@ urlpatterns = [
     path('site-groups/<int:pk>/', views.SiteGroupView.as_view(), name='sitegroup'),
     path('site-groups/<int:pk>/edit/', views.SiteGroupEditView.as_view(), name='sitegroup_edit'),
     path('site-groups/<int:pk>/delete/', views.SiteGroupDeleteView.as_view(), name='sitegroup_delete'),
-    path('site-groups/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='sitegroup_changelog', kwargs={'model': SiteGroup}),
+    path('site-groups/<int:pk>/', include(get_model_urls('dcim', 'sitegroup'))),
 
     # Sites
     path('sites/', views.SiteListView.as_view(), name='site_list'),
@@ -38,8 +40,7 @@ urlpatterns = [
     path('sites/<int:pk>/', views.SiteView.as_view(), name='site'),
     path('sites/<int:pk>/edit/', views.SiteEditView.as_view(), name='site_edit'),
     path('sites/<int:pk>/delete/', views.SiteDeleteView.as_view(), name='site_delete'),
-    path('sites/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}),
-    path('sites/<int:pk>/journal/', ObjectJournalView.as_view(), name='site_journal', kwargs={'model': Site}),
+    path('sites/<int:pk>/', include(get_model_urls('dcim', 'site'))),
 
     # Locations
     path('locations/', views.LocationListView.as_view(), name='location_list'),
@@ -50,7 +51,7 @@ urlpatterns = [
     path('locations/<int:pk>/', views.LocationView.as_view(), name='location'),
     path('locations/<int:pk>/edit/', views.LocationEditView.as_view(), name='location_edit'),
     path('locations/<int:pk>/delete/', views.LocationDeleteView.as_view(), name='location_delete'),
-    path('locations/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='location_changelog', kwargs={'model': Location}),
+    path('locations/<int:pk>/', include(get_model_urls('dcim', 'location'))),
 
     # Rack roles
     path('rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'),
@@ -61,7 +62,7 @@ urlpatterns = [
     path('rack-roles/<int:pk>/', views.RackRoleView.as_view(), name='rackrole'),
     path('rack-roles/<int:pk>/edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'),
     path('rack-roles/<int:pk>/delete/', views.RackRoleDeleteView.as_view(), name='rackrole_delete'),
-    path('rack-roles/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}),
+    path('rack-roles/<int:pk>/', include(get_model_urls('dcim', 'rackrole'))),
 
     # Rack reservations
     path('rack-reservations/', views.RackReservationListView.as_view(), name='rackreservation_list'),
@@ -72,8 +73,7 @@ urlpatterns = [
     path('rack-reservations/<int:pk>/', views.RackReservationView.as_view(), name='rackreservation'),
     path('rack-reservations/<int:pk>/edit/', views.RackReservationEditView.as_view(), name='rackreservation_edit'),
     path('rack-reservations/<int:pk>/delete/', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'),
-    path('rack-reservations/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}),
-    path('rack-reservations/<int:pk>/journal/', ObjectJournalView.as_view(), name='rackreservation_journal', kwargs={'model': RackReservation}),
+    path('rack-reservations/<int:pk>/', include(get_model_urls('dcim', 'rackreservation'))),
 
     # Racks
     path('racks/', views.RackListView.as_view(), name='rack_list'),
@@ -85,8 +85,7 @@ urlpatterns = [
     path('racks/<int:pk>/', views.RackView.as_view(), name='rack'),
     path('racks/<int:pk>/edit/', views.RackEditView.as_view(), name='rack_edit'),
     path('racks/<int:pk>/delete/', views.RackDeleteView.as_view(), name='rack_delete'),
-    path('racks/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}),
-    path('racks/<int:pk>/journal/', ObjectJournalView.as_view(), name='rack_journal', kwargs={'model': Rack}),
+    path('racks/<int:pk>/', include(get_model_urls('dcim', 'rack'))),
 
     # Manufacturers
     path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'),
@@ -97,7 +96,7 @@ urlpatterns = [
     path('manufacturers/<int:pk>/', views.ManufacturerView.as_view(), name='manufacturer'),
     path('manufacturers/<int:pk>/edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'),
     path('manufacturers/<int:pk>/delete/', views.ManufacturerDeleteView.as_view(), name='manufacturer_delete'),
-    path('manufacturers/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}),
+    path('manufacturers/<int:pk>/', include(get_model_urls('dcim', 'manufacturer'))),
 
     # Device types
     path('device-types/', views.DeviceTypeListView.as_view(), name='devicetype_list'),
@@ -106,20 +105,9 @@ urlpatterns = [
     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/<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>/delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'),
-    path('device-types/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}),
-    path('device-types/<int:pk>/journal/', ObjectJournalView.as_view(), name='devicetype_journal', kwargs={'model': DeviceType}),
+    path('device-types/<int:pk>/', include(get_model_urls('dcim', 'devicetype'))),
 
     # Module types
     path('module-types/', views.ModuleTypeListView.as_view(), name='moduletype_list'),
@@ -128,17 +116,9 @@ urlpatterns = [
     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/<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>/delete/', views.ModuleTypeDeleteView.as_view(), name='moduletype_delete'),
-    path('module-types/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='moduletype_changelog', kwargs={'model': ModuleType}),
-    path('module-types/<int:pk>/journal/', ObjectJournalView.as_view(), name='moduletype_journal', kwargs={'model': ModuleType}),
+    path('module-types/<int:pk>/', include(get_model_urls('dcim', 'moduletype'))),
 
     # Console port templates
     path('console-port-templates/add/', views.ConsolePortTemplateCreateView.as_view(), name='consoleporttemplate_add'),
@@ -229,7 +209,7 @@ urlpatterns = [
     path('device-roles/<int:pk>/', views.DeviceRoleView.as_view(), name='devicerole'),
     path('device-roles/<int:pk>/edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'),
     path('device-roles/<int:pk>/delete/', views.DeviceRoleDeleteView.as_view(), name='devicerole_delete'),
-    path('device-roles/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}),
+    path('device-roles/<int:pk>/', include(get_model_urls('dcim', 'devicerole'))),
 
     # Platforms
     path('platforms/', views.PlatformListView.as_view(), name='platform_list'),
@@ -240,7 +220,7 @@ urlpatterns = [
     path('platforms/<int:pk>/', views.PlatformView.as_view(), name='platform'),
     path('platforms/<int:pk>/edit/', views.PlatformEditView.as_view(), name='platform_edit'),
     path('platforms/<int:pk>/delete/', views.PlatformDeleteView.as_view(), name='platform_delete'),
-    path('platforms/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}),
+    path('platforms/<int:pk>/', include(get_model_urls('dcim', 'platform'))),
 
     # Devices
     path('devices/', views.DeviceListView.as_view(), name='device_list'),
@@ -253,22 +233,7 @@ urlpatterns = [
     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>/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>/config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'),
-    path('devices/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}),
-    path('devices/<int:pk>/journal/', ObjectJournalView.as_view(), name='device_journal', kwargs={'model': Device}),
-    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>/config/', views.DeviceConfigView.as_view(), name='device_config'),
+    path('devices/<int:pk>/', include(get_model_urls('dcim', 'device'))),
 
     # Modules
     path('modules/', views.ModuleListView.as_view(), name='module_list'),
@@ -279,8 +244,7 @@ urlpatterns = [
     path('modules/<int:pk>/', views.ModuleView.as_view(), name='module'),
     path('modules/<int:pk>/edit/', views.ModuleEditView.as_view(), name='module_edit'),
     path('modules/<int:pk>/delete/', views.ModuleDeleteView.as_view(), name='module_delete'),
-    path('modules/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='module_changelog', kwargs={'model': Module}),
-    path('modules/<int:pk>/journal/', ObjectJournalView.as_view(), name='module_journal', kwargs={'model': Module}),
+    path('modules/<int:pk>/', include(get_model_urls('dcim', 'module'))),
 
     # Console ports
     path('console-ports/', views.ConsolePortListView.as_view(), name='consoleport_list'),
@@ -293,8 +257,8 @@ urlpatterns = [
     path('console-ports/<int:pk>/', views.ConsolePortView.as_view(), name='consoleport'),
     path('console-ports/<int:pk>/edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
     path('console-ports/<int:pk>/delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
-    path('console-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='consoleport_changelog', kwargs={'model': ConsolePort}),
     path('console-ports/<int:pk>/trace/', views.PathTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}),
+    path('console-ports/<int:pk>/', include(get_model_urls('dcim', 'consoleport'))),
     path('devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
 
     # Console server ports
@@ -308,8 +272,8 @@ urlpatterns = [
     path('console-server-ports/<int:pk>/', views.ConsoleServerPortView.as_view(), name='consoleserverport'),
     path('console-server-ports/<int:pk>/edit/', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'),
     path('console-server-ports/<int:pk>/delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
-    path('console-server-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='consoleserverport_changelog', kwargs={'model': ConsoleServerPort}),
     path('console-server-ports/<int:pk>/trace/', views.PathTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}),
+    path('console-server-ports/<int:pk>/', include(get_model_urls('dcim', 'consoleserverport'))),
     path('devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'),
 
     # Power ports
@@ -323,8 +287,8 @@ urlpatterns = [
     path('power-ports/<int:pk>/', views.PowerPortView.as_view(), name='powerport'),
     path('power-ports/<int:pk>/edit/', views.PowerPortEditView.as_view(), name='powerport_edit'),
     path('power-ports/<int:pk>/delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
-    path('power-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerport_changelog', kwargs={'model': PowerPort}),
     path('power-ports/<int:pk>/trace/', views.PathTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}),
+    path('power-ports/<int:pk>/', include(get_model_urls('dcim', 'powerport'))),
     path('devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
 
     # Power outlets
@@ -338,8 +302,8 @@ urlpatterns = [
     path('power-outlets/<int:pk>/', views.PowerOutletView.as_view(), name='poweroutlet'),
     path('power-outlets/<int:pk>/edit/', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'),
     path('power-outlets/<int:pk>/delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
-    path('power-outlets/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='poweroutlet_changelog', kwargs={'model': PowerOutlet}),
     path('power-outlets/<int:pk>/trace/', views.PathTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}),
+    path('power-outlets/<int:pk>/', include(get_model_urls('dcim', 'poweroutlet'))),
     path('devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'),
 
     # Interfaces
@@ -353,8 +317,8 @@ urlpatterns = [
     path('interfaces/<int:pk>/', views.InterfaceView.as_view(), name='interface'),
     path('interfaces/<int:pk>/edit/', views.InterfaceEditView.as_view(), name='interface_edit'),
     path('interfaces/<int:pk>/delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'),
-    path('interfaces/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}),
     path('interfaces/<int:pk>/trace/', views.PathTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}),
+    path('interfaces/<int:pk>/', include(get_model_urls('dcim', 'interface'))),
     path('devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
 
     # Front ports
@@ -368,8 +332,8 @@ urlpatterns = [
     path('front-ports/<int:pk>/', views.FrontPortView.as_view(), name='frontport'),
     path('front-ports/<int:pk>/edit/', views.FrontPortEditView.as_view(), name='frontport_edit'),
     path('front-ports/<int:pk>/delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'),
-    path('front-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='frontport_changelog', kwargs={'model': FrontPort}),
     path('front-ports/<int:pk>/trace/', views.PathTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}),
+    path('front-ports/<int:pk>/', include(get_model_urls('dcim', 'frontport'))),
     # path('devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'),
 
     # Rear ports
@@ -383,8 +347,8 @@ urlpatterns = [
     path('rear-ports/<int:pk>/', views.RearPortView.as_view(), name='rearport'),
     path('rear-ports/<int:pk>/edit/', views.RearPortEditView.as_view(), name='rearport_edit'),
     path('rear-ports/<int:pk>/delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'),
-    path('rear-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rearport_changelog', kwargs={'model': RearPort}),
     path('rear-ports/<int:pk>/trace/', views.PathTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}),
+    path('rear-ports/<int:pk>/', include(get_model_urls('dcim', 'rearport'))),
     path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'),
 
     # Module bays
@@ -397,7 +361,7 @@ urlpatterns = [
     path('module-bays/<int:pk>/', views.ModuleBayView.as_view(), name='modulebay'),
     path('module-bays/<int:pk>/edit/', views.ModuleBayEditView.as_view(), name='modulebay_edit'),
     path('module-bays/<int:pk>/delete/', views.ModuleBayDeleteView.as_view(), name='modulebay_delete'),
-    path('module-bays/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='modulebay_changelog', kwargs={'model': ModuleBay}),
+    path('module-bays/<int:pk>/', include(get_model_urls('dcim', 'modulebay'))),
     path('devices/module-bays/add/', views.DeviceBulkAddModuleBayView.as_view(), name='device_bulk_add_modulebay'),
 
     # Device bays
@@ -410,9 +374,9 @@ urlpatterns = [
     path('device-bays/<int:pk>/', views.DeviceBayView.as_view(), name='devicebay'),
     path('device-bays/<int:pk>/edit/', views.DeviceBayEditView.as_view(), name='devicebay_edit'),
     path('device-bays/<int:pk>/delete/', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'),
-    path('device-bays/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='devicebay_changelog', kwargs={'model': DeviceBay}),
     path('device-bays/<int:pk>/populate/', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'),
     path('device-bays/<int:pk>/depopulate/', views.DeviceBayDepopulateView.as_view(), name='devicebay_depopulate'),
+    path('device-bays/<int:pk>/', include(get_model_urls('dcim', 'devicebay'))),
     path('devices/device-bays/add/', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'),
 
     # Inventory items
@@ -425,10 +389,10 @@ urlpatterns = [
     path('inventory-items/<int:pk>/', views.InventoryItemView.as_view(), name='inventoryitem'),
     path('inventory-items/<int:pk>/edit/', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'),
     path('inventory-items/<int:pk>/delete/', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'),
-    path('inventory-items/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='inventoryitem_changelog', kwargs={'model': InventoryItem}),
+    path('inventory-items/<int:pk>/', include(get_model_urls('dcim', 'inventoryitem'))),
     path('devices/inventory-items/add/', views.DeviceBulkAddInventoryItemView.as_view(), name='device_bulk_add_inventoryitem'),
 
-    # Device roles
+    # Inventory item roles
     path('inventory-item-roles/', views.InventoryItemRoleListView.as_view(), name='inventoryitemrole_list'),
     path('inventory-item-roles/add/', views.InventoryItemRoleEditView.as_view(), name='inventoryitemrole_add'),
     path('inventory-item-roles/import/', views.InventoryItemRoleBulkImportView.as_view(), name='inventoryitemrole_import'),
@@ -437,7 +401,7 @@ urlpatterns = [
     path('inventory-item-roles/<int:pk>/', views.InventoryItemRoleView.as_view(), name='inventoryitemrole'),
     path('inventory-item-roles/<int:pk>/edit/', views.InventoryItemRoleEditView.as_view(), name='inventoryitemrole_edit'),
     path('inventory-item-roles/<int:pk>/delete/', views.InventoryItemRoleDeleteView.as_view(), name='inventoryitemrole_delete'),
-    path('inventory-item-roles/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='inventoryitemrole_changelog', kwargs={'model': InventoryItemRole}),
+    path('inventory-item-roles/<int:pk>/', include(get_model_urls('dcim', 'inventoryitemrole'))),
 
     # Cables
     path('cables/', views.CableListView.as_view(), name='cable_list'),
@@ -448,8 +412,7 @@ urlpatterns = [
     path('cables/<int:pk>/', views.CableView.as_view(), name='cable'),
     path('cables/<int:pk>/edit/', views.CableEditView.as_view(), name='cable_edit'),
     path('cables/<int:pk>/delete/', views.CableDeleteView.as_view(), name='cable_delete'),
-    path('cables/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='cable_changelog', kwargs={'model': Cable}),
-    path('cables/<int:pk>/journal/', ObjectJournalView.as_view(), name='cable_journal', kwargs={'model': Cable}),
+    path('cables/<int:pk>/', include(get_model_urls('dcim', 'cable'))),
 
     # Console/power/interface connections (read-only)
     path('console-connections/', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'),
@@ -465,9 +428,8 @@ urlpatterns = [
     path('virtual-chassis/<int:pk>/', views.VirtualChassisView.as_view(), name='virtualchassis'),
     path('virtual-chassis/<int:pk>/edit/', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'),
     path('virtual-chassis/<int:pk>/delete/', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'),
-    path('virtual-chassis/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='virtualchassis_changelog', kwargs={'model': VirtualChassis}),
-    path('virtual-chassis/<int:pk>/journal/', ObjectJournalView.as_view(), name='virtualchassis_journal', kwargs={'model': VirtualChassis}),
     path('virtual-chassis/<int:pk>/add-member/', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'),
+    path('virtual-chassis/<int:pk>/', include(get_model_urls('dcim', 'virtualchassis'))),
     path('virtual-chassis-members/<int:pk>/delete/', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'),
 
     # Power panels
@@ -479,8 +441,7 @@ urlpatterns = [
     path('power-panels/<int:pk>/', views.PowerPanelView.as_view(), name='powerpanel'),
     path('power-panels/<int:pk>/edit/', views.PowerPanelEditView.as_view(), name='powerpanel_edit'),
     path('power-panels/<int:pk>/delete/', views.PowerPanelDeleteView.as_view(), name='powerpanel_delete'),
-    path('power-panels/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerpanel_changelog', kwargs={'model': PowerPanel}),
-    path('power-panels/<int:pk>/journal/', ObjectJournalView.as_view(), name='powerpanel_journal', kwargs={'model': PowerPanel}),
+    path('power-panels/<int:pk>/', include(get_model_urls('dcim', 'powerpanel'))),
 
     # Power feeds
     path('power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'),
@@ -493,7 +454,6 @@ urlpatterns = [
     path('power-feeds/<int:pk>/edit/', views.PowerFeedEditView.as_view(), name='powerfeed_edit'),
     path('power-feeds/<int:pk>/delete/', views.PowerFeedDeleteView.as_view(), name='powerfeed_delete'),
     path('power-feeds/<int:pk>/trace/', views.PathTraceView.as_view(), name='powerfeed_trace', kwargs={'model': PowerFeed}),
-    path('power-feeds/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}),
-    path('power-feeds/<int:pk>/journal/', ObjectJournalView.as_view(), name='powerfeed_journal', kwargs={'model': PowerFeed}),
+    path('power-feeds/<int:pk>/', include(get_model_urls('dcim', 'powerfeed'))),
 
 ]

+ 237 - 55
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.utils.html import escape
 from django.utils.safestring import mark_safe
+from django.utils.translation import gettext as _
 from django.views.generic import View
 
 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.permissions import get_permission_for_model
 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 . import filtersets, forms, tables
 from .choices import DeviceFaceChoices
@@ -45,11 +46,6 @@ class DeviceComponentsView(generic.ObjectChildrenView):
     def get_children(self, request, parent):
         return self.child_model.objects.restrict(request.user, 'view').filter(device=parent)
 
-    def get_extra_context(self, request, instance):
-        return {
-            'active_tab': f"{self.child_model._meta.verbose_name_plural.replace(' ', '-')}",
-        }
-
 
 class DeviceTypeComponentsView(DeviceComponentsView):
     queryset = DeviceType.objects.all()
@@ -60,10 +56,9 @@ class DeviceTypeComponentsView(DeviceComponentsView):
         return self.child_model.objects.restrict(request.user, 'view').filter(device_type=parent)
 
     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
+        return {
+            'return_url': reverse(self.viewname, kwargs={'pk': instance.pk}),
+        }
 
 
 class ModuleTypeComponentsView(DeviceComponentsView):
@@ -75,10 +70,9 @@ class ModuleTypeComponentsView(DeviceComponentsView):
         return self.child_model.objects.restrict(request.user, 'view').filter(module_type=parent)
 
     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
+        return {
+            'return_url': reverse(self.viewname, kwargs={'pk': instance.pk}),
+        }
 
 
 class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
@@ -857,74 +851,134 @@ class DeviceTypeView(generic.ObjectView):
         }
 
 
+@register_model_view(DeviceType, 'consoleports', path='console-ports')
 class DeviceTypeConsolePortsView(DeviceTypeComponentsView):
     child_model = ConsolePortTemplate
     table = tables.ConsolePortTemplateTable
     filterset = filtersets.ConsolePortTemplateFilterSet
     viewname = 'dcim:devicetype_consoleports'
+    tab = ViewTab(
+        label=_('Console Ports'),
+        badge=lambda obj: obj.consoleporttemplates.count(),
+        permission='dcim.view_consoleporttemplate'
+    )
 
 
+@register_model_view(DeviceType, 'consoleserverports', path='console-server-ports')
 class DeviceTypeConsoleServerPortsView(DeviceTypeComponentsView):
     child_model = ConsoleServerPortTemplate
     table = tables.ConsoleServerPortTemplateTable
     filterset = filtersets.ConsoleServerPortTemplateFilterSet
     viewname = 'dcim:devicetype_consoleserverports'
+    tab = ViewTab(
+        label=_('Console Server Ports'),
+        badge=lambda obj: obj.consoleserverporttemplates.count(),
+        permission='dcim.view_consoleserverporttemplate'
+    )
 
 
+@register_model_view(DeviceType, 'powerports', path='power-ports')
 class DeviceTypePowerPortsView(DeviceTypeComponentsView):
     child_model = PowerPortTemplate
     table = tables.PowerPortTemplateTable
     filterset = filtersets.PowerPortTemplateFilterSet
     viewname = 'dcim:devicetype_powerports'
+    tab = ViewTab(
+        label=_('Power Ports'),
+        badge=lambda obj: obj.powerporttemplates.count(),
+        permission='dcim.view_powerporttemplate'
+    )
 
 
+@register_model_view(DeviceType, 'poweroutlets', path='power-outlets')
 class DeviceTypePowerOutletsView(DeviceTypeComponentsView):
     child_model = PowerOutletTemplate
     table = tables.PowerOutletTemplateTable
     filterset = filtersets.PowerOutletTemplateFilterSet
     viewname = 'dcim:devicetype_poweroutlets'
+    tab = ViewTab(
+        label=_('Power Outlets'),
+        badge=lambda obj: obj.poweroutlettemplates.count(),
+        permission='dcim.view_poweroutlettemplate'
+    )
 
 
+@register_model_view(DeviceType, 'interfaces')
 class DeviceTypeInterfacesView(DeviceTypeComponentsView):
     child_model = InterfaceTemplate
     table = tables.InterfaceTemplateTable
     filterset = filtersets.InterfaceTemplateFilterSet
     viewname = 'dcim:devicetype_interfaces'
+    tab = ViewTab(
+        label=_('Interfaces'),
+        badge=lambda obj: obj.interfacetemplates.count(),
+        permission='dcim.view_interfacetemplate'
+    )
 
 
+@register_model_view(DeviceType, 'frontports', path='front-ports')
 class DeviceTypeFrontPortsView(DeviceTypeComponentsView):
     child_model = FrontPortTemplate
     table = tables.FrontPortTemplateTable
     filterset = filtersets.FrontPortTemplateFilterSet
     viewname = 'dcim:devicetype_frontports'
+    tab = ViewTab(
+        label=_('Front Ports'),
+        badge=lambda obj: obj.frontporttemplates.count(),
+        permission='dcim.view_frontporttemplate'
+    )
 
 
+@register_model_view(DeviceType, 'rearports', path='rear-ports')
 class DeviceTypeRearPortsView(DeviceTypeComponentsView):
     child_model = RearPortTemplate
     table = tables.RearPortTemplateTable
     filterset = filtersets.RearPortTemplateFilterSet
     viewname = 'dcim:devicetype_rearports'
+    tab = ViewTab(
+        label=_('Rear Ports'),
+        badge=lambda obj: obj.rearporttemplates.count(),
+        permission='dcim.view_rearporttemplate'
+    )
 
 
+@register_model_view(DeviceType, 'modulebays', path='module-bays')
 class DeviceTypeModuleBaysView(DeviceTypeComponentsView):
     child_model = ModuleBayTemplate
     table = tables.ModuleBayTemplateTable
     filterset = filtersets.ModuleBayTemplateFilterSet
     viewname = 'dcim:devicetype_modulebays'
+    tab = ViewTab(
+        label=_('Module Bays'),
+        badge=lambda obj: obj.modulebaytemplates.count(),
+        permission='dcim.view_modulebaytemplate'
+    )
 
 
+@register_model_view(DeviceType, 'devicebays', path='device-bays')
 class DeviceTypeDeviceBaysView(DeviceTypeComponentsView):
     child_model = DeviceBayTemplate
     table = tables.DeviceBayTemplateTable
     filterset = filtersets.DeviceBayTemplateFilterSet
     viewname = 'dcim:devicetype_devicebays'
+    tab = ViewTab(
+        label=_('Device Bays'),
+        badge=lambda obj: obj.devicebaytemplates.count(),
+        permission='dcim.view_devicebaytemplate'
+    )
 
 
+@register_model_view(DeviceType, 'inventoryitems', path='inventory-items')
 class DeviceTypeInventoryItemsView(DeviceTypeComponentsView):
     child_model = InventoryItemTemplate
     table = tables.InventoryItemTemplateTable
     filterset = filtersets.InventoryItemTemplateFilterSet
     viewname = 'dcim:devicetype_inventoryitems'
+    tab = ViewTab(
+        label=_('Inventory Items'),
+        badge=lambda obj: obj.inventoryitemtemplates.count(),
+        permission='dcim.view_invenotryitemtemplate'
+    )
 
 
 class DeviceTypeEditView(generic.ObjectEditView):
@@ -1011,53 +1065,95 @@ class ModuleTypeView(generic.ObjectView):
         }
 
 
+@register_model_view(ModuleType, 'consoleports', path='console-ports')
 class ModuleTypeConsolePortsView(ModuleTypeComponentsView):
     child_model = ConsolePortTemplate
     table = tables.ConsolePortTemplateTable
     filterset = filtersets.ConsolePortTemplateFilterSet
     viewname = 'dcim:moduletype_consoleports'
+    tab = ViewTab(
+        label=_('Console Ports'),
+        badge=lambda obj: obj.consoleporttemplates.count(),
+        permission='dcim.view_consoleporttemplate'
+    )
 
 
+@register_model_view(ModuleType, 'consoleserverports', path='console-server-ports')
 class ModuleTypeConsoleServerPortsView(ModuleTypeComponentsView):
     child_model = ConsoleServerPortTemplate
     table = tables.ConsoleServerPortTemplateTable
     filterset = filtersets.ConsoleServerPortTemplateFilterSet
     viewname = 'dcim:moduletype_consoleserverports'
+    tab = ViewTab(
+        label=_('Console Server Ports'),
+        badge=lambda obj: obj.consoleserverporttemplates.count(),
+        permission='dcim.view_consoleserverporttemplate'
+    )
 
 
+@register_model_view(ModuleType, 'powerports', path='power-ports')
 class ModuleTypePowerPortsView(ModuleTypeComponentsView):
     child_model = PowerPortTemplate
     table = tables.PowerPortTemplateTable
     filterset = filtersets.PowerPortTemplateFilterSet
     viewname = 'dcim:moduletype_powerports'
+    tab = ViewTab(
+        label=_('Power Ports'),
+        badge=lambda obj: obj.powerporttemplates.count(),
+        permission='dcim.view_powerporttemplate'
+    )
 
 
+@register_model_view(ModuleType, 'poweroutlets', path='power-outlets')
 class ModuleTypePowerOutletsView(ModuleTypeComponentsView):
     child_model = PowerOutletTemplate
     table = tables.PowerOutletTemplateTable
     filterset = filtersets.PowerOutletTemplateFilterSet
     viewname = 'dcim:moduletype_poweroutlets'
+    tab = ViewTab(
+        label=_('Power Outlets'),
+        badge=lambda obj: obj.poweroutlettemplates.count(),
+        permission='dcim.view_poweroutlettemplate'
+    )
 
 
+@register_model_view(ModuleType, 'interfaces')
 class ModuleTypeInterfacesView(ModuleTypeComponentsView):
     child_model = InterfaceTemplate
     table = tables.InterfaceTemplateTable
     filterset = filtersets.InterfaceTemplateFilterSet
     viewname = 'dcim:moduletype_interfaces'
+    tab = ViewTab(
+        label=_('Interfaces'),
+        badge=lambda obj: obj.interfacetemplates.count(),
+        permission='dcim.view_interfacetemplate'
+    )
 
 
+@register_model_view(ModuleType, 'frontports', path='front-ports')
 class ModuleTypeFrontPortsView(ModuleTypeComponentsView):
     child_model = FrontPortTemplate
     table = tables.FrontPortTemplateTable
     filterset = filtersets.FrontPortTemplateFilterSet
     viewname = 'dcim:moduletype_frontports'
+    tab = ViewTab(
+        label=_('Front Ports'),
+        badge=lambda obj: obj.frontporttemplates.count(),
+        permission='dcim.view_frontporttemplate'
+    )
 
 
+@register_model_view(ModuleType, 'rearports', path='rear-ports')
 class ModuleTypeRearPortsView(ModuleTypeComponentsView):
     child_model = RearPortTemplate
     table = tables.RearPortTemplateTable
     filterset = filtersets.RearPortTemplateFilterSet
     viewname = 'dcim:moduletype_rearports'
+    tab = ViewTab(
+        label=_('Rear Ports'),
+        badge=lambda obj: obj.rearporttemplates.count(),
+        permission='dcim.view_rearporttemplate'
+    )
 
 
 class ModuleTypeEditView(generic.ObjectEditView):
@@ -1620,39 +1716,69 @@ class DeviceView(generic.ObjectView):
         }
 
 
+@register_model_view(Device, 'consoleports', path='console-ports')
 class DeviceConsolePortsView(DeviceComponentsView):
     child_model = ConsolePort
     table = tables.DeviceConsolePortTable
     filterset = filtersets.ConsolePortFilterSet
     template_name = 'dcim/device/consoleports.html'
+    tab = ViewTab(
+        label=_('Console Ports'),
+        badge=lambda obj: obj.consoleports.count(),
+        permission='dcim.view_consoleport'
+    )
 
 
+@register_model_view(Device, 'consoleserverports', path='console-server-ports')
 class DeviceConsoleServerPortsView(DeviceComponentsView):
     child_model = ConsoleServerPort
     table = tables.DeviceConsoleServerPortTable
     filterset = filtersets.ConsoleServerPortFilterSet
     template_name = 'dcim/device/consoleserverports.html'
+    tab = ViewTab(
+        label=_('Console Server Ports'),
+        badge=lambda obj: obj.consoleserverports.count(),
+        permission='dcim.view_consoleserverport'
+    )
 
 
+@register_model_view(Device, 'powerports', path='power-ports')
 class DevicePowerPortsView(DeviceComponentsView):
     child_model = PowerPort
     table = tables.DevicePowerPortTable
     filterset = filtersets.PowerPortFilterSet
     template_name = 'dcim/device/powerports.html'
+    tab = ViewTab(
+        label=_('Power Ports'),
+        badge=lambda obj: obj.powerports.count(),
+        permission='dcim.view_powerport'
+    )
 
 
+@register_model_view(Device, 'poweroutlets', path='power-outlets')
 class DevicePowerOutletsView(DeviceComponentsView):
     child_model = PowerOutlet
     table = tables.DevicePowerOutletTable
     filterset = filtersets.PowerOutletFilterSet
     template_name = 'dcim/device/poweroutlets.html'
+    tab = ViewTab(
+        label=_('Power Outlets'),
+        badge=lambda obj: obj.poweroutlets.count(),
+        permission='dcim.view_poweroutlet'
+    )
 
 
+@register_model_view(Device, 'interfaces')
 class DeviceInterfacesView(DeviceComponentsView):
     child_model = Interface
     table = tables.DeviceInterfaceTable
     filterset = filtersets.InterfaceFilterSet
     template_name = 'dcim/device/interfaces.html'
+    tab = ViewTab(
+        label=_('Interfaces'),
+        badge=lambda obj: obj.interfaces.count(),
+        permission='dcim.view_interface'
+    )
 
     def get_children(self, request, parent):
         return parent.vc_interfaces().restrict(request.user, 'view').prefetch_related(
@@ -1661,84 +1787,79 @@ class DeviceInterfacesView(DeviceComponentsView):
         )
 
 
+@register_model_view(Device, 'frontports', path='front-ports')
 class DeviceFrontPortsView(DeviceComponentsView):
     child_model = FrontPort
     table = tables.DeviceFrontPortTable
     filterset = filtersets.FrontPortFilterSet
     template_name = 'dcim/device/frontports.html'
+    tab = ViewTab(
+        label=_('Front Ports'),
+        badge=lambda obj: obj.frontports.count(),
+        permission='dcim.view_frontport'
+    )
 
 
+@register_model_view(Device, 'rearports', path='rear-ports')
 class DeviceRearPortsView(DeviceComponentsView):
     child_model = RearPort
     table = tables.DeviceRearPortTable
     filterset = filtersets.RearPortFilterSet
     template_name = 'dcim/device/rearports.html'
+    tab = ViewTab(
+        label=_('Rear Ports'),
+        badge=lambda obj: obj.rearports.count(),
+        permission='dcim.view_rearport'
+    )
 
 
+@register_model_view(Device, 'modulebays', path='module-bays')
 class DeviceModuleBaysView(DeviceComponentsView):
     child_model = ModuleBay
     table = tables.DeviceModuleBayTable
     filterset = filtersets.ModuleBayFilterSet
     template_name = 'dcim/device/modulebays.html'
+    tab = ViewTab(
+        label=_('Module Bays'),
+        badge=lambda obj: obj.modulebays.count(),
+        permission='dcim.view_modulebay'
+    )
 
 
+@register_model_view(Device, 'devicebays', path='device-bays')
 class DeviceDeviceBaysView(DeviceComponentsView):
     child_model = DeviceBay
     table = tables.DeviceDeviceBayTable
     filterset = filtersets.DeviceBayFilterSet
     template_name = 'dcim/device/devicebays.html'
+    tab = ViewTab(
+        label=_('Device Bays'),
+        badge=lambda obj: obj.devicebays.count(),
+        permission='dcim.view_devicebay'
+    )
 
 
+@register_model_view(Device, 'inventory')
 class DeviceInventoryView(DeviceComponentsView):
     child_model = InventoryItem
     table = tables.DeviceInventoryItemTable
     filterset = filtersets.InventoryItemFilterSet
     template_name = 'dcim/device/inventory.html'
+    tab = ViewTab(
+        label=_('Inventory Items'),
+        badge=lambda obj: obj.inventoryitems.count(),
+        permission='dcim.view_inventoryitem'
+    )
 
 
-class DeviceStatusView(generic.ObjectView):
-    additional_permissions = ['dcim.napalm_read_device']
-    queryset = Device.objects.all()
-    template_name = 'dcim/device/status.html'
-
-    def get_extra_context(self, request, instance):
-        return {
-            'active_tab': 'status',
-        }
-
-
-class DeviceLLDPNeighborsView(generic.ObjectView):
-    additional_permissions = ['dcim.napalm_read_device']
-    queryset = Device.objects.all()
-    template_name = 'dcim/device/lldp_neighbors.html'
-
-    def get_extra_context(self, request, instance):
-        interfaces = instance.vc_interfaces().restrict(request.user, 'view').prefetch_related(
-            '_path'
-        ).exclude(
-            type__in=NONCONNECTABLE_IFACE_TYPES
-        )
-
-        return {
-            'interfaces': interfaces,
-            'active_tab': 'lldp-neighbors',
-        }
-
-
-class DeviceConfigView(generic.ObjectView):
-    additional_permissions = ['dcim.napalm_read_device']
-    queryset = Device.objects.all()
-    template_name = 'dcim/device/config.html'
-
-    def get_extra_context(self, request, instance):
-        return {
-            'active_tab': 'config',
-        }
-
-
+@register_model_view(Device, 'configcontext', path='config-context')
 class DeviceConfigContextView(ObjectConfigContextView):
     queryset = Device.objects.annotate_config_context_data()
     base_template = 'dcim/device/base.html'
+    tab = ViewTab(
+        label=_('Config Context'),
+        permission='extras.view_configcontext'
+    )
 
 
 class DeviceEditView(generic.ObjectEditView):
@@ -1796,7 +1917,68 @@ class DeviceBulkRenameView(generic.BulkRenameView):
 
 
 #
-# Devices
+# Device NAPALM views
+#
+
+class NAPALMViewTab(ViewTab):
+
+    def render(self, instance):
+        # Display NAPALM tabs only for devices which meet certain requirements
+        if not (
+            instance.status == 'active' and
+            instance.primary_ip and
+            instance.platform.napalm_driver
+        ):
+            return None
+        return super().render(instance)
+
+
+@register_model_view(Device, 'status')
+class DeviceStatusView(generic.ObjectView):
+    additional_permissions = ['dcim.napalm_read_device']
+    queryset = Device.objects.all()
+    template_name = 'dcim/device/status.html'
+    tab = NAPALMViewTab(
+        label=_('Status'),
+        permission='dcim.napalm_read_device',
+    )
+
+
+@register_model_view(Device, 'lldp_neighbors', path='lldp-neighbors')
+class DeviceLLDPNeighborsView(generic.ObjectView):
+    additional_permissions = ['dcim.napalm_read_device']
+    queryset = Device.objects.all()
+    template_name = 'dcim/device/lldp_neighbors.html'
+    tab = NAPALMViewTab(
+        label=_('LLDP Neighbors'),
+        permission='dcim.napalm_read_device',
+    )
+
+    def get_extra_context(self, request, instance):
+        interfaces = instance.vc_interfaces().restrict(request.user, 'view').prefetch_related(
+            '_path'
+        ).exclude(
+            type__in=NONCONNECTABLE_IFACE_TYPES
+        )
+
+        return {
+            'interfaces': interfaces,
+        }
+
+
+@register_model_view(Device, 'config')
+class DeviceConfigView(generic.ObjectView):
+    additional_permissions = ['dcim.napalm_read_device']
+    queryset = Device.objects.all()
+    template_name = 'dcim/device/config.html'
+    tab = NAPALMViewTab(
+        label=_('Config'),
+        permission='dcim.napalm_read_device',
+    )
+
+
+#
+# Modules
 #
 
 class ModuleListView(generic.ObjectListView):

+ 1 - 0
netbox/extras/registry.py

@@ -29,3 +29,4 @@ registry['model_features'] = {
     feature: collections.defaultdict(set) for feature in EXTRAS_FEATURES
 }
 registry['denormalized_fields'] = collections.defaultdict(list)
+registry['views'] = collections.defaultdict(dict)

+ 9 - 0
netbox/extras/tests/dummy_plugin/views.py

@@ -1,6 +1,8 @@
 from django.http import HttpResponse
 from django.views.generic import View
 
+from dcim.models import Site
+from utilities.views import register_model_view
 from .models import DummyModel
 
 
@@ -9,3 +11,10 @@ class DummyModelsView(View):
     def get(self, request):
         instance_count = DummyModel.objects.count()
         return HttpResponse(f"Instances: {instance_count}")
+
+
+@register_model_view(Site, 'extra', path='other-stuff')
+class ExtraCoreModelView(View):
+
+    def get(self, request, pk):
+        return HttpResponse("Success!")

+ 11 - 0
netbox/extras/tests/test_plugins.py

@@ -59,6 +59,17 @@ class PluginTest(TestCase):
         response = client.get(url)
         self.assertEqual(response.status_code, 200)
 
+    def test_registered_views(self):
+
+        # Test URL resolution
+        url = reverse('dcim:site_extra', kwargs={'pk': 1})
+        self.assertEqual(url, '/dcim/sites/1/other-stuff/')
+
+        # Test GET request
+        client = Client()
+        response = client.get(url)
+        self.assertEqual(response.status_code, 200)
+
     def test_menu(self):
         """
         Check menu registration.

+ 10 - 17
netbox/extras/urls.py

@@ -1,7 +1,7 @@
-from django.urls import path, re_path
+from django.urls import include, path, re_path
 
-from extras import models, views
-from netbox.views.generic import ObjectChangeLogView
+from extras import views
+from utilities.urls import get_model_urls
 
 
 app_name = 'extras'
@@ -16,8 +16,7 @@ urlpatterns = [
     path('custom-fields/<int:pk>/', views.CustomFieldView.as_view(), name='customfield'),
     path('custom-fields/<int:pk>/edit/', views.CustomFieldEditView.as_view(), name='customfield_edit'),
     path('custom-fields/<int:pk>/delete/', views.CustomFieldDeleteView.as_view(), name='customfield_delete'),
-    path('custom-fields/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='customfield_changelog',
-         kwargs={'model': models.CustomField}),
+    path('custom-fields/<int:pk>/', include(get_model_urls('extras', 'customfield'))),
 
     # Custom links
     path('custom-links/', views.CustomLinkListView.as_view(), name='customlink_list'),
@@ -28,8 +27,7 @@ urlpatterns = [
     path('custom-links/<int:pk>/', views.CustomLinkView.as_view(), name='customlink'),
     path('custom-links/<int:pk>/edit/', views.CustomLinkEditView.as_view(), name='customlink_edit'),
     path('custom-links/<int:pk>/delete/', views.CustomLinkDeleteView.as_view(), name='customlink_delete'),
-    path('custom-links/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='customlink_changelog',
-         kwargs={'model': models.CustomLink}),
+    path('custom-links/<int:pk>/', include(get_model_urls('extras', 'customlink'))),
 
     # Export templates
     path('export-templates/', views.ExportTemplateListView.as_view(), name='exporttemplate_list'),
@@ -40,8 +38,7 @@ urlpatterns = [
     path('export-templates/<int:pk>/', views.ExportTemplateView.as_view(), name='exporttemplate'),
     path('export-templates/<int:pk>/edit/', views.ExportTemplateEditView.as_view(), name='exporttemplate_edit'),
     path('export-templates/<int:pk>/delete/', views.ExportTemplateDeleteView.as_view(), name='exporttemplate_delete'),
-    path('export-templates/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='exporttemplate_changelog',
-         kwargs={'model': models.ExportTemplate}),
+    path('export-templates/<int:pk>/', include(get_model_urls('extras', 'exporttemplate'))),
 
     # Webhooks
     path('webhooks/', views.WebhookListView.as_view(), name='webhook_list'),
@@ -52,8 +49,7 @@ urlpatterns = [
     path('webhooks/<int:pk>/', views.WebhookView.as_view(), name='webhook'),
     path('webhooks/<int:pk>/edit/', views.WebhookEditView.as_view(), name='webhook_edit'),
     path('webhooks/<int:pk>/delete/', views.WebhookDeleteView.as_view(), name='webhook_delete'),
-    path('webhooks/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='webhook_changelog',
-         kwargs={'model': models.Webhook}),
+    path('webhooks/<int:pk>/', include(get_model_urls('extras', 'webhook'))),
 
     # Tags
     path('tags/', views.TagListView.as_view(), name='tag_list'),
@@ -64,8 +60,7 @@ urlpatterns = [
     path('tags/<int:pk>/', views.TagView.as_view(), name='tag'),
     path('tags/<int:pk>/edit/', views.TagEditView.as_view(), name='tag_edit'),
     path('tags/<int:pk>/delete/', views.TagDeleteView.as_view(), name='tag_delete'),
-    path('tags/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='tag_changelog',
-         kwargs={'model': models.Tag}),
+    path('tags/<int:pk>/', include(get_model_urls('extras', 'tag'))),
 
     # Config contexts
     path('config-contexts/', views.ConfigContextListView.as_view(), name='configcontext_list'),
@@ -75,8 +70,7 @@ urlpatterns = [
     path('config-contexts/<int:pk>/', views.ConfigContextView.as_view(), name='configcontext'),
     path('config-contexts/<int:pk>/edit/', views.ConfigContextEditView.as_view(), name='configcontext_edit'),
     path('config-contexts/<int:pk>/delete/', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'),
-    path('config-contexts/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='configcontext_changelog',
-         kwargs={'model': models.ConfigContext}),
+    path('config-contexts/<int:pk>/', include(get_model_urls('extras', 'configcontext'))),
 
     # Image attachments
     path('image-attachments/add/', views.ImageAttachmentEditView.as_view(), name='imageattachment_add'),
@@ -91,8 +85,7 @@ urlpatterns = [
     path('journal-entries/<int:pk>/', views.JournalEntryView.as_view(), name='journalentry'),
     path('journal-entries/<int:pk>/edit/', views.JournalEntryEditView.as_view(), name='journalentry_edit'),
     path('journal-entries/<int:pk>/delete/', views.JournalEntryDeleteView.as_view(), name='journalentry_delete'),
-    path('journal-entries/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='journalentry_changelog',
-         kwargs={'model': models.JournalEntry}),
+    path('journal-entries/<int:pk>/', include(get_model_urls('extras', 'journalentry'))),
 
     # Change logging
     path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),

+ 0 - 1
netbox/extras/views.py

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

+ 19 - 39
netbox/ipam/urls.py

@@ -1,8 +1,7 @@
-from django.urls import path
+from django.urls import include, path
 
-from netbox.views.generic import ObjectChangeLogView, ObjectJournalView
+from utilities.urls import get_model_urls
 from . import views
-from .models import *
 
 app_name = 'ipam'
 urlpatterns = [
@@ -16,8 +15,7 @@ urlpatterns = [
     path('asns/<int:pk>/', views.ASNView.as_view(), name='asn'),
     path('asns/<int:pk>/edit/', views.ASNEditView.as_view(), name='asn_edit'),
     path('asns/<int:pk>/delete/', views.ASNDeleteView.as_view(), name='asn_delete'),
-    path('asns/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='asn_changelog', kwargs={'model': ASN}),
-    path('asns/<int:pk>/journal/', ObjectJournalView.as_view(), name='asn_journal', kwargs={'model': ASN}),
+    path('asns/<int:pk>/', include(get_model_urls('ipam', 'asn'))),
 
     # VRFs
     path('vrfs/', views.VRFListView.as_view(), name='vrf_list'),
@@ -28,8 +26,7 @@ urlpatterns = [
     path('vrfs/<int:pk>/', views.VRFView.as_view(), name='vrf'),
     path('vrfs/<int:pk>/edit/', views.VRFEditView.as_view(), name='vrf_edit'),
     path('vrfs/<int:pk>/delete/', views.VRFDeleteView.as_view(), name='vrf_delete'),
-    path('vrfs/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vrf_changelog', kwargs={'model': VRF}),
-    path('vrfs/<int:pk>/journal/', ObjectJournalView.as_view(), name='vrf_journal', kwargs={'model': VRF}),
+    path('vrfs/<int:pk>/', include(get_model_urls('ipam', 'vrf'))),
 
     # Route targets
     path('route-targets/', views.RouteTargetListView.as_view(), name='routetarget_list'),
@@ -40,8 +37,7 @@ urlpatterns = [
     path('route-targets/<int:pk>/', views.RouteTargetView.as_view(), name='routetarget'),
     path('route-targets/<int:pk>/edit/', views.RouteTargetEditView.as_view(), name='routetarget_edit'),
     path('route-targets/<int:pk>/delete/', views.RouteTargetDeleteView.as_view(), name='routetarget_delete'),
-    path('route-targets/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='routetarget_changelog', kwargs={'model': RouteTarget}),
-    path('route-targets/<int:pk>/journal/', ObjectJournalView.as_view(), name='routetarget_journal', kwargs={'model': RouteTarget}),
+    path('route-targets/<int:pk>/', include(get_model_urls('ipam', 'routetarget'))),
 
     # RIRs
     path('rirs/', views.RIRListView.as_view(), name='rir_list'),
@@ -52,7 +48,7 @@ urlpatterns = [
     path('rirs/<int:pk>/', views.RIRView.as_view(), name='rir'),
     path('rirs/<int:pk>/edit/', views.RIREditView.as_view(), name='rir_edit'),
     path('rirs/<int:pk>/delete/', views.RIRDeleteView.as_view(), name='rir_delete'),
-    path('rirs/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rir_changelog', kwargs={'model': RIR}),
+    path('rirs/<int:pk>/', include(get_model_urls('ipam', 'rir'))),
 
     # Aggregates
     path('aggregates/', views.AggregateListView.as_view(), name='aggregate_list'),
@@ -61,11 +57,9 @@ urlpatterns = [
     path('aggregates/edit/', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'),
     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>/prefixes/', views.AggregatePrefixesView.as_view(), name='aggregate_prefixes'),
     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>/changelog/', ObjectChangeLogView.as_view(), name='aggregate_changelog', kwargs={'model': Aggregate}),
-    path('aggregates/<int:pk>/journal/', ObjectJournalView.as_view(), name='aggregate_journal', kwargs={'model': Aggregate}),
+    path('aggregates/<int:pk>/', include(get_model_urls('ipam', 'aggregate'))),
 
     # Roles
     path('roles/', views.RoleListView.as_view(), name='role_list'),
@@ -76,7 +70,7 @@ urlpatterns = [
     path('roles/<int:pk>/', views.RoleView.as_view(), name='role'),
     path('roles/<int:pk>/edit/', views.RoleEditView.as_view(), name='role_edit'),
     path('roles/<int:pk>/delete/', views.RoleDeleteView.as_view(), name='role_delete'),
-    path('roles/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='role_changelog', kwargs={'model': Role}),
+    path('roles/<int:pk>/', include(get_model_urls('ipam', 'role'))),
 
     # Prefixes
     path('prefixes/', views.PrefixListView.as_view(), name='prefix_list'),
@@ -87,11 +81,7 @@ urlpatterns = [
     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>/delete/', views.PrefixDeleteView.as_view(), name='prefix_delete'),
-    path('prefixes/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='prefix_changelog', kwargs={'model': Prefix}),
-    path('prefixes/<int:pk>/journal/', ObjectJournalView.as_view(), name='prefix_journal', kwargs={'model': Prefix}),
-    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'))),
 
     # IP ranges
     path('ip-ranges/', views.IPRangeListView.as_view(), name='iprange_list'),
@@ -102,9 +92,7 @@ urlpatterns = [
     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>/delete/', views.IPRangeDeleteView.as_view(), name='iprange_delete'),
-    path('ip-ranges/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='iprange_changelog', kwargs={'model': IPRange}),
-    path('ip-ranges/<int:pk>/journal/', ObjectJournalView.as_view(), name='iprange_journal', kwargs={'model': IPRange}),
-    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'))),
 
     # IP addresses
     path('ip-addresses/', views.IPAddressListView.as_view(), name='ipaddress_list'),
@@ -113,12 +101,11 @@ urlpatterns = [
     path('ip-addresses/import/', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'),
     path('ip-addresses/edit/', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'),
     path('ip-addresses/delete/', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'),
-    path('ip-addresses/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='ipaddress_changelog', kwargs={'model': IPAddress}),
-    path('ip-addresses/<int:pk>/journal/', ObjectJournalView.as_view(), name='ipaddress_journal', kwargs={'model': IPAddress}),
     path('ip-addresses/assign/', views.IPAddressAssignView.as_view(), name='ipaddress_assign'),
     path('ip-addresses/<int:pk>/', views.IPAddressView.as_view(), name='ipaddress'),
     path('ip-addresses/<int:pk>/edit/', views.IPAddressEditView.as_view(), name='ipaddress_edit'),
     path('ip-addresses/<int:pk>/delete/', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'),
+    path('ip-addresses/<int:pk>/', include(get_model_urls('ipam', 'ipaddress'))),
 
     # FHRP groups
     path('fhrp-groups/', views.FHRPGroupListView.as_view(), name='fhrpgroup_list'),
@@ -129,8 +116,7 @@ urlpatterns = [
     path('fhrp-groups/<int:pk>/', views.FHRPGroupView.as_view(), name='fhrpgroup'),
     path('fhrp-groups/<int:pk>/edit/', views.FHRPGroupEditView.as_view(), name='fhrpgroup_edit'),
     path('fhrp-groups/<int:pk>/delete/', views.FHRPGroupDeleteView.as_view(), name='fhrpgroup_delete'),
-    path('fhrp-groups/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='fhrpgroup_changelog', kwargs={'model': FHRPGroup}),
-    path('fhrp-groups/<int:pk>/journal/', ObjectJournalView.as_view(), name='fhrpgroup_journal', kwargs={'model': FHRPGroup}),
+    path('fhrp-groups/<int:pk>/', include(get_model_urls('ipam', 'fhrpgroup'))),
 
     # FHRP group assignments
     path('fhrp-group-assignments/add/', views.FHRPGroupAssignmentEditView.as_view(), name='fhrpgroupassignment_add'),
@@ -146,7 +132,7 @@ urlpatterns = [
     path('vlan-groups/<int:pk>/', views.VLANGroupView.as_view(), name='vlangroup'),
     path('vlan-groups/<int:pk>/edit/', views.VLANGroupEditView.as_view(), name='vlangroup_edit'),
     path('vlan-groups/<int:pk>/delete/', views.VLANGroupDeleteView.as_view(), name='vlangroup_delete'),
-    path('vlan-groups/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vlangroup_changelog', kwargs={'model': VLANGroup}),
+    path('vlan-groups/<int:pk>/', include(get_model_urls('ipam', 'vlangroup'))),
 
     # VLANs
     path('vlans/', views.VLANListView.as_view(), name='vlan_list'),
@@ -155,12 +141,9 @@ urlpatterns = [
     path('vlans/edit/', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'),
     path('vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'),
     path('vlans/<int:pk>/', views.VLANView.as_view(), name='vlan'),
-    path('vlans/<int:pk>/interfaces/', views.VLANInterfacesView.as_view(), name='vlan_interfaces'),
-    path('vlans/<int:pk>/vm-interfaces/', views.VLANVMInterfacesView.as_view(), name='vlan_vminterfaces'),
     path('vlans/<int:pk>/edit/', views.VLANEditView.as_view(), name='vlan_edit'),
     path('vlans/<int:pk>/delete/', views.VLANDeleteView.as_view(), name='vlan_delete'),
-    path('vlans/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}),
-    path('vlans/<int:pk>/journal/', ObjectJournalView.as_view(), name='vlan_journal', kwargs={'model': VLAN}),
+    path('vlans/<int:pk>/', include(get_model_urls('ipam', 'vlan'))),
 
     # Service templates
     path('service-templates/', views.ServiceTemplateListView.as_view(), name='servicetemplate_list'),
@@ -171,8 +154,7 @@ urlpatterns = [
     path('service-templates/<int:pk>/', views.ServiceTemplateView.as_view(), name='servicetemplate'),
     path('service-templates/<int:pk>/edit/', views.ServiceTemplateEditView.as_view(), name='servicetemplate_edit'),
     path('service-templates/<int:pk>/delete/', views.ServiceTemplateDeleteView.as_view(), name='servicetemplate_delete'),
-    path('service-templates/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='servicetemplate_changelog', kwargs={'model': ServiceTemplate}),
-    path('service-templates/<int:pk>/journal/', ObjectJournalView.as_view(), name='servicetemplate_journal', kwargs={'model': ServiceTemplate}),
+    path('service-templates/<int:pk>/', include(get_model_urls('ipam', 'servicetemplate'))),
 
     # Services
     path('services/', views.ServiceListView.as_view(), name='service_list'),
@@ -183,8 +165,7 @@ urlpatterns = [
     path('services/<int:pk>/', views.ServiceView.as_view(), name='service'),
     path('services/<int:pk>/edit/', views.ServiceEditView.as_view(), name='service_edit'),
     path('services/<int:pk>/delete/', views.ServiceDeleteView.as_view(), name='service_delete'),
-    path('services/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}),
-    path('services/<int:pk>/journal/', ObjectJournalView.as_view(), name='service_journal', kwargs={'model': Service}),
+    path('services/<int:pk>/', include(get_model_urls('ipam', 'service'))),
 
     # L2VPN
     path('l2vpns/', views.L2VPNListView.as_view(), name='l2vpn_list'),
@@ -195,9 +176,9 @@ urlpatterns = [
     path('l2vpns/<int:pk>/', views.L2VPNView.as_view(), name='l2vpn'),
     path('l2vpns/<int:pk>/edit/', views.L2VPNEditView.as_view(), name='l2vpn_edit'),
     path('l2vpns/<int:pk>/delete/', views.L2VPNDeleteView.as_view(), name='l2vpn_delete'),
-    path('l2vpns/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='l2vpn_changelog', kwargs={'model': L2VPN}),
-    path('l2vpns/<int:pk>/journal/', ObjectJournalView.as_view(), name='l2vpn_journal', kwargs={'model': L2VPN}),
+    path('l2vpns/<int:pk>/', include(get_model_urls('ipam', 'l2vpn'))),
 
+    # L2VPN terminations
     path('l2vpn-terminations/', views.L2VPNTerminationListView.as_view(), name='l2vpntermination_list'),
     path('l2vpn-terminations/add/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_add'),
     path('l2vpn-terminations/import/', views.L2VPNTerminationBulkImportView.as_view(), name='l2vpntermination_import'),
@@ -206,6 +187,5 @@ urlpatterns = [
     path('l2vpn-terminations/<int:pk>/', views.L2VPNTerminationView.as_view(), name='l2vpntermination'),
     path('l2vpn-terminations/<int:pk>/edit/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_edit'),
     path('l2vpn-terminations/<int:pk>/delete/', views.L2VPNTerminationDeleteView.as_view(), name='l2vpntermination_delete'),
-    path('l2vpn-terminations/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='l2vpntermination_changelog', kwargs={'model': L2VPNTermination}),
-    path('l2vpn-terminations/<int:pk>/journal/', ObjectJournalView.as_view(), name='l2vpntermination_journal', kwargs={'model': L2VPNTermination}),
+    path('l2vpn-terminations/<int:pk>/', include(get_model_urls('ipam', 'l2vpntermination'))),
 ]

+ 44 - 19
netbox/ipam/views.py

@@ -3,6 +3,7 @@ from django.db.models import Prefetch
 from django.db.models.expressions import RawSQL
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
+from django.utils.translation import gettext as _
 
 from circuits.models import Provider, Circuit
 from circuits.tables import ProviderTable
@@ -11,6 +12,7 @@ from dcim.models import Interface, Site, Device
 from dcim.tables import SiteTable
 from netbox.views import generic
 from utilities.utils import count_related
+from utilities.views import ViewTab, register_model_view
 from virtualization.filtersets import VMInterfaceFilterSet
 from virtualization.models import VMInterface, VirtualMachine
 from . import filtersets, forms, tables
@@ -289,12 +291,18 @@ class AggregateView(generic.ObjectView):
     queryset = Aggregate.objects.all()
 
 
+@register_model_view(Aggregate, 'prefixes')
 class AggregatePrefixesView(generic.ObjectChildrenView):
     queryset = Aggregate.objects.all()
     child_model = Prefix
     table = tables.PrefixTable
     filterset = filtersets.PrefixFilterSet
     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):
         return Prefix.objects.restrict(request.user, 'view').filter(
@@ -311,7 +319,6 @@ class AggregatePrefixesView(generic.ObjectChildrenView):
     def get_extra_context(self, request, instance):
         return {
             'bulk_querystring': f'within={instance.prefix}',
-            'active_tab': 'prefixes',
             'first_available_prefix': instance.get_first_available_prefix(),
             'show_available': bool(request.GET.get('show_available', 'true') == 'true'),
             'show_assigned': bool(request.GET.get('show_assigned', 'true') == 'true'),
@@ -466,12 +473,18 @@ class PrefixView(generic.ObjectView):
         }
 
 
+@register_model_view(Prefix, 'prefixes')
 class PrefixPrefixesView(generic.ObjectChildrenView):
     queryset = Prefix.objects.all()
     child_model = Prefix
     table = tables.PrefixTable
     filterset = filtersets.PrefixFilterSet
     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):
         return parent.get_child_prefixes().restrict(request.user, 'view').prefetch_related(
@@ -488,19 +501,24 @@ class PrefixPrefixesView(generic.ObjectChildrenView):
     def get_extra_context(self, request, instance):
         return {
             'bulk_querystring': f"vrf_id={instance.vrf.pk if instance.vrf else '0'}&within={instance.prefix}",
-            'active_tab': 'prefixes',
             'first_available_prefix': instance.get_first_available_prefix(),
             'show_available': bool(request.GET.get('show_available', 'true') == 'true'),
             'show_assigned': bool(request.GET.get('show_assigned', 'true') == 'true'),
         }
 
 
+@register_model_view(Prefix, 'ipranges', path='ip-ranges')
 class PrefixIPRangesView(generic.ObjectChildrenView):
     queryset = Prefix.objects.all()
     child_model = IPRange
     table = tables.IPRangeTable
     filterset = filtersets.IPRangeFilterSet
     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):
         return parent.get_child_ranges().restrict(request.user, 'view').prefetch_related(
@@ -510,17 +528,22 @@ class PrefixIPRangesView(generic.ObjectChildrenView):
     def get_extra_context(self, request, instance):
         return {
             'bulk_querystring': f"vrf_id={instance.vrf.pk if instance.vrf else '0'}&parent={instance.prefix}",
-            'active_tab': 'ip-ranges',
             'first_available_ip': instance.get_first_available_ip(),
         }
 
 
+@register_model_view(Prefix, 'ipaddresses', path='ip-addresses')
 class PrefixIPAddressesView(generic.ObjectChildrenView):
     queryset = Prefix.objects.all()
     child_model = IPAddress
     table = tables.IPAddressTable
     filterset = filtersets.IPAddressFilterSet
     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):
         return parent.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf', 'tenant', 'tenant__group')
@@ -533,7 +556,6 @@ class PrefixIPAddressesView(generic.ObjectChildrenView):
     def get_extra_context(self, request, instance):
         return {
             'bulk_querystring': f"vrf_id={instance.vrf.pk if instance.vrf else '0'}&parent={instance.prefix}",
-            'active_tab': 'ip-addresses',
             'first_available_ip': instance.get_first_available_ip(),
         }
 
@@ -581,21 +603,22 @@ class IPRangeView(generic.ObjectView):
     queryset = IPRange.objects.all()
 
 
+@register_model_view(IPRange, 'ipaddresses', path='ip-addresses')
 class IPRangeIPAddressesView(generic.ObjectChildrenView):
     queryset = IPRange.objects.all()
     child_model = IPAddress
     table = tables.IPAddressTable
     filterset = filtersets.IPAddressFilterSet
     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):
         return parent.get_child_ips().restrict(request.user, 'view')
 
-    def get_extra_context(self, request, instance):
-        return {
-            'active_tab': 'ip-addresses',
-        }
-
 
 class IPRangeEditView(generic.ObjectEditView):
     queryset = IPRange.objects.all()
@@ -1000,37 +1023,39 @@ class VLANView(generic.ObjectView):
         }
 
 
+@register_model_view(VLAN, 'interfaces')
 class VLANInterfacesView(generic.ObjectChildrenView):
     queryset = VLAN.objects.all()
     child_model = Interface
     table = tables.VLANDevicesTable
     filterset = InterfaceFilterSet
     template_name = 'ipam/vlan/interfaces.html'
+    tab = ViewTab(
+        label=_('Device Interfaces'),
+        badge=lambda x: x.get_interfaces().count(),
+        permission='dcim.view_interface'
+    )
 
     def get_children(self, request, parent):
         return parent.get_interfaces().restrict(request.user, 'view')
 
-    def get_extra_context(self, request, instance):
-        return {
-            'active_tab': 'interfaces',
-        }
-
 
+@register_model_view(VLAN, 'vminterfaces', path='vm-interfaces')
 class VLANVMInterfacesView(generic.ObjectChildrenView):
     queryset = VLAN.objects.all()
     child_model = VMInterface
     table = tables.VLANVirtualMachinesTable
     filterset = VMInterfaceFilterSet
     template_name = 'ipam/vlan/vminterfaces.html'
+    tab = ViewTab(
+        label=_('VM Interfaces'),
+        badge=lambda x: x.get_vminterfaces().count(),
+        permission='virtualization.view_vminterface'
+    )
 
     def get_children(self, request, parent):
         return parent.get_vminterfaces().restrict(request.user, 'view')
 
-    def get_extra_context(self, request, instance):
-        return {
-            'active_tab': 'vminterfaces',
-        }
-
 
 class VLANEditView(generic.ObjectEditView):
     queryset = VLAN.objects.all()

+ 15 - 0
netbox/netbox/models/features.py

@@ -13,6 +13,7 @@ from extras.utils import is_taggable, register_features
 from netbox.signals import post_clean
 from utilities.json import CustomFieldJSONEncoder
 from utilities.utils import serialize_object
+from utilities.views import register_model_view
 
 __all__ = (
     'ChangeLoggingMixin',
@@ -292,3 +293,17 @@ def _register_features(sender, **kwargs):
         feature for feature, cls in FEATURES_MAP if issubclass(sender, cls)
     }
     register_features(sender, features)
+
+    # Feature view registration
+    if issubclass(sender, JournalingMixin):
+        register_model_view(
+            sender,
+            'journal',
+            kwargs={'model': sender}
+        )('netbox.views.generic.ObjectJournalView')
+    if issubclass(sender, ChangeLoggingMixin):
+        register_model_view(
+            sender,
+            'changelog',
+            kwargs={'model': sender}
+        )('netbox.views.generic.ObjectChangeLogView')

+ 13 - 2
netbox/netbox/views/generic/feature_views.py

@@ -1,10 +1,12 @@
 from django.contrib.contenttypes.models import ContentType
 from django.db.models import Q
 from django.shortcuts import get_object_or_404, render
+from django.utils.translation import gettext as _
 from django.views.generic import View
 
 from extras import forms, tables
 from extras.models import *
+from utilities.views import ViewTab
 
 __all__ = (
     'ObjectChangeLogView',
@@ -23,6 +25,10 @@ class ObjectChangeLogView(View):
         base_template: The name of the template to extend. If not provided, "{app}/{model}.html" will be used.
     """
     base_template = None
+    tab = ViewTab(
+        label=_('Changelog'),
+        permission='extras.view_objectchange'
+    )
 
     def get(self, request, model, **kwargs):
 
@@ -56,7 +62,7 @@ class ObjectChangeLogView(View):
             'object': obj,
             'table': objectchanges_table,
             'base_template': self.base_template,
-            'active_tab': 'changelog',
+            'tab': self.tab,
         })
 
 
@@ -71,6 +77,11 @@ class ObjectJournalView(View):
         base_template: The name of the template to extend. If not provided, "{app}/{model}.html" will be used.
     """
     base_template = None
+    tab = ViewTab(
+        label=_('Journal'),
+        badge=lambda obj: obj.journal_entries.count(),
+        permission='extras.view_journalentry'
+    )
 
     def get(self, request, model, **kwargs):
 
@@ -111,5 +122,5 @@ class ObjectJournalView(View):
             'form': form,
             'table': journalentry_table,
             'base_template': self.base_template,
-            'active_tab': 'journal',
+            'tab': self.tab,
         })

+ 7 - 1
netbox/netbox/views/generic/object_views.py

@@ -5,7 +5,6 @@ from django.contrib import messages
 from django.core.exceptions import ObjectDoesNotExist
 from django.db import transaction
 from django.db.models import ProtectedError
-from django.forms.widgets import HiddenInput
 from django.shortcuts import redirect, render
 from django.urls import reverse
 from django.utils.html import escape
@@ -38,7 +37,12 @@ class ObjectView(BaseObjectView):
     Retrieve a single object for display.
 
     Note: If `template_name` is not specified, it will be determined automatically based on the queryset model.
+
+    Attributes:
+        tab: A ViewTab instance for the view
     """
+    tab = None
+
     def get_required_permission(self):
         return get_permission_for_model(self.queryset.model, 'view')
 
@@ -67,6 +71,7 @@ class ObjectView(BaseObjectView):
 
         return render(request, self.get_template_name(), {
             'object': instance,
+            'tab': self.tab,
             **self.get_extra_context(request, instance),
         })
 
@@ -141,6 +146,7 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
             'child_model': self.child_model,
             'table': table,
             'actions': actions,
+            'tab': self.tab,
             **self.get_extra_context(request, instance),
         })
 

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

@@ -54,113 +54,3 @@
         </div>
     {% endif %}
 {% endblock %}
-
-{% 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 %}
-        {# NAPALM-enabled tabs #}
-        <li role="presentation" class="nav-item">
-            <a class="nav-link{% if active_tab == 'status' %} active{% endif %}" href="{% url 'dcim:device_status' pk=object.pk %}">
-                Status
-            </a>
-        </li>
-        <li role="presentation" class="nav-item">
-            <a class="nav-link{% if active_tab == 'lldp-neighbors' %} active{% endif %}" href="{% url 'dcim:device_lldp_neighbors' pk=object.pk %}">
-                LLDP Neighbors
-            </a>
-        </li>
-        <li role="presentation" class="nav-item">
-            <a class="nav-link{% if active_tab == 'config' %} active{% endif %}" href="{% url 'dcim:device_config' pk=object.pk %}">
-                Configuration
-            </a>
-        </li>
-    {% 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 %}

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

@@ -51,85 +51,3 @@
     </div>
   {% endif %}
 {% 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>
   {% endif %}
 {% 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 %}

+ 5 - 27
netbox/templates/generic/object.html

@@ -4,6 +4,7 @@
 {% load helpers %}
 {% load perms %}
 {% load plugins %}
+{% load tabs %}
 
 {% comment %}
 Blocks:
@@ -80,37 +81,14 @@ Context:
   <ul class="nav nav-tabs px-3">
     {# Primary tab #}
     <li class="nav-item" role="presentation">
-      <a class="nav-link{% if not active_tab %} active{% endif %}" href="{{ object.get_absolute_url }}">{{ object|meta:"verbose_name"|bettertitle }}</a>
+      <a class="nav-link{% if not tab %} active{% endif %}" href="{{ object.get_absolute_url }}">{{ object|meta:"verbose_name"|bettertitle }}</a>
     </li>
 
-    {# Include any additional tabs #}
+    {# Include any extra tabs passed by the view #}
     {% block extra_tabs %}{% endblock %}
 
-    {# Object journal #}
-    {% if perms.extras.view_journalentry %}
-      {% with journal_viewname=object|viewname:'journal' %}
-        {% url journal_viewname pk=object.pk as journal_url %}
-        {% if journal_url %}
-          <li role="presentation" class="nav-item">
-            <a href="{{ journal_url }}" class="nav-link{% if active_tab == 'journal'%} active{% endif %}">
-              Journal {% badge object.journal_entries.count %}
-            </a>
-          </li>
-        {% endif %}
-      {% endwith %}
-    {% endif %}
-
-    {# Object changelog #}
-    {% if perms.extras.view_objectchange %}
-      {% with changelog_viewname=object|viewname:'changelog' %}
-        {% url changelog_viewname pk=object.pk as changelog_url %}
-        {% if changelog_url %}
-          <li role="presentation" class="nav-item">
-              <a href="{{ changelog_url }}" class="nav-link{% if active_tab == 'changelog'%} active{% endif %}">Change Log</a>
-          </li>
-        {% endif %}
-      {% endwith %}
-    {% endif %}
+    {# Include tabs for registered model views #}
+    {% model_view_tabs object %}
   </ul>
 {% endblock tabs %}
 

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

@@ -6,13 +6,3 @@
   {{ block.super }}
   <li class="breadcrumb-item"><a href="{% url 'ipam:aggregate_list' %}?rir_id={{ object.rir.pk }}">{{ object.rir }}</a></li>
 {% 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>
   {% endif %}
 {% 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>
   {% endif %}
 {% 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 - 24
netbox/templates/ipam/vlan/base.html

@@ -13,27 +13,3 @@
     <li class="breadcrumb-item"><a href="{% url 'ipam:vlan_list' %}?group_id={{ object.group.pk }}">{{ object.group }}</a></li>
   {% endif %}
 {% endblock %}
-
-{% block tabs %}
-  <ul class="nav nav-tabs px-3">
-    <li class="nav-item" role="presentation">
-      <a class="nav-link{% if not active_tab %} active{% endif %}" href="{% url 'ipam:vlan' pk=object.pk %}">VLAN</a>
-    </li>
-    <li class="nav-item" role="presentation">
-      <a class="nav-link{% if active_tab == 'interfaces' %} active{% endif %}" href="{% url 'ipam:vlan_interfaces' pk=object.pk %}">Device Interfaces {% badge object.get_interfaces.count %}</a>
-    </li>
-    <li class="nav-item" role="presentation">
-      <a class="nav-link{% if active_tab == 'vminterfaces' %} active{% endif %}" href="{% url 'ipam:vlan_vminterfaces' pk=object.pk %}">VM Interfaces {% badge object.get_vminterfaces.count %}</a>
-    </li>
-    {% if perms.extras.view_journalentry %}
-      <li class="nav-item" role="presentation">
-        <a class="nav-link{% if active_tab == 'journal' %} active{% endif %}" href="{% url 'ipam:vlan_journal' pk=object.pk %}">Journal</a>
-      </li>
-    {% endif %}
-    {% if perms.extras.view_objectchange %}
-      <li class="nav-item" role="presentation">
-        <a class="nav-link{% if active_tab == 'changelog' %} active{% endif %}" href="{% url 'ipam:vlan_changelog' pk=object.pk %}">Change Log</a>
-      </li>
-    {% endif %}
-  </ul>
-{% endblock %}

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

@@ -24,20 +24,3 @@
     </a>
   {% endif %}
 {% 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>
   {% endif %}
 {% 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 %}

+ 7 - 10
netbox/tenancy/urls.py

@@ -1,8 +1,7 @@
-from django.urls import path
+from django.urls import include, path
 
-from netbox.views.generic import ObjectChangeLogView, ObjectJournalView
+from utilities.urls import get_model_urls
 from . import views
-from .models import *
 
 app_name = 'tenancy'
 urlpatterns = [
@@ -16,7 +15,7 @@ urlpatterns = [
     path('tenant-groups/<int:pk>/', views.TenantGroupView.as_view(), name='tenantgroup'),
     path('tenant-groups/<int:pk>/edit/', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'),
     path('tenant-groups/<int:pk>/delete/', views.TenantGroupDeleteView.as_view(), name='tenantgroup_delete'),
-    path('tenant-groups/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='tenantgroup_changelog', kwargs={'model': TenantGroup}),
+    path('tenant-groups/<int:pk>/', include(get_model_urls('tenancy', 'tenantgroup'))),
 
     # Tenants
     path('tenants/', views.TenantListView.as_view(), name='tenant_list'),
@@ -27,8 +26,7 @@ urlpatterns = [
     path('tenants/<int:pk>/', views.TenantView.as_view(), name='tenant'),
     path('tenants/<int:pk>/edit/', views.TenantEditView.as_view(), name='tenant_edit'),
     path('tenants/<int:pk>/delete/', views.TenantDeleteView.as_view(), name='tenant_delete'),
-    path('tenants/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='tenant_changelog', kwargs={'model': Tenant}),
-    path('tenants/<int:pk>/journal/', ObjectJournalView.as_view(), name='tenant_journal', kwargs={'model': Tenant}),
+    path('tenants/<int:pk>/', include(get_model_urls('tenancy', 'tenant'))),
 
     # Contact groups
     path('contact-groups/', views.ContactGroupListView.as_view(), name='contactgroup_list'),
@@ -39,7 +37,7 @@ urlpatterns = [
     path('contact-groups/<int:pk>/', views.ContactGroupView.as_view(), name='contactgroup'),
     path('contact-groups/<int:pk>/edit/', views.ContactGroupEditView.as_view(), name='contactgroup_edit'),
     path('contact-groups/<int:pk>/delete/', views.ContactGroupDeleteView.as_view(), name='contactgroup_delete'),
-    path('contact-groups/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='contactgroup_changelog', kwargs={'model': ContactGroup}),
+    path('contact-groups/<int:pk>/', include(get_model_urls('tenancy', 'contactgroup'))),
 
     # Contact roles
     path('contact-roles/', views.ContactRoleListView.as_view(), name='contactrole_list'),
@@ -50,7 +48,7 @@ urlpatterns = [
     path('contact-roles/<int:pk>/', views.ContactRoleView.as_view(), name='contactrole'),
     path('contact-roles/<int:pk>/edit/', views.ContactRoleEditView.as_view(), name='contactrole_edit'),
     path('contact-roles/<int:pk>/delete/', views.ContactRoleDeleteView.as_view(), name='contactrole_delete'),
-    path('contact-roles/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='contactrole_changelog', kwargs={'model': ContactRole}),
+    path('contact-roles/<int:pk>/', include(get_model_urls('tenancy', 'contactrole'))),
 
     # Contacts
     path('contacts/', views.ContactListView.as_view(), name='contact_list'),
@@ -61,8 +59,7 @@ urlpatterns = [
     path('contacts/<int:pk>/', views.ContactView.as_view(), name='contact'),
     path('contacts/<int:pk>/edit/', views.ContactEditView.as_view(), name='contact_edit'),
     path('contacts/<int:pk>/delete/', views.ContactDeleteView.as_view(), name='contact_delete'),
-    path('contacts/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='contact_changelog', kwargs={'model': Contact}),
-    path('contacts/<int:pk>/journal/', ObjectJournalView.as_view(), name='contact_journal', kwargs={'model': Contact}),
+    path('contacts/<int:pk>/', include(get_model_urls('tenancy', 'contact'))),
 
     # Contact assignments
     path('contact-assignments/add/', views.ContactAssignmentEditView.as_view(), name='contactassignment_add'),

+ 7 - 0
netbox/utilities/templates/tabs/model_view_tabs.html

@@ -0,0 +1,7 @@
+{% for tab in tabs %}
+  <li role="presentation" class="nav-item">
+    <a href="{{ tab.url }}" class="nav-link{% if tab.is_active %} active{% endif %}">
+      {{ tab.label }} {% badge tab.badge %}
+    </a>
+  </li>
+{% endfor %}

+ 48 - 0
netbox/utilities/templatetags/tabs.py

@@ -0,0 +1,48 @@
+from django import template
+from django.urls import reverse
+from django.utils.module_loading import import_string
+
+from extras.registry import registry
+
+register = template.Library()
+
+
+#
+# Object detail view tabs
+#
+
+@register.inclusion_tag('tabs/model_view_tabs.html', takes_context=True)
+def model_view_tabs(context, instance):
+    app_label = instance._meta.app_label
+    model_name = instance._meta.model_name
+    user = context['request'].user
+    tabs = []
+
+    # Retrieve registered views for this model
+    try:
+        views = registry['views'][app_label][model_name]
+    except KeyError:
+        # No views have been registered for this model
+        views = []
+
+    # Compile a list of tabs to be displayed in the UI
+    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
+
+            if attrs := tab.render(instance):
+                viewname = f"{app_label}:{model_name}_{config['name']}"
+                active_tab = context.get('tab')
+                tabs.append({
+                    'name': config['name'],
+                    'url': reverse(viewname, args=[instance.pk]),
+                    'label': attrs['label'],
+                    'badge': attrs['badge'],
+                    'is_active': active_tab and active_tab == tab,
+                })
+
+    return {
+        'tabs': tabs,
+    }

+ 39 - 0
netbox/utilities/urls.py

@@ -0,0 +1,39 @@
+from django.urls import path
+from django.utils.module_loading import import_string
+from django.views.generic import View
+
+from extras.registry import registry
+
+
+def get_model_urls(app_label, model_name):
+    """
+    Return a list of URL paths for detail views registered to the given model.
+
+    Args:
+        app_label: App/plugin name
+        model_name: Model name
+    """
+    paths = []
+
+    # Retrieve registered views for this model
+    try:
+        views = registry['views'][app_label][model_name]
+    except KeyError:
+        # No views have been registered for this model
+        views = []
+
+    for config in views:
+        # Import the view class or function
+        if type(config['view']) is str:
+            view_ = import_string(config['view'])
+        else:
+            view_ = config['view']
+        if issubclass(view_, View):
+            view_ = view_.as_view()
+
+        # Create a path to the view
+        paths.append(
+            path(f"{config['path']}/", view_, name=f"{model_name}_{config['name']}", kwargs=config['kwargs'])
+        )
+
+    return paths

+ 81 - 0
netbox/utilities/views.py

@@ -3,8 +3,17 @@ from django.core.exceptions import ImproperlyConfigured
 from django.urls import reverse
 from django.urls.exceptions import NoReverseMatch
 
+from extras.registry import registry
 from .permissions import resolve_permission
 
+__all__ = (
+    'ContentTypePermissionRequiredMixin',
+    'GetReturnURLMixin',
+    'ObjectPermissionRequiredMixin',
+    'ViewTab',
+    'register_model_view',
+)
+
 
 #
 # View Mixins
@@ -122,3 +131,75 @@ class GetReturnURLMixin:
 
         # If all else fails, return home. Ideally this should never happen.
         return reverse('home')
+
+
+class ViewTab:
+    """
+    ViewTabs are used for navigation among multiple object-specific views, such as the changelog or journal for
+    a particular object.
+
+    Args:
+        label: Human-friendly text
+        badge: A static value or callable to display alongside the label (optional). If a callable is used, it must accept a single
+            argument representing the object being viewed.
+        permission: The permission required to display the tab (optional).
+    """
+    def __init__(self, label, badge=None, permission=None):
+        self.label = label
+        self.badge = badge
+        self.permission = permission
+
+    def render(self, instance):
+        """Return the attributes needed to render a tab in HTML."""
+        badge_value = self._get_badge_value(instance)
+        if self.badge and not badge_value:
+            return None
+        return {
+            'label': self.label,
+            'badge': badge_value,
+        }
+
+    def _get_badge_value(self, instance):
+        if not self.badge:
+            return None
+        if callable(self.badge):
+            return self.badge(instance)
+        return self.badge
+
+
+def register_model_view(model, name, path=None, kwargs=None):
+    """
+    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:
+        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).
+    """
+    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 {},
+        })
+
+        return cls
+
+    return _wrapper

+ 7 - 14
netbox/virtualization/urls.py

@@ -1,8 +1,7 @@
-from django.urls import path
+from django.urls import include, path
 
-from netbox.views.generic import ObjectChangeLogView, ObjectJournalView
+from utilities.urls import get_model_urls
 from . import views
-from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 
 app_name = 'virtualization'
 urlpatterns = [
@@ -16,7 +15,7 @@ urlpatterns = [
     path('cluster-types/<int:pk>/', views.ClusterTypeView.as_view(), name='clustertype'),
     path('cluster-types/<int:pk>/edit/', views.ClusterTypeEditView.as_view(), name='clustertype_edit'),
     path('cluster-types/<int:pk>/delete/', views.ClusterTypeDeleteView.as_view(), name='clustertype_delete'),
-    path('cluster-types/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='clustertype_changelog', kwargs={'model': ClusterType}),
+    path('cluster-types/<int:pk>/', include(get_model_urls('virtualization', 'clustertype'))),
 
     # Cluster groups
     path('cluster-groups/', views.ClusterGroupListView.as_view(), name='clustergroup_list'),
@@ -27,7 +26,7 @@ urlpatterns = [
     path('cluster-groups/<int:pk>/', views.ClusterGroupView.as_view(), name='clustergroup'),
     path('cluster-groups/<int:pk>/edit/', views.ClusterGroupEditView.as_view(), name='clustergroup_edit'),
     path('cluster-groups/<int:pk>/delete/', views.ClusterGroupDeleteView.as_view(), name='clustergroup_delete'),
-    path('cluster-groups/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='clustergroup_changelog', kwargs={'model': ClusterGroup}),
+    path('cluster-groups/<int:pk>/', include(get_model_urls('virtualization', 'clustergroup'))),
 
     # Clusters
     path('clusters/', views.ClusterListView.as_view(), name='cluster_list'),
@@ -36,14 +35,11 @@ urlpatterns = [
     path('clusters/edit/', views.ClusterBulkEditView.as_view(), name='cluster_bulk_edit'),
     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>/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>/delete/', views.ClusterDeleteView.as_view(), name='cluster_delete'),
-    path('clusters/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='cluster_changelog', kwargs={'model': Cluster}),
-    path('clusters/<int:pk>/journal/', ObjectJournalView.as_view(), name='cluster_journal', kwargs={'model': Cluster}),
     path('clusters/<int:pk>/devices/add/', views.ClusterAddDevicesView.as_view(), name='cluster_add_devices'),
     path('clusters/<int:pk>/devices/remove/', views.ClusterRemoveDevicesView.as_view(), name='cluster_remove_devices'),
+    path('clusters/<int:pk>/', include(get_model_urls('virtualization', 'cluster'))),
 
     # Virtual machines
     path('virtual-machines/', views.VirtualMachineListView.as_view(), name='virtualmachine_list'),
@@ -52,12 +48,9 @@ urlpatterns = [
     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/<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>/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>/changelog/', ObjectChangeLogView.as_view(), name='virtualmachine_changelog', kwargs={'model': VirtualMachine}),
-    path('virtual-machines/<int:pk>/journal/', ObjectJournalView.as_view(), name='virtualmachine_journal', kwargs={'model': VirtualMachine}),
+    path('virtual-machines/<int:pk>/', include(get_model_urls('virtualization', 'virtualmachine'))),
 
     # VM interfaces
     path('interfaces/', views.VMInterfaceListView.as_view(), name='vminterface_list'),
@@ -69,7 +62,7 @@ urlpatterns = [
     path('interfaces/<int:pk>/', views.VMInterfaceView.as_view(), name='vminterface'),
     path('interfaces/<int:pk>/edit/', views.VMInterfaceEditView.as_view(), name='vminterface_edit'),
     path('interfaces/<int:pk>/delete/', views.VMInterfaceDeleteView.as_view(), name='vminterface_delete'),
-    path('interfaces/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vminterface_changelog', kwargs={'model': VMInterface}),
+    path('interfaces/<int:pk>/', include(get_model_urls('virtualization', 'vminterface'))),
     path('virtual-machines/interfaces/add/', views.VirtualMachineBulkAddInterfaceView.as_view(), name='virtualmachine_bulk_add_vminterface'),
 
 ]

+ 25 - 15
netbox/virtualization/views.py

@@ -3,6 +3,7 @@ from django.db import transaction
 from django.db.models import Prefetch
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
+from django.utils.translation import gettext as _
 
 from dcim.filtersets import DeviceFilterSet
 from dcim.models import Device
@@ -12,6 +13,7 @@ from ipam.models import IPAddress, Service
 from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable
 from netbox.views import generic
 from utilities.utils import count_related
+from utilities.views import ViewTab, register_model_view
 from . import filtersets, forms, tables
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 
@@ -161,37 +163,39 @@ class ClusterView(generic.ObjectView):
     queryset = Cluster.objects.all()
 
 
+@register_model_view(Cluster, 'virtualmachines', path='virtual-machines')
 class ClusterVirtualMachinesView(generic.ObjectChildrenView):
     queryset = Cluster.objects.all()
     child_model = VirtualMachine
     table = tables.VirtualMachineTable
     filterset = filtersets.VirtualMachineFilterSet
     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):
         return VirtualMachine.objects.restrict(request.user, 'view').filter(cluster=parent)
 
-    def get_extra_context(self, request, instance):
-        return {
-            'active_tab': 'virtual-machines',
-        }
-
 
+@register_model_view(Cluster, 'devices')
 class ClusterDevicesView(generic.ObjectChildrenView):
     queryset = Cluster.objects.all()
     child_model = Device
     table = DeviceTable
     filterset = DeviceFilterSet
     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):
         return Device.objects.restrict(request.user, 'view').filter(cluster=parent)
 
-    def get_extra_context(self, request, instance):
-        return {
-            'active_tab': 'devices',
-        }
-
 
 class ClusterEditView(generic.ObjectEditView):
     queryset = Cluster.objects.all()
@@ -344,12 +348,18 @@ class VirtualMachineView(generic.ObjectView):
         }
 
 
+@register_model_view(VirtualMachine, 'interfaces')
 class VirtualMachineInterfacesView(generic.ObjectChildrenView):
     queryset = VirtualMachine.objects.all()
     child_model = VMInterface
     table = tables.VirtualMachineVMInterfaceTable
     filterset = filtersets.VMInterfaceFilterSet
     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):
         return parent.interfaces.restrict(request.user, 'view').prefetch_related(
@@ -357,15 +367,15 @@ class VirtualMachineInterfacesView(generic.ObjectChildrenView):
             'tags',
         )
 
-    def get_extra_context(self, request, instance):
-        return {
-            'active_tab': 'interfaces',
-        }
-
 
+@register_model_view(VirtualMachine, 'configcontext', path='config-context')
 class VirtualMachineConfigContextView(ObjectConfigContextView):
     queryset = VirtualMachine.objects.annotate_config_context_data()
     base_template = 'virtualization/virtualmachine.html'
+    tab = ViewTab(
+        label=_('Config Context'),
+        permission='extras.view_configcontext'
+    )
 
 
 class VirtualMachineEditView(generic.ObjectEditView):

+ 5 - 8
netbox/wireless/urls.py

@@ -1,8 +1,7 @@
-from django.urls import path
+from django.urls import include, path
 
-from netbox.views.generic import ObjectChangeLogView, ObjectJournalView
+from utilities.urls import get_model_urls
 from . import views
-from .models import *
 
 app_name = 'wireless'
 urlpatterns = (
@@ -16,7 +15,7 @@ urlpatterns = (
     path('wireless-lan-groups/<int:pk>/', views.WirelessLANGroupView.as_view(), name='wirelesslangroup'),
     path('wireless-lan-groups/<int:pk>/edit/', views.WirelessLANGroupEditView.as_view(), name='wirelesslangroup_edit'),
     path('wireless-lan-groups/<int:pk>/delete/', views.WirelessLANGroupDeleteView.as_view(), name='wirelesslangroup_delete'),
-    path('wireless-lan-groups/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='wirelesslangroup_changelog', kwargs={'model': WirelessLANGroup}),
+    path('wireless-lan-groups/<int:pk>/', include(get_model_urls('wireless', 'wirelesslangroup'))),
 
     # Wireless LANs
     path('wireless-lans/', views.WirelessLANListView.as_view(), name='wirelesslan_list'),
@@ -27,8 +26,7 @@ urlpatterns = (
     path('wireless-lans/<int:pk>/', views.WirelessLANView.as_view(), name='wirelesslan'),
     path('wireless-lans/<int:pk>/edit/', views.WirelessLANEditView.as_view(), name='wirelesslan_edit'),
     path('wireless-lans/<int:pk>/delete/', views.WirelessLANDeleteView.as_view(), name='wirelesslan_delete'),
-    path('wireless-lans/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='wirelesslan_changelog', kwargs={'model': WirelessLAN}),
-    path('wireless-lans/<int:pk>/journal/', ObjectJournalView.as_view(), name='wirelesslan_journal', kwargs={'model': WirelessLAN}),
+    path('wireless-lans/<int:pk>/', include(get_model_urls('wireless', 'wirelesslan'))),
 
     # Wireless links
     path('wireless-links/', views.WirelessLinkListView.as_view(), name='wirelesslink_list'),
@@ -39,7 +37,6 @@ urlpatterns = (
     path('wireless-links/<int:pk>/', views.WirelessLinkView.as_view(), name='wirelesslink'),
     path('wireless-links/<int:pk>/edit/', views.WirelessLinkEditView.as_view(), name='wirelesslink_edit'),
     path('wireless-links/<int:pk>/delete/', views.WirelessLinkDeleteView.as_view(), name='wirelesslink_delete'),
-    path('wireless-links/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='wirelesslink_changelog', kwargs={'model': WirelessLink}),
-    path('wireless-links/<int:pk>/journal/', ObjectJournalView.as_view(), name='wirelesslink_journal', kwargs={'model': WirelessLink}),
+    path('wireless-links/<int:pk>/', include(get_model_urls('wireless', 'wirelesslink'))),
 
 )