diff --git a/docs/plugins/development/views.md b/docs/plugins/development/views.md index cabcd70452..dfada7a429 100644 --- a/docs/plugins/development/views.md +++ b/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 diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index cc4d0d6f5f..bbb386577a 100644 --- a/docs/release-notes/version-3.4.md +++ b/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 diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index 5b15b29aca..55ceeddc32 100644 --- a/netbox/circuits/urls.py +++ b/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 @@ path('providers//', views.ProviderView.as_view(), name='provider'), path('providers//edit/', views.ProviderEditView.as_view(), name='provider_edit'), path('providers//delete/', views.ProviderDeleteView.as_view(), name='provider_delete'), - path('providers//changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}), - path('providers//journal/', ObjectJournalView.as_view(), name='provider_journal', kwargs={'model': Provider}), + path('providers//', include(get_model_urls('circuits', 'provider'))), # Provider networks path('provider-networks/', views.ProviderNetworkListView.as_view(), name='providernetwork_list'), @@ -29,8 +28,7 @@ path('provider-networks//', views.ProviderNetworkView.as_view(), name='providernetwork'), path('provider-networks//edit/', views.ProviderNetworkEditView.as_view(), name='providernetwork_edit'), path('provider-networks//delete/', views.ProviderNetworkDeleteView.as_view(), name='providernetwork_delete'), - path('provider-networks//changelog/', ObjectChangeLogView.as_view(), name='providernetwork_changelog', kwargs={'model': ProviderNetwork}), - path('provider-networks//journal/', ObjectJournalView.as_view(), name='providernetwork_journal', kwargs={'model': ProviderNetwork}), + path('provider-networks//', include(get_model_urls('circuits', 'providernetwork'))), # Circuit types path('circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'), @@ -41,7 +39,7 @@ path('circuit-types//', views.CircuitTypeView.as_view(), name='circuittype'), path('circuit-types//edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'), path('circuit-types//delete/', views.CircuitTypeDeleteView.as_view(), name='circuittype_delete'), - path('circuit-types//changelog/', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}), + path('circuit-types//', include(get_model_urls('circuits', 'circuittype'))), # Circuits path('circuits/', views.CircuitListView.as_view(), name='circuit_list'), @@ -52,9 +50,8 @@ path('circuits//', views.CircuitView.as_view(), name='circuit'), path('circuits//edit/', views.CircuitEditView.as_view(), name='circuit_edit'), path('circuits//delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'), - path('circuits//changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}), - path('circuits//journal/', ObjectJournalView.as_view(), name='circuit_journal', kwargs={'model': Circuit}), path('circuits//terminations/swap/', views.CircuitSwapTerminations.as_view(), name='circuit_terminations_swap'), + path('circuits//', include(get_model_urls('circuits', 'circuit'))), # Circuit terminations path('circuit-terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'), diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index c11a92a99d..94c2a71317 100644 --- a/netbox/dcim/urls.py +++ b/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 @@ path('regions//', views.RegionView.as_view(), name='region'), path('regions//edit/', views.RegionEditView.as_view(), name='region_edit'), path('regions//delete/', views.RegionDeleteView.as_view(), name='region_delete'), - path('regions//changelog/', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}), + path('regions//', include(get_model_urls('dcim', 'region'))), # Site groups path('site-groups/', views.SiteGroupListView.as_view(), name='sitegroup_list'), @@ -27,7 +29,7 @@ path('site-groups//', views.SiteGroupView.as_view(), name='sitegroup'), path('site-groups//edit/', views.SiteGroupEditView.as_view(), name='sitegroup_edit'), path('site-groups//delete/', views.SiteGroupDeleteView.as_view(), name='sitegroup_delete'), - path('site-groups//changelog/', ObjectChangeLogView.as_view(), name='sitegroup_changelog', kwargs={'model': SiteGroup}), + path('site-groups//', include(get_model_urls('dcim', 'sitegroup'))), # Sites path('sites/', views.SiteListView.as_view(), name='site_list'), @@ -38,8 +40,7 @@ path('sites//', views.SiteView.as_view(), name='site'), path('sites//edit/', views.SiteEditView.as_view(), name='site_edit'), path('sites//delete/', views.SiteDeleteView.as_view(), name='site_delete'), - path('sites//changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}), - path('sites//journal/', ObjectJournalView.as_view(), name='site_journal', kwargs={'model': Site}), + path('sites//', include(get_model_urls('dcim', 'site'))), # Locations path('locations/', views.LocationListView.as_view(), name='location_list'), @@ -50,7 +51,7 @@ path('locations//', views.LocationView.as_view(), name='location'), path('locations//edit/', views.LocationEditView.as_view(), name='location_edit'), path('locations//delete/', views.LocationDeleteView.as_view(), name='location_delete'), - path('locations//changelog/', ObjectChangeLogView.as_view(), name='location_changelog', kwargs={'model': Location}), + path('locations//', include(get_model_urls('dcim', 'location'))), # Rack roles path('rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'), @@ -61,7 +62,7 @@ path('rack-roles//', views.RackRoleView.as_view(), name='rackrole'), path('rack-roles//edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'), path('rack-roles//delete/', views.RackRoleDeleteView.as_view(), name='rackrole_delete'), - path('rack-roles//changelog/', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}), + path('rack-roles//', include(get_model_urls('dcim', 'rackrole'))), # Rack reservations path('rack-reservations/', views.RackReservationListView.as_view(), name='rackreservation_list'), @@ -72,8 +73,7 @@ path('rack-reservations//', views.RackReservationView.as_view(), name='rackreservation'), path('rack-reservations//edit/', views.RackReservationEditView.as_view(), name='rackreservation_edit'), path('rack-reservations//delete/', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'), - path('rack-reservations//changelog/', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}), - path('rack-reservations//journal/', ObjectJournalView.as_view(), name='rackreservation_journal', kwargs={'model': RackReservation}), + path('rack-reservations//', include(get_model_urls('dcim', 'rackreservation'))), # Racks path('racks/', views.RackListView.as_view(), name='rack_list'), @@ -85,8 +85,7 @@ path('racks//', views.RackView.as_view(), name='rack'), path('racks//edit/', views.RackEditView.as_view(), name='rack_edit'), path('racks//delete/', views.RackDeleteView.as_view(), name='rack_delete'), - path('racks//changelog/', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}), - path('racks//journal/', ObjectJournalView.as_view(), name='rack_journal', kwargs={'model': Rack}), + path('racks//', include(get_model_urls('dcim', 'rack'))), # Manufacturers path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'), @@ -97,7 +96,7 @@ path('manufacturers//', views.ManufacturerView.as_view(), name='manufacturer'), path('manufacturers//edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'), path('manufacturers//delete/', views.ManufacturerDeleteView.as_view(), name='manufacturer_delete'), - path('manufacturers//changelog/', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}), + path('manufacturers//', include(get_model_urls('dcim', 'manufacturer'))), # Device types path('device-types/', views.DeviceTypeListView.as_view(), name='devicetype_list'), @@ -106,20 +105,9 @@ 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//', views.DeviceTypeView.as_view(), name='devicetype'), - path('device-types//console-ports/', views.DeviceTypeConsolePortsView.as_view(), name='devicetype_consoleports'), - path('device-types//console-server-ports/', views.DeviceTypeConsoleServerPortsView.as_view(), name='devicetype_consoleserverports'), - path('device-types//power-ports/', views.DeviceTypePowerPortsView.as_view(), name='devicetype_powerports'), - path('device-types//power-outlets/', views.DeviceTypePowerOutletsView.as_view(), name='devicetype_poweroutlets'), - path('device-types//interfaces/', views.DeviceTypeInterfacesView.as_view(), name='devicetype_interfaces'), - path('device-types//front-ports/', views.DeviceTypeFrontPortsView.as_view(), name='devicetype_frontports'), - path('device-types//rear-ports/', views.DeviceTypeRearPortsView.as_view(), name='devicetype_rearports'), - path('device-types//module-bays/', views.DeviceTypeModuleBaysView.as_view(), name='devicetype_modulebays'), - path('device-types//device-bays/', views.DeviceTypeDeviceBaysView.as_view(), name='devicetype_devicebays'), - path('device-types//inventory-items/', views.DeviceTypeInventoryItemsView.as_view(), name='devicetype_inventoryitems'), path('device-types//edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'), path('device-types//delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'), - path('device-types//changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}), - path('device-types//journal/', ObjectJournalView.as_view(), name='devicetype_journal', kwargs={'model': DeviceType}), + path('device-types//', include(get_model_urls('dcim', 'devicetype'))), # Module types path('module-types/', views.ModuleTypeListView.as_view(), name='moduletype_list'), @@ -128,17 +116,9 @@ 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//', views.ModuleTypeView.as_view(), name='moduletype'), - path('module-types//console-ports/', views.ModuleTypeConsolePortsView.as_view(), name='moduletype_consoleports'), - path('module-types//console-server-ports/', views.ModuleTypeConsoleServerPortsView.as_view(), name='moduletype_consoleserverports'), - path('module-types//power-ports/', views.ModuleTypePowerPortsView.as_view(), name='moduletype_powerports'), - path('module-types//power-outlets/', views.ModuleTypePowerOutletsView.as_view(), name='moduletype_poweroutlets'), - path('module-types//interfaces/', views.ModuleTypeInterfacesView.as_view(), name='moduletype_interfaces'), - path('module-types//front-ports/', views.ModuleTypeFrontPortsView.as_view(), name='moduletype_frontports'), - path('module-types//rear-ports/', views.ModuleTypeRearPortsView.as_view(), name='moduletype_rearports'), path('module-types//edit/', views.ModuleTypeEditView.as_view(), name='moduletype_edit'), path('module-types//delete/', views.ModuleTypeDeleteView.as_view(), name='moduletype_delete'), - path('module-types//changelog/', ObjectChangeLogView.as_view(), name='moduletype_changelog', kwargs={'model': ModuleType}), - path('module-types//journal/', ObjectJournalView.as_view(), name='moduletype_journal', kwargs={'model': ModuleType}), + path('module-types//', 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 @@ path('device-roles//', views.DeviceRoleView.as_view(), name='devicerole'), path('device-roles//edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'), path('device-roles//delete/', views.DeviceRoleDeleteView.as_view(), name='devicerole_delete'), - path('device-roles//changelog/', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}), + path('device-roles//', include(get_model_urls('dcim', 'devicerole'))), # Platforms path('platforms/', views.PlatformListView.as_view(), name='platform_list'), @@ -240,7 +220,7 @@ path('platforms//', views.PlatformView.as_view(), name='platform'), path('platforms//edit/', views.PlatformEditView.as_view(), name='platform_edit'), path('platforms//delete/', views.PlatformDeleteView.as_view(), name='platform_delete'), - path('platforms//changelog/', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}), + path('platforms//', include(get_model_urls('dcim', 'platform'))), # Devices path('devices/', views.DeviceListView.as_view(), name='device_list'), @@ -253,22 +233,7 @@ path('devices//', views.DeviceView.as_view(), name='device'), path('devices//edit/', views.DeviceEditView.as_view(), name='device_edit'), path('devices//delete/', views.DeviceDeleteView.as_view(), name='device_delete'), - path('devices//console-ports/', views.DeviceConsolePortsView.as_view(), name='device_consoleports'), - path('devices//console-server-ports/', views.DeviceConsoleServerPortsView.as_view(), name='device_consoleserverports'), - path('devices//power-ports/', views.DevicePowerPortsView.as_view(), name='device_powerports'), - path('devices//power-outlets/', views.DevicePowerOutletsView.as_view(), name='device_poweroutlets'), - path('devices//interfaces/', views.DeviceInterfacesView.as_view(), name='device_interfaces'), - path('devices//front-ports/', views.DeviceFrontPortsView.as_view(), name='device_frontports'), - path('devices//rear-ports/', views.DeviceRearPortsView.as_view(), name='device_rearports'), - path('devices//module-bays/', views.DeviceModuleBaysView.as_view(), name='device_modulebays'), - path('devices//device-bays/', views.DeviceDeviceBaysView.as_view(), name='device_devicebays'), - path('devices//inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'), - path('devices//config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'), - path('devices//changelog/', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}), - path('devices//journal/', ObjectJournalView.as_view(), name='device_journal', kwargs={'model': Device}), - path('devices//status/', views.DeviceStatusView.as_view(), name='device_status'), - path('devices//lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'), - path('devices//config/', views.DeviceConfigView.as_view(), name='device_config'), + path('devices//', include(get_model_urls('dcim', 'device'))), # Modules path('modules/', views.ModuleListView.as_view(), name='module_list'), @@ -279,8 +244,7 @@ path('modules//', views.ModuleView.as_view(), name='module'), path('modules//edit/', views.ModuleEditView.as_view(), name='module_edit'), path('modules//delete/', views.ModuleDeleteView.as_view(), name='module_delete'), - path('modules//changelog/', ObjectChangeLogView.as_view(), name='module_changelog', kwargs={'model': Module}), - path('modules//journal/', ObjectJournalView.as_view(), name='module_journal', kwargs={'model': Module}), + path('modules//', include(get_model_urls('dcim', 'module'))), # Console ports path('console-ports/', views.ConsolePortListView.as_view(), name='consoleport_list'), @@ -293,8 +257,8 @@ path('console-ports//', views.ConsolePortView.as_view(), name='consoleport'), path('console-ports//edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'), path('console-ports//delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'), - path('console-ports//changelog/', ObjectChangeLogView.as_view(), name='consoleport_changelog', kwargs={'model': ConsolePort}), path('console-ports//trace/', views.PathTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}), + path('console-ports//', 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 @@ path('console-server-ports//', views.ConsoleServerPortView.as_view(), name='consoleserverport'), path('console-server-ports//edit/', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'), path('console-server-ports//delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'), - path('console-server-ports//changelog/', ObjectChangeLogView.as_view(), name='consoleserverport_changelog', kwargs={'model': ConsoleServerPort}), path('console-server-ports//trace/', views.PathTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}), + path('console-server-ports//', 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 @@ path('power-ports//', views.PowerPortView.as_view(), name='powerport'), path('power-ports//edit/', views.PowerPortEditView.as_view(), name='powerport_edit'), path('power-ports//delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'), - path('power-ports//changelog/', ObjectChangeLogView.as_view(), name='powerport_changelog', kwargs={'model': PowerPort}), path('power-ports//trace/', views.PathTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}), + path('power-ports//', 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 @@ path('power-outlets//', views.PowerOutletView.as_view(), name='poweroutlet'), path('power-outlets//edit/', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'), path('power-outlets//delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'), - path('power-outlets//changelog/', ObjectChangeLogView.as_view(), name='poweroutlet_changelog', kwargs={'model': PowerOutlet}), path('power-outlets//trace/', views.PathTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}), + path('power-outlets//', 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 @@ path('interfaces//', views.InterfaceView.as_view(), name='interface'), path('interfaces//edit/', views.InterfaceEditView.as_view(), name='interface_edit'), path('interfaces//delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'), - path('interfaces//changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}), path('interfaces//trace/', views.PathTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}), + path('interfaces//', 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 @@ path('front-ports//', views.FrontPortView.as_view(), name='frontport'), path('front-ports//edit/', views.FrontPortEditView.as_view(), name='frontport_edit'), path('front-ports//delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'), - path('front-ports//changelog/', ObjectChangeLogView.as_view(), name='frontport_changelog', kwargs={'model': FrontPort}), path('front-ports//trace/', views.PathTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}), + path('front-ports//', 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 @@ path('rear-ports//', views.RearPortView.as_view(), name='rearport'), path('rear-ports//edit/', views.RearPortEditView.as_view(), name='rearport_edit'), path('rear-ports//delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'), - path('rear-ports//changelog/', ObjectChangeLogView.as_view(), name='rearport_changelog', kwargs={'model': RearPort}), path('rear-ports//trace/', views.PathTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}), + path('rear-ports//', 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 @@ path('module-bays//', views.ModuleBayView.as_view(), name='modulebay'), path('module-bays//edit/', views.ModuleBayEditView.as_view(), name='modulebay_edit'), path('module-bays//delete/', views.ModuleBayDeleteView.as_view(), name='modulebay_delete'), - path('module-bays//changelog/', ObjectChangeLogView.as_view(), name='modulebay_changelog', kwargs={'model': ModuleBay}), + path('module-bays//', 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 @@ path('device-bays//', views.DeviceBayView.as_view(), name='devicebay'), path('device-bays//edit/', views.DeviceBayEditView.as_view(), name='devicebay_edit'), path('device-bays//delete/', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'), - path('device-bays//changelog/', ObjectChangeLogView.as_view(), name='devicebay_changelog', kwargs={'model': DeviceBay}), path('device-bays//populate/', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'), path('device-bays//depopulate/', views.DeviceBayDepopulateView.as_view(), name='devicebay_depopulate'), + path('device-bays//', 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 @@ path('inventory-items//', views.InventoryItemView.as_view(), name='inventoryitem'), path('inventory-items//edit/', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'), path('inventory-items//delete/', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'), - path('inventory-items//changelog/', ObjectChangeLogView.as_view(), name='inventoryitem_changelog', kwargs={'model': InventoryItem}), + path('inventory-items//', 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 @@ path('inventory-item-roles//', views.InventoryItemRoleView.as_view(), name='inventoryitemrole'), path('inventory-item-roles//edit/', views.InventoryItemRoleEditView.as_view(), name='inventoryitemrole_edit'), path('inventory-item-roles//delete/', views.InventoryItemRoleDeleteView.as_view(), name='inventoryitemrole_delete'), - path('inventory-item-roles//changelog/', ObjectChangeLogView.as_view(), name='inventoryitemrole_changelog', kwargs={'model': InventoryItemRole}), + path('inventory-item-roles//', include(get_model_urls('dcim', 'inventoryitemrole'))), # Cables path('cables/', views.CableListView.as_view(), name='cable_list'), @@ -448,8 +412,7 @@ path('cables//', views.CableView.as_view(), name='cable'), path('cables//edit/', views.CableEditView.as_view(), name='cable_edit'), path('cables//delete/', views.CableDeleteView.as_view(), name='cable_delete'), - path('cables//changelog/', ObjectChangeLogView.as_view(), name='cable_changelog', kwargs={'model': Cable}), - path('cables//journal/', ObjectJournalView.as_view(), name='cable_journal', kwargs={'model': Cable}), + path('cables//', 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 @@ path('virtual-chassis//', views.VirtualChassisView.as_view(), name='virtualchassis'), path('virtual-chassis//edit/', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'), path('virtual-chassis//delete/', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'), - path('virtual-chassis//changelog/', ObjectChangeLogView.as_view(), name='virtualchassis_changelog', kwargs={'model': VirtualChassis}), - path('virtual-chassis//journal/', ObjectJournalView.as_view(), name='virtualchassis_journal', kwargs={'model': VirtualChassis}), path('virtual-chassis//add-member/', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'), + path('virtual-chassis//', include(get_model_urls('dcim', 'virtualchassis'))), path('virtual-chassis-members//delete/', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'), # Power panels @@ -479,8 +441,7 @@ path('power-panels//', views.PowerPanelView.as_view(), name='powerpanel'), path('power-panels//edit/', views.PowerPanelEditView.as_view(), name='powerpanel_edit'), path('power-panels//delete/', views.PowerPanelDeleteView.as_view(), name='powerpanel_delete'), - path('power-panels//changelog/', ObjectChangeLogView.as_view(), name='powerpanel_changelog', kwargs={'model': PowerPanel}), - path('power-panels//journal/', ObjectJournalView.as_view(), name='powerpanel_journal', kwargs={'model': PowerPanel}), + path('power-panels//', include(get_model_urls('dcim', 'powerpanel'))), # Power feeds path('power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'), @@ -493,7 +454,6 @@ path('power-feeds//edit/', views.PowerFeedEditView.as_view(), name='powerfeed_edit'), path('power-feeds//delete/', views.PowerFeedDeleteView.as_view(), name='powerfeed_delete'), path('power-feeds//trace/', views.PathTraceView.as_view(), name='powerfeed_trace', kwargs={'model': PowerFeed}), - path('power-feeds//changelog/', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}), - path('power-feeds//journal/', ObjectJournalView.as_view(), name='powerfeed_journal', kwargs={'model': PowerFeed}), + path('power-feeds//', include(get_model_urls('dcim', 'powerfeed'))), ] diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 5930d6b2d3..ef0c1f9170 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -8,6 +8,7 @@ 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.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 @@ def get_children(self, request, parent): 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 @@ def get_children(self, request, parent): 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 @@ def get_extra_context(self, request, instance): } +@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 @@ def get_extra_context(self, request, instance): } +@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 @@ def get_extra_context(self, request, instance): } +@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 @@ def get_children(self, request, parent): ) +@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): diff --git a/netbox/extras/registry.py b/netbox/extras/registry.py index e1437c00e8..b748b6f908 100644 --- a/netbox/extras/registry.py +++ b/netbox/extras/registry.py @@ -29,3 +29,4 @@ def __delitem__(self, key): feature: collections.defaultdict(set) for feature in EXTRAS_FEATURES } registry['denormalized_fields'] = collections.defaultdict(list) +registry['views'] = collections.defaultdict(dict) diff --git a/netbox/extras/tests/dummy_plugin/views.py b/netbox/extras/tests/dummy_plugin/views.py index 4512758dfb..8713102c5e 100644 --- a/netbox/extras/tests/dummy_plugin/views.py +++ b/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!") diff --git a/netbox/extras/tests/test_plugins.py b/netbox/extras/tests/test_plugins.py index e0ff67a2b6..2eca3a3f7f 100644 --- a/netbox/extras/tests/test_plugins.py +++ b/netbox/extras/tests/test_plugins.py @@ -59,6 +59,17 @@ def test_api_views(self): 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. diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index ced3bd4b94..18d0314bf7 100644 --- a/netbox/extras/urls.py +++ b/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 @@ path('custom-fields//', views.CustomFieldView.as_view(), name='customfield'), path('custom-fields//edit/', views.CustomFieldEditView.as_view(), name='customfield_edit'), path('custom-fields//delete/', views.CustomFieldDeleteView.as_view(), name='customfield_delete'), - path('custom-fields//changelog/', ObjectChangeLogView.as_view(), name='customfield_changelog', - kwargs={'model': models.CustomField}), + path('custom-fields//', include(get_model_urls('extras', 'customfield'))), # Custom links path('custom-links/', views.CustomLinkListView.as_view(), name='customlink_list'), @@ -28,8 +27,7 @@ path('custom-links//', views.CustomLinkView.as_view(), name='customlink'), path('custom-links//edit/', views.CustomLinkEditView.as_view(), name='customlink_edit'), path('custom-links//delete/', views.CustomLinkDeleteView.as_view(), name='customlink_delete'), - path('custom-links//changelog/', ObjectChangeLogView.as_view(), name='customlink_changelog', - kwargs={'model': models.CustomLink}), + path('custom-links//', include(get_model_urls('extras', 'customlink'))), # Export templates path('export-templates/', views.ExportTemplateListView.as_view(), name='exporttemplate_list'), @@ -40,8 +38,7 @@ path('export-templates//', views.ExportTemplateView.as_view(), name='exporttemplate'), path('export-templates//edit/', views.ExportTemplateEditView.as_view(), name='exporttemplate_edit'), path('export-templates//delete/', views.ExportTemplateDeleteView.as_view(), name='exporttemplate_delete'), - path('export-templates//changelog/', ObjectChangeLogView.as_view(), name='exporttemplate_changelog', - kwargs={'model': models.ExportTemplate}), + path('export-templates//', include(get_model_urls('extras', 'exporttemplate'))), # Webhooks path('webhooks/', views.WebhookListView.as_view(), name='webhook_list'), @@ -52,8 +49,7 @@ path('webhooks//', views.WebhookView.as_view(), name='webhook'), path('webhooks//edit/', views.WebhookEditView.as_view(), name='webhook_edit'), path('webhooks//delete/', views.WebhookDeleteView.as_view(), name='webhook_delete'), - path('webhooks//changelog/', ObjectChangeLogView.as_view(), name='webhook_changelog', - kwargs={'model': models.Webhook}), + path('webhooks//', include(get_model_urls('extras', 'webhook'))), # Tags path('tags/', views.TagListView.as_view(), name='tag_list'), @@ -64,8 +60,7 @@ path('tags//', views.TagView.as_view(), name='tag'), path('tags//edit/', views.TagEditView.as_view(), name='tag_edit'), path('tags//delete/', views.TagDeleteView.as_view(), name='tag_delete'), - path('tags//changelog/', ObjectChangeLogView.as_view(), name='tag_changelog', - kwargs={'model': models.Tag}), + path('tags//', include(get_model_urls('extras', 'tag'))), # Config contexts path('config-contexts/', views.ConfigContextListView.as_view(), name='configcontext_list'), @@ -75,8 +70,7 @@ path('config-contexts//', views.ConfigContextView.as_view(), name='configcontext'), path('config-contexts//edit/', views.ConfigContextEditView.as_view(), name='configcontext_edit'), path('config-contexts//delete/', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'), - path('config-contexts//changelog/', ObjectChangeLogView.as_view(), name='configcontext_changelog', - kwargs={'model': models.ConfigContext}), + path('config-contexts//', include(get_model_urls('extras', 'configcontext'))), # Image attachments path('image-attachments/add/', views.ImageAttachmentEditView.as_view(), name='imageattachment_add'), @@ -91,8 +85,7 @@ path('journal-entries//', views.JournalEntryView.as_view(), name='journalentry'), path('journal-entries//edit/', views.JournalEntryEditView.as_view(), name='journalentry_edit'), path('journal-entries//delete/', views.JournalEntryDeleteView.as_view(), name='journalentry_delete'), - path('journal-entries//changelog/', ObjectChangeLogView.as_view(), name='journalentry_changelog', - kwargs={'model': models.JournalEntry}), + path('journal-entries//', include(get_model_urls('extras', 'journalentry'))), # Change logging path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index d8a015bb03..e48fd672bb 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -352,7 +352,6 @@ def get_extra_context(self, request, instance): 'source_contexts': source_contexts, 'format': format, 'base_template': self.base_template, - 'active_tab': 'config-context', } diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index d27209fd20..d5594eeb92 100644 --- a/netbox/ipam/urls.py +++ b/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 @@ path('asns//', views.ASNView.as_view(), name='asn'), path('asns//edit/', views.ASNEditView.as_view(), name='asn_edit'), path('asns//delete/', views.ASNDeleteView.as_view(), name='asn_delete'), - path('asns//changelog/', ObjectChangeLogView.as_view(), name='asn_changelog', kwargs={'model': ASN}), - path('asns//journal/', ObjectJournalView.as_view(), name='asn_journal', kwargs={'model': ASN}), + path('asns//', include(get_model_urls('ipam', 'asn'))), # VRFs path('vrfs/', views.VRFListView.as_view(), name='vrf_list'), @@ -28,8 +26,7 @@ path('vrfs//', views.VRFView.as_view(), name='vrf'), path('vrfs//edit/', views.VRFEditView.as_view(), name='vrf_edit'), path('vrfs//delete/', views.VRFDeleteView.as_view(), name='vrf_delete'), - path('vrfs//changelog/', ObjectChangeLogView.as_view(), name='vrf_changelog', kwargs={'model': VRF}), - path('vrfs//journal/', ObjectJournalView.as_view(), name='vrf_journal', kwargs={'model': VRF}), + path('vrfs//', include(get_model_urls('ipam', 'vrf'))), # Route targets path('route-targets/', views.RouteTargetListView.as_view(), name='routetarget_list'), @@ -40,8 +37,7 @@ path('route-targets//', views.RouteTargetView.as_view(), name='routetarget'), path('route-targets//edit/', views.RouteTargetEditView.as_view(), name='routetarget_edit'), path('route-targets//delete/', views.RouteTargetDeleteView.as_view(), name='routetarget_delete'), - path('route-targets//changelog/', ObjectChangeLogView.as_view(), name='routetarget_changelog', kwargs={'model': RouteTarget}), - path('route-targets//journal/', ObjectJournalView.as_view(), name='routetarget_journal', kwargs={'model': RouteTarget}), + path('route-targets//', include(get_model_urls('ipam', 'routetarget'))), # RIRs path('rirs/', views.RIRListView.as_view(), name='rir_list'), @@ -52,7 +48,7 @@ path('rirs//', views.RIRView.as_view(), name='rir'), path('rirs//edit/', views.RIREditView.as_view(), name='rir_edit'), path('rirs//delete/', views.RIRDeleteView.as_view(), name='rir_delete'), - path('rirs//changelog/', ObjectChangeLogView.as_view(), name='rir_changelog', kwargs={'model': RIR}), + path('rirs//', include(get_model_urls('ipam', 'rir'))), # Aggregates path('aggregates/', views.AggregateListView.as_view(), name='aggregate_list'), @@ -61,11 +57,9 @@ path('aggregates/edit/', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'), path('aggregates/delete/', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'), path('aggregates//', views.AggregateView.as_view(), name='aggregate'), - path('aggregates//prefixes/', views.AggregatePrefixesView.as_view(), name='aggregate_prefixes'), path('aggregates//edit/', views.AggregateEditView.as_view(), name='aggregate_edit'), path('aggregates//delete/', views.AggregateDeleteView.as_view(), name='aggregate_delete'), - path('aggregates//changelog/', ObjectChangeLogView.as_view(), name='aggregate_changelog', kwargs={'model': Aggregate}), - path('aggregates//journal/', ObjectJournalView.as_view(), name='aggregate_journal', kwargs={'model': Aggregate}), + path('aggregates//', include(get_model_urls('ipam', 'aggregate'))), # Roles path('roles/', views.RoleListView.as_view(), name='role_list'), @@ -76,7 +70,7 @@ path('roles//', views.RoleView.as_view(), name='role'), path('roles//edit/', views.RoleEditView.as_view(), name='role_edit'), path('roles//delete/', views.RoleDeleteView.as_view(), name='role_delete'), - path('roles//changelog/', ObjectChangeLogView.as_view(), name='role_changelog', kwargs={'model': Role}), + path('roles//', include(get_model_urls('ipam', 'role'))), # Prefixes path('prefixes/', views.PrefixListView.as_view(), name='prefix_list'), @@ -87,11 +81,7 @@ path('prefixes//', views.PrefixView.as_view(), name='prefix'), path('prefixes//edit/', views.PrefixEditView.as_view(), name='prefix_edit'), path('prefixes//delete/', views.PrefixDeleteView.as_view(), name='prefix_delete'), - path('prefixes//changelog/', ObjectChangeLogView.as_view(), name='prefix_changelog', kwargs={'model': Prefix}), - path('prefixes//journal/', ObjectJournalView.as_view(), name='prefix_journal', kwargs={'model': Prefix}), - path('prefixes//prefixes/', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'), - path('prefixes//ip-ranges/', views.PrefixIPRangesView.as_view(), name='prefix_ipranges'), - path('prefixes//ip-addresses/', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'), + path('prefixes//', include(get_model_urls('ipam', 'prefix'))), # IP ranges path('ip-ranges/', views.IPRangeListView.as_view(), name='iprange_list'), @@ -102,9 +92,7 @@ path('ip-ranges//', views.IPRangeView.as_view(), name='iprange'), path('ip-ranges//edit/', views.IPRangeEditView.as_view(), name='iprange_edit'), path('ip-ranges//delete/', views.IPRangeDeleteView.as_view(), name='iprange_delete'), - path('ip-ranges//changelog/', ObjectChangeLogView.as_view(), name='iprange_changelog', kwargs={'model': IPRange}), - path('ip-ranges//journal/', ObjectJournalView.as_view(), name='iprange_journal', kwargs={'model': IPRange}), - path('ip-ranges//ip-addresses/', views.IPRangeIPAddressesView.as_view(), name='iprange_ipaddresses'), + path('ip-ranges//', include(get_model_urls('ipam', 'iprange'))), # IP addresses path('ip-addresses/', views.IPAddressListView.as_view(), name='ipaddress_list'), @@ -113,12 +101,11 @@ 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//changelog/', ObjectChangeLogView.as_view(), name='ipaddress_changelog', kwargs={'model': IPAddress}), - path('ip-addresses//journal/', ObjectJournalView.as_view(), name='ipaddress_journal', kwargs={'model': IPAddress}), path('ip-addresses/assign/', views.IPAddressAssignView.as_view(), name='ipaddress_assign'), path('ip-addresses//', views.IPAddressView.as_view(), name='ipaddress'), path('ip-addresses//edit/', views.IPAddressEditView.as_view(), name='ipaddress_edit'), path('ip-addresses//delete/', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'), + path('ip-addresses//', include(get_model_urls('ipam', 'ipaddress'))), # FHRP groups path('fhrp-groups/', views.FHRPGroupListView.as_view(), name='fhrpgroup_list'), @@ -129,8 +116,7 @@ path('fhrp-groups//', views.FHRPGroupView.as_view(), name='fhrpgroup'), path('fhrp-groups//edit/', views.FHRPGroupEditView.as_view(), name='fhrpgroup_edit'), path('fhrp-groups//delete/', views.FHRPGroupDeleteView.as_view(), name='fhrpgroup_delete'), - path('fhrp-groups//changelog/', ObjectChangeLogView.as_view(), name='fhrpgroup_changelog', kwargs={'model': FHRPGroup}), - path('fhrp-groups//journal/', ObjectJournalView.as_view(), name='fhrpgroup_journal', kwargs={'model': FHRPGroup}), + path('fhrp-groups//', 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 @@ path('vlan-groups//', views.VLANGroupView.as_view(), name='vlangroup'), path('vlan-groups//edit/', views.VLANGroupEditView.as_view(), name='vlangroup_edit'), path('vlan-groups//delete/', views.VLANGroupDeleteView.as_view(), name='vlangroup_delete'), - path('vlan-groups//changelog/', ObjectChangeLogView.as_view(), name='vlangroup_changelog', kwargs={'model': VLANGroup}), + path('vlan-groups//', include(get_model_urls('ipam', 'vlangroup'))), # VLANs path('vlans/', views.VLANListView.as_view(), name='vlan_list'), @@ -155,12 +141,9 @@ path('vlans/edit/', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'), path('vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'), path('vlans//', views.VLANView.as_view(), name='vlan'), - path('vlans//interfaces/', views.VLANInterfacesView.as_view(), name='vlan_interfaces'), - path('vlans//vm-interfaces/', views.VLANVMInterfacesView.as_view(), name='vlan_vminterfaces'), path('vlans//edit/', views.VLANEditView.as_view(), name='vlan_edit'), path('vlans//delete/', views.VLANDeleteView.as_view(), name='vlan_delete'), - path('vlans//changelog/', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}), - path('vlans//journal/', ObjectJournalView.as_view(), name='vlan_journal', kwargs={'model': VLAN}), + path('vlans//', include(get_model_urls('ipam', 'vlan'))), # Service templates path('service-templates/', views.ServiceTemplateListView.as_view(), name='servicetemplate_list'), @@ -171,8 +154,7 @@ path('service-templates//', views.ServiceTemplateView.as_view(), name='servicetemplate'), path('service-templates//edit/', views.ServiceTemplateEditView.as_view(), name='servicetemplate_edit'), path('service-templates//delete/', views.ServiceTemplateDeleteView.as_view(), name='servicetemplate_delete'), - path('service-templates//changelog/', ObjectChangeLogView.as_view(), name='servicetemplate_changelog', kwargs={'model': ServiceTemplate}), - path('service-templates//journal/', ObjectJournalView.as_view(), name='servicetemplate_journal', kwargs={'model': ServiceTemplate}), + path('service-templates//', include(get_model_urls('ipam', 'servicetemplate'))), # Services path('services/', views.ServiceListView.as_view(), name='service_list'), @@ -183,8 +165,7 @@ path('services//', views.ServiceView.as_view(), name='service'), path('services//edit/', views.ServiceEditView.as_view(), name='service_edit'), path('services//delete/', views.ServiceDeleteView.as_view(), name='service_delete'), - path('services//changelog/', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}), - path('services//journal/', ObjectJournalView.as_view(), name='service_journal', kwargs={'model': Service}), + path('services//', include(get_model_urls('ipam', 'service'))), # L2VPN path('l2vpns/', views.L2VPNListView.as_view(), name='l2vpn_list'), @@ -195,9 +176,9 @@ path('l2vpns//', views.L2VPNView.as_view(), name='l2vpn'), path('l2vpns//edit/', views.L2VPNEditView.as_view(), name='l2vpn_edit'), path('l2vpns//delete/', views.L2VPNDeleteView.as_view(), name='l2vpn_delete'), - path('l2vpns//changelog/', ObjectChangeLogView.as_view(), name='l2vpn_changelog', kwargs={'model': L2VPN}), - path('l2vpns//journal/', ObjectJournalView.as_view(), name='l2vpn_journal', kwargs={'model': L2VPN}), + path('l2vpns//', 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 @@ path('l2vpn-terminations//', views.L2VPNTerminationView.as_view(), name='l2vpntermination'), path('l2vpn-terminations//edit/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_edit'), path('l2vpn-terminations//delete/', views.L2VPNTerminationDeleteView.as_view(), name='l2vpntermination_delete'), - path('l2vpn-terminations//changelog/', ObjectChangeLogView.as_view(), name='l2vpntermination_changelog', kwargs={'model': L2VPNTermination}), - path('l2vpn-terminations//journal/', ObjectJournalView.as_view(), name='l2vpntermination_journal', kwargs={'model': L2VPNTermination}), + path('l2vpn-terminations//', include(get_model_urls('ipam', 'l2vpntermination'))), ] diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 04d07e3566..fba577f029 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -3,6 +3,7 @@ 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.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 @@ def prep_table_data(self, request, queryset, parent): 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 @@ def get_extra_context(self, request, instance): } +@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 @@ def prep_table_data(self, request, queryset, parent): 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 @@ def get_children(self, request, parent): 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 @@ def prep_table_data(self, request, queryset, parent): 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 @@ def get_extra_context(self, request, instance): } +@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() diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index ce80cec3e7..f59e72c145 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -13,6 +13,7 @@ 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') diff --git a/netbox/netbox/views/generic/feature_views.py b/netbox/netbox/views/generic/feature_views.py index 85e675a69c..ce5b29eb2e 100644 --- a/netbox/netbox/views/generic/feature_views.py +++ b/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 @@ def get(self, request, model, **kwargs): '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 @@ def get(self, request, model, **kwargs): 'form': form, 'table': journalentry_table, 'base_template': self.base_template, - 'active_tab': 'journal', + 'tab': self.tab, }) diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index a56a832b6e..9aa71b01ba 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -5,7 +5,6 @@ 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 @@ def get(self, request, **kwargs): return render(request, self.get_template_name(), { 'object': instance, + 'tab': self.tab, **self.get_extra_context(request, instance), }) @@ -141,6 +146,7 @@ def get(self, request, *args, **kwargs): 'child_model': self.child_model, 'table': table, 'actions': actions, + 'tab': self.tab, **self.get_extra_context(request, instance), }) diff --git a/netbox/templates/dcim/device/base.html b/netbox/templates/dcim/device/base.html index ea67154b1e..71baa46c3e 100644 --- a/netbox/templates/dcim/device/base.html +++ b/netbox/templates/dcim/device/base.html @@ -54,113 +54,3 @@ {% endif %} {% endblock %} - -{% block extra_tabs %} - {% with tab_name='device-bays' devicebay_count=object.devicebays.count %} - {% if active_tab == tab_name or devicebay_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='module-bays' modulebay_count=object.modulebays.count %} - {% if active_tab == tab_name or modulebay_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='interfaces' interface_count=object.interfaces_count %} - {% if active_tab == tab_name or interface_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='front-ports' frontport_count=object.frontports.count %} - {% if active_tab == tab_name or frontport_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='rear-ports' rearport_count=object.rearports.count %} - {% if active_tab == tab_name or rearport_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='console-ports' consoleport_count=object.consoleports.count %} - {% if active_tab == tab_name or consoleport_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='console-server-ports' consoleserverport_count=object.consoleserverports.count %} - {% if active_tab == tab_name or consoleserverport_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='power-ports' powerport_count=object.powerports.count %} - {% if active_tab == tab_name or powerport_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='power-outlets' poweroutlet_count=object.poweroutlets.count %} - {% if active_tab == tab_name or poweroutlet_count %} - - {% endif %} - {% endwith %} - - - {% with tab_name='inventory-items' inventoryitem_count=object.inventoryitems.count %} - {% if active_tab == tab_name or inventoryitem_count %} - - {% endif %} - {% endwith %} - - {% if perms.dcim.napalm_read_device and object.status == 'active' and object.primary_ip and object.platform.napalm_driver %} - {# NAPALM-enabled tabs #} - - - - {% endif %} - - {% if perms.extras.view_configcontext %} - - {% endif %} -{% endblock %} diff --git a/netbox/templates/dcim/devicetype/base.html b/netbox/templates/dcim/devicetype/base.html index 83ee1f41ee..916952dfb9 100644 --- a/netbox/templates/dcim/devicetype/base.html +++ b/netbox/templates/dcim/devicetype/base.html @@ -51,85 +51,3 @@ {% endif %} {% endblock %} - -{% block extra_tabs %} - {% with tab_name='device-bay-templates' devicebay_count=object.devicebaytemplates.count %} - {% if active_tab == tab_name or devicebay_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='module-bay-templates' modulebay_count=object.modulebaytemplates.count %} - {% if active_tab == tab_name or modulebay_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='interface-templates' interface_count=object.interfacetemplates.count %} - {% if active_tab == tab_name or interface_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='front-port-templates' frontport_count=object.frontporttemplates.count %} - {% if active_tab == tab_name or frontport_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='rear-port-templates' rearport_count=object.rearporttemplates.count %} - {% if active_tab == tab_name or rearport_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='console-port-templates' consoleport_count=object.consoleporttemplates.count %} - {% if active_tab == tab_name or consoleport_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='console-server-port-templates' consoleserverport_count=object.consoleserverporttemplates.count %} - {% if active_tab == tab_name or consoleserverport_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='power-port-templates' powerport_count=object.powerporttemplates.count %} - {% if active_tab == tab_name or powerport_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='power-outlet-templates' poweroutlet_count=object.poweroutlettemplates.count %} - {% if active_tab == tab_name or poweroutlet_count %} - - {% endif %} - {% endwith %} - - {% with tab_name='inventory-item-templates' inventoryitem_count=object.inventoryitemtemplates.count %} - {% if active_tab == tab_name or inventoryitem_count %} - - {% endif %} - {% endwith %} -{% endblock %} diff --git a/netbox/templates/dcim/moduletype/base.html b/netbox/templates/dcim/moduletype/base.html index f5713efc37..148effec24 100644 --- a/netbox/templates/dcim/moduletype/base.html +++ b/netbox/templates/dcim/moduletype/base.html @@ -42,61 +42,3 @@ {% endif %} {% endblock %} - -{% block extra_tabs %} - {% with interface_count=object.interfacetemplates.count %} - {% if interface_count %} - - {% endif %} - {% endwith %} - - {% with frontport_count=object.frontporttemplates.count %} - {% if frontport_count %} - - {% endif %} - {% endwith %} - - {% with rearport_count=object.rearporttemplates.count %} - {% if rearport_count %} - - {% endif %} - {% endwith %} - - {% with consoleport_count=object.consoleporttemplates.count %} - {% if consoleport_count %} - - {% endif %} - {% endwith %} - - {% with consoleserverport_count=object.consoleserverporttemplates.count %} - {% if consoleserverport_count %} - - {% endif %} - {% endwith %} - - {% with powerport_count=object.powerporttemplates.count %} - {% if powerport_count %} - - {% endif %} - {% endwith %} - - {% with poweroutlet_count=object.poweroutlettemplates.count %} - {% if poweroutlet_count %} - - {% endif %} - {% endwith %} -{% endblock %} diff --git a/netbox/templates/generic/object.html b/netbox/templates/generic/object.html index ef95ccdc01..023726a30e 100644 --- a/netbox/templates/generic/object.html +++ b/netbox/templates/generic/object.html @@ -4,6 +4,7 @@ {% load helpers %} {% load perms %} {% load plugins %} +{% load tabs %} {% comment %} Blocks: @@ -80,37 +81,14 @@ {% endblock tabs %} diff --git a/netbox/templates/ipam/aggregate/base.html b/netbox/templates/ipam/aggregate/base.html index c69661a65d..968c4a041b 100644 --- a/netbox/templates/ipam/aggregate/base.html +++ b/netbox/templates/ipam/aggregate/base.html @@ -6,13 +6,3 @@ {{ block.super }} {% endblock %} - -{% block extra_tabs %} - {% if perms.ipam.view_prefix %} - - {% endif %} -{% endblock %} diff --git a/netbox/templates/ipam/iprange/base.html b/netbox/templates/ipam/iprange/base.html index 30e8582644..e97db85571 100644 --- a/netbox/templates/ipam/iprange/base.html +++ b/netbox/templates/ipam/iprange/base.html @@ -8,13 +8,3 @@ {% endif %} {% endblock %} - -{% block extra_tabs %} - {% if perms.ipam.view_ipaddress %} - - {% endif %} -{% endblock %} diff --git a/netbox/templates/ipam/prefix/base.html b/netbox/templates/ipam/prefix/base.html index b543e37ac2..7ac3070140 100644 --- a/netbox/templates/ipam/prefix/base.html +++ b/netbox/templates/ipam/prefix/base.html @@ -8,21 +8,3 @@ {% endif %} {% endblock %} - -{% block extra_tabs %} - - - -{% endblock %} diff --git a/netbox/templates/ipam/vlan/base.html b/netbox/templates/ipam/vlan/base.html index 42c6c03475..031ae6517d 100644 --- a/netbox/templates/ipam/vlan/base.html +++ b/netbox/templates/ipam/vlan/base.html @@ -13,27 +13,3 @@ {% endif %} {% endblock %} - -{% block tabs %} - -{% endblock %} diff --git a/netbox/templates/virtualization/cluster/base.html b/netbox/templates/virtualization/cluster/base.html index 69b55ec6b4..eb9eefe0ef 100644 --- a/netbox/templates/virtualization/cluster/base.html +++ b/netbox/templates/virtualization/cluster/base.html @@ -24,20 +24,3 @@ {% endif %} {% endblock %} - -{% block extra_tabs %} - {% with virtualmachine_count=object.virtual_machines.count %} - - {% endwith %} - {% with device_count=object.devices.count %} - - {% endwith %} -{% endblock %} diff --git a/netbox/templates/virtualization/virtualmachine/base.html b/netbox/templates/virtualization/virtualmachine/base.html index 946467e31f..995c16fb00 100644 --- a/netbox/templates/virtualization/virtualmachine/base.html +++ b/netbox/templates/virtualization/virtualmachine/base.html @@ -21,18 +21,3 @@ {% endif %} {% endblock %} - -{% block extra_tabs %} - {% with interface_count=object.interfaces.count %} - {% if interface_count %} - - {% endif %} - {% endwith %} - {% if perms.extras.view_configcontext %} - - {% endif %} -{% endblock %} diff --git a/netbox/tenancy/urls.py b/netbox/tenancy/urls.py index 2141002757..b55e949dd4 100644 --- a/netbox/tenancy/urls.py +++ b/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 @@ path('tenant-groups//', views.TenantGroupView.as_view(), name='tenantgroup'), path('tenant-groups//edit/', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'), path('tenant-groups//delete/', views.TenantGroupDeleteView.as_view(), name='tenantgroup_delete'), - path('tenant-groups//changelog/', ObjectChangeLogView.as_view(), name='tenantgroup_changelog', kwargs={'model': TenantGroup}), + path('tenant-groups//', include(get_model_urls('tenancy', 'tenantgroup'))), # Tenants path('tenants/', views.TenantListView.as_view(), name='tenant_list'), @@ -27,8 +26,7 @@ path('tenants//', views.TenantView.as_view(), name='tenant'), path('tenants//edit/', views.TenantEditView.as_view(), name='tenant_edit'), path('tenants//delete/', views.TenantDeleteView.as_view(), name='tenant_delete'), - path('tenants//changelog/', ObjectChangeLogView.as_view(), name='tenant_changelog', kwargs={'model': Tenant}), - path('tenants//journal/', ObjectJournalView.as_view(), name='tenant_journal', kwargs={'model': Tenant}), + path('tenants//', include(get_model_urls('tenancy', 'tenant'))), # Contact groups path('contact-groups/', views.ContactGroupListView.as_view(), name='contactgroup_list'), @@ -39,7 +37,7 @@ path('contact-groups//', views.ContactGroupView.as_view(), name='contactgroup'), path('contact-groups//edit/', views.ContactGroupEditView.as_view(), name='contactgroup_edit'), path('contact-groups//delete/', views.ContactGroupDeleteView.as_view(), name='contactgroup_delete'), - path('contact-groups//changelog/', ObjectChangeLogView.as_view(), name='contactgroup_changelog', kwargs={'model': ContactGroup}), + path('contact-groups//', include(get_model_urls('tenancy', 'contactgroup'))), # Contact roles path('contact-roles/', views.ContactRoleListView.as_view(), name='contactrole_list'), @@ -50,7 +48,7 @@ path('contact-roles//', views.ContactRoleView.as_view(), name='contactrole'), path('contact-roles//edit/', views.ContactRoleEditView.as_view(), name='contactrole_edit'), path('contact-roles//delete/', views.ContactRoleDeleteView.as_view(), name='contactrole_delete'), - path('contact-roles//changelog/', ObjectChangeLogView.as_view(), name='contactrole_changelog', kwargs={'model': ContactRole}), + path('contact-roles//', include(get_model_urls('tenancy', 'contactrole'))), # Contacts path('contacts/', views.ContactListView.as_view(), name='contact_list'), @@ -61,8 +59,7 @@ path('contacts//', views.ContactView.as_view(), name='contact'), path('contacts//edit/', views.ContactEditView.as_view(), name='contact_edit'), path('contacts//delete/', views.ContactDeleteView.as_view(), name='contact_delete'), - path('contacts//changelog/', ObjectChangeLogView.as_view(), name='contact_changelog', kwargs={'model': Contact}), - path('contacts//journal/', ObjectJournalView.as_view(), name='contact_journal', kwargs={'model': Contact}), + path('contacts//', include(get_model_urls('tenancy', 'contact'))), # Contact assignments path('contact-assignments/add/', views.ContactAssignmentEditView.as_view(), name='contactassignment_add'), diff --git a/netbox/utilities/templates/tabs/model_view_tabs.html b/netbox/utilities/templates/tabs/model_view_tabs.html new file mode 100644 index 0000000000..e41acf4c00 --- /dev/null +++ b/netbox/utilities/templates/tabs/model_view_tabs.html @@ -0,0 +1,7 @@ +{% for tab in tabs %} + +{% endfor %} diff --git a/netbox/utilities/templatetags/tabs.py b/netbox/utilities/templatetags/tabs.py new file mode 100644 index 0000000000..6f245eff37 --- /dev/null +++ b/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, + } diff --git a/netbox/utilities/urls.py b/netbox/utilities/urls.py new file mode 100644 index 0000000000..9ba2a65e61 --- /dev/null +++ b/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 diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 858e7b4913..75c5aecff4 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -3,8 +3,17 @@ 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 @@ def get_return_url(self, request, obj=None): # 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//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 diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py index e01dbc0596..31914bc3b0 100644 --- a/netbox/virtualization/urls.py +++ b/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 @@ path('cluster-types//', views.ClusterTypeView.as_view(), name='clustertype'), path('cluster-types//edit/', views.ClusterTypeEditView.as_view(), name='clustertype_edit'), path('cluster-types//delete/', views.ClusterTypeDeleteView.as_view(), name='clustertype_delete'), - path('cluster-types//changelog/', ObjectChangeLogView.as_view(), name='clustertype_changelog', kwargs={'model': ClusterType}), + path('cluster-types//', include(get_model_urls('virtualization', 'clustertype'))), # Cluster groups path('cluster-groups/', views.ClusterGroupListView.as_view(), name='clustergroup_list'), @@ -27,7 +26,7 @@ path('cluster-groups//', views.ClusterGroupView.as_view(), name='clustergroup'), path('cluster-groups//edit/', views.ClusterGroupEditView.as_view(), name='clustergroup_edit'), path('cluster-groups//delete/', views.ClusterGroupDeleteView.as_view(), name='clustergroup_delete'), - path('cluster-groups//changelog/', ObjectChangeLogView.as_view(), name='clustergroup_changelog', kwargs={'model': ClusterGroup}), + path('cluster-groups//', include(get_model_urls('virtualization', 'clustergroup'))), # Clusters path('clusters/', views.ClusterListView.as_view(), name='cluster_list'), @@ -36,14 +35,11 @@ path('clusters/edit/', views.ClusterBulkEditView.as_view(), name='cluster_bulk_edit'), path('clusters/delete/', views.ClusterBulkDeleteView.as_view(), name='cluster_bulk_delete'), path('clusters//', views.ClusterView.as_view(), name='cluster'), - path('clusters//devices/', views.ClusterDevicesView.as_view(), name='cluster_devices'), - path('clusters//virtual-machines/', views.ClusterVirtualMachinesView.as_view(), name='cluster_virtualmachines'), path('clusters//edit/', views.ClusterEditView.as_view(), name='cluster_edit'), path('clusters//delete/', views.ClusterDeleteView.as_view(), name='cluster_delete'), - path('clusters//changelog/', ObjectChangeLogView.as_view(), name='cluster_changelog', kwargs={'model': Cluster}), - path('clusters//journal/', ObjectJournalView.as_view(), name='cluster_journal', kwargs={'model': Cluster}), path('clusters//devices/add/', views.ClusterAddDevicesView.as_view(), name='cluster_add_devices'), path('clusters//devices/remove/', views.ClusterRemoveDevicesView.as_view(), name='cluster_remove_devices'), + path('clusters//', include(get_model_urls('virtualization', 'cluster'))), # Virtual machines path('virtual-machines/', views.VirtualMachineListView.as_view(), name='virtualmachine_list'), @@ -52,12 +48,9 @@ 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//', views.VirtualMachineView.as_view(), name='virtualmachine'), - path('virtual-machines//interfaces/', views.VirtualMachineInterfacesView.as_view(), name='virtualmachine_interfaces'), path('virtual-machines//edit/', views.VirtualMachineEditView.as_view(), name='virtualmachine_edit'), path('virtual-machines//delete/', views.VirtualMachineDeleteView.as_view(), name='virtualmachine_delete'), - path('virtual-machines//config-context/', views.VirtualMachineConfigContextView.as_view(), name='virtualmachine_configcontext'), - path('virtual-machines//changelog/', ObjectChangeLogView.as_view(), name='virtualmachine_changelog', kwargs={'model': VirtualMachine}), - path('virtual-machines//journal/', ObjectJournalView.as_view(), name='virtualmachine_journal', kwargs={'model': VirtualMachine}), + path('virtual-machines//', include(get_model_urls('virtualization', 'virtualmachine'))), # VM interfaces path('interfaces/', views.VMInterfaceListView.as_view(), name='vminterface_list'), @@ -69,7 +62,7 @@ path('interfaces//', views.VMInterfaceView.as_view(), name='vminterface'), path('interfaces//edit/', views.VMInterfaceEditView.as_view(), name='vminterface_edit'), path('interfaces//delete/', views.VMInterfaceDeleteView.as_view(), name='vminterface_delete'), - path('interfaces//changelog/', ObjectChangeLogView.as_view(), name='vminterface_changelog', kwargs={'model': VMInterface}), + path('interfaces//', include(get_model_urls('virtualization', 'vminterface'))), path('virtual-machines/interfaces/add/', views.VirtualMachineBulkAddInterfaceView.as_view(), name='virtualmachine_bulk_add_vminterface'), ] diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 611725d62e..6a4cc32f43 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -3,6 +3,7 @@ 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.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 @@ def get_extra_context(self, request, instance): } +@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 @@ def get_children(self, request, parent): '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): diff --git a/netbox/wireless/urls.py b/netbox/wireless/urls.py index cef96fd5e3..d6e84b1b83 100644 --- a/netbox/wireless/urls.py +++ b/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 @@ path('wireless-lan-groups//', views.WirelessLANGroupView.as_view(), name='wirelesslangroup'), path('wireless-lan-groups//edit/', views.WirelessLANGroupEditView.as_view(), name='wirelesslangroup_edit'), path('wireless-lan-groups//delete/', views.WirelessLANGroupDeleteView.as_view(), name='wirelesslangroup_delete'), - path('wireless-lan-groups//changelog/', ObjectChangeLogView.as_view(), name='wirelesslangroup_changelog', kwargs={'model': WirelessLANGroup}), + path('wireless-lan-groups//', include(get_model_urls('wireless', 'wirelesslangroup'))), # Wireless LANs path('wireless-lans/', views.WirelessLANListView.as_view(), name='wirelesslan_list'), @@ -27,8 +26,7 @@ path('wireless-lans//', views.WirelessLANView.as_view(), name='wirelesslan'), path('wireless-lans//edit/', views.WirelessLANEditView.as_view(), name='wirelesslan_edit'), path('wireless-lans//delete/', views.WirelessLANDeleteView.as_view(), name='wirelesslan_delete'), - path('wireless-lans//changelog/', ObjectChangeLogView.as_view(), name='wirelesslan_changelog', kwargs={'model': WirelessLAN}), - path('wireless-lans//journal/', ObjectJournalView.as_view(), name='wirelesslan_journal', kwargs={'model': WirelessLAN}), + path('wireless-lans//', include(get_model_urls('wireless', 'wirelesslan'))), # Wireless links path('wireless-links/', views.WirelessLinkListView.as_view(), name='wirelesslink_list'), @@ -39,7 +37,6 @@ path('wireless-links//', views.WirelessLinkView.as_view(), name='wirelesslink'), path('wireless-links//edit/', views.WirelessLinkEditView.as_view(), name='wirelesslink_edit'), path('wireless-links//delete/', views.WirelessLinkDeleteView.as_view(), name='wirelesslink_delete'), - path('wireless-links//changelog/', ObjectChangeLogView.as_view(), name='wirelesslink_changelog', kwargs={'model': WirelessLink}), - path('wireless-links//journal/', ObjectJournalView.as_view(), name='wirelesslink_journal', kwargs={'model': WirelessLink}), + path('wireless-links//', include(get_model_urls('wireless', 'wirelesslink'))), )