Browse Source

Merge branch 'develop' into feature

jeremystretch 4 years ago
parent
commit
2c2e37e9f0

+ 1 - 1
.github/ISSUE_TEMPLATE/bug_report.yaml

@@ -17,7 +17,7 @@ body:
         What version of NetBox are you currently running? (If you don't have access to the most
         What version of NetBox are you currently running? (If you don't have access to the most
         recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/)
         recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/)
         before opening a bug report to see if your issue has already been addressed.)
         before opening a bug report to see if your issue has already been addressed.)
-      placeholder: v3.0.8
+      placeholder: v3.0.9
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

+ 1 - 1
.github/ISSUE_TEMPLATE/feature_request.yaml

@@ -14,7 +14,7 @@ body:
     attributes:
     attributes:
       label: NetBox version
       label: NetBox version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v3.0.8
+      placeholder: v3.0.9
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

+ 1 - 1
docs/core-functionality/power.md

@@ -5,4 +5,4 @@
 
 
 # Example Power Topology
 # Example Power Topology
 
 
-![Power distribution model](/media/power_distribution.png)
+![Power distribution model](../media/power_distribution.png)

+ 17 - 1
docs/customization/custom-scripts.md

@@ -240,7 +240,7 @@ An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two a
 !!! note
 !!! note
     To run a custom script, a user must be assigned the `extras.run_script` permission. This is achieved by assigning the user (or group) a permission on the Script object and specifying the `run` action in the admin UI as shown below.
     To run a custom script, a user must be assigned the `extras.run_script` permission. This is achieved by assigning the user (or group) a permission on the Script object and specifying the `run` action in the admin UI as shown below.
 
 
-    ![Adding the run action to a permission](/media/admin_ui_run_permission.png)
+    ![Adding the run action to a permission](../media/admin_ui_run_permission.png)
 
 
 ### Via the Web UI
 ### Via the Web UI
 
 
@@ -259,6 +259,22 @@ http://netbox/api/extras/scripts/example.MyReport/ \
 --data '{"data": {"foo": "somevalue", "bar": 123}, "commit": true}'
 --data '{"data": {"foo": "somevalue", "bar": 123}, "commit": true}'
 ```
 ```
 
 
+### Via the CLI
+
+Scripts can be run on the CLI by invoking the management command:
+
+```
+python3 manage.py runscript [--commit] [--loglevel {debug,info,warning,error,critical}] [--data "<data>"] <module>.<script> 
+```
+
+The required ``<module>.<script>`` argument is the script to run where ``<module>`` is the name of the python file in the ``scripts`` directory without the ``.py`` extension and ``<script>`` is the name of the script class in the ``<module>`` to run.
+
+The optional ``--data "<data>"`` argument is the data to send to the script
+
+The optional ``--loglevel`` argument is the desired logging level to output to the console.
+
+The optional ``--commit`` argument will commit any changes in the script to the database.
+
 ## Example
 ## Example
 
 
 Below is an example script that creates new objects for a planned site. The user is prompted for three variables:
 Below is an example script that creates new objects for a planned site. The user is prompted for three variables:

+ 1 - 1
docs/models/dcim/cable.md

@@ -25,7 +25,7 @@ A cable may be traced from either of its endpoints by clicking the "trace" butto
 
 
 In the example below, three individual cables comprise a path between devices A and D:
 In the example below, three individual cables comprise a path between devices A and D:
 
 
-![Cable path](/media/models/dcim_cable_trace.png)
+![Cable path](../media/models/dcim_cable_trace.png)
 
 
 Traced from Interface 1 on Device A, NetBox will show the following path:
 Traced from Interface 1 on Device A, NetBox will show the following path:
 
 

+ 24 - 1
docs/release-notes/version-3.0.md

@@ -1,6 +1,29 @@
 # NetBox v3.0
 # NetBox v3.0
 
 
-## v3.0.9 (FUTURE)
+## v3.0.10 (FUTURE)
+
+---
+
+## v3.0.9 (2021-11-03)
+
+### Enhancements
+
+* [#6529](https://github.com/netbox-community/netbox/issues/6529) - Introduce the `runscript` management command
+* [#6930](https://github.com/netbox-community/netbox/issues/6930) - Add an optional "ID" column to all tables
+* [#7668](https://github.com/netbox-community/netbox/issues/7668) - Add "view elevations" button to location view
+
+### Bug Fixes
+
+* [#7599](https://github.com/netbox-community/netbox/issues/7599) - Improve color mode preference handling
+* [#7601](https://github.com/netbox-community/netbox/issues/7601) - Correct devices count for locations within global search results
+* [#7612](https://github.com/netbox-community/netbox/issues/7612) - Strip HTML from custom field descriptions
+* [#7628](https://github.com/netbox-community/netbox/issues/7628) - Fix `load_yaml` method for custom scripts
+* [#7643](https://github.com/netbox-community/netbox/issues/7643) - Fix circuit assignment when creating multiple terminations simultaneously
+* [#7644](https://github.com/netbox-community/netbox/issues/7644) - Prevent inadvertent deletion of prior change records when deleting objects (#7333 revisited)
+* [#7647](https://github.com/netbox-community/netbox/issues/7647) - Require interface assignment when designating IP address as primary for device/VM during CSV import
+* [#7664](https://github.com/netbox-community/netbox/issues/7664) - Preserve initial form data when bulk edit validation fails
+* [#7717](https://github.com/netbox-community/netbox/issues/7717) - Restore missing tags column on IP range table
+* [#7721](https://github.com/netbox-community/netbox/issues/7721) - Retain pagination preference when `MAX_PAGE_SIZE` is zero
 
 
 ---
 ---
 
 

+ 1 - 0
netbox/circuits/signals.py

@@ -11,6 +11,7 @@ def update_circuit(instance, **kwargs):
     When a CircuitTermination has been modified, update its parent Circuit.
     When a CircuitTermination has been modified, update its parent Circuit.
     """
     """
     termination_name = f'termination_{instance.term_side.lower()}'
     termination_name = f'termination_{instance.term_side.lower()}'
+    instance.circuit.refresh_from_db()
     setattr(instance.circuit, termination_name, instance)
     setattr(instance.circuit, termination_name, instance)
     instance.circuit.save()
     instance.circuit.save()
 
 

+ 6 - 6
netbox/circuits/tables.py

@@ -44,8 +44,8 @@ class ProviderTable(BaseTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Provider
         model = Provider
         fields = (
         fields = (
-            'pk', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count', 'comments',
-            'tags',
+            'pk', 'id', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count',
+            'comments', 'tags',
         )
         )
         default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count')
         default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count')
 
 
@@ -69,7 +69,7 @@ class ProviderNetworkTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = ProviderNetwork
         model = ProviderNetwork
-        fields = ('pk', 'name', 'provider', 'description', 'comments', 'tags')
+        fields = ('pk', 'id', 'name', 'provider', 'description', 'comments', 'tags')
         default_columns = ('pk', 'name', 'provider', 'description')
         default_columns = ('pk', 'name', 'provider', 'description')
 
 
 
 
@@ -92,7 +92,7 @@ class CircuitTypeTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = CircuitType
         model = CircuitType
-        fields = ('pk', 'name', 'circuit_count', 'description', 'slug', 'tags', 'actions')
+        fields = ('pk', 'id', 'name', 'circuit_count', 'description', 'slug', 'tags', 'actions')
         default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug', 'actions')
         default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug', 'actions')
 
 
 
 
@@ -104,7 +104,7 @@ class CircuitTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
     cid = tables.Column(
     cid = tables.Column(
         linkify=True,
         linkify=True,
-        verbose_name='ID'
+        verbose_name='Circuit ID'
     )
     )
     provider = tables.Column(
     provider = tables.Column(
         linkify=True
         linkify=True
@@ -127,7 +127,7 @@ class CircuitTable(BaseTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Circuit
         model = Circuit
         fields = (
         fields = (
-            'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date',
+            'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date',
             'commit_rate', 'description', 'comments', 'tags',
             'commit_rate', 'description', 'comments', 'tags',
         )
         )
         default_columns = (
         default_columns = (

+ 3 - 0
netbox/dcim/tables/__init__.py

@@ -43,6 +43,7 @@ class ConsoleConnectionTable(BaseTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = ConsolePort
         model = ConsolePort
         fields = ('device', 'name', 'console_server', 'console_server_port', 'reachable')
         fields = ('device', 'name', 'console_server', 'console_server_port', 'reachable')
+        exclude = ('id', )
 
 
 
 
 class PowerConnectionTable(BaseTable):
 class PowerConnectionTable(BaseTable):
@@ -73,6 +74,7 @@ class PowerConnectionTable(BaseTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = PowerPort
         model = PowerPort
         fields = ('device', 'name', 'pdu', 'outlet', 'reachable')
         fields = ('device', 'name', 'pdu', 'outlet', 'reachable')
+        exclude = ('id', )
 
 
 
 
 class InterfaceConnectionTable(BaseTable):
 class InterfaceConnectionTable(BaseTable):
@@ -106,3 +108,4 @@ class InterfaceConnectionTable(BaseTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Interface
         model = Interface
         fields = ('device_a', 'interface_a', 'device_b', 'interface_b', 'reachable')
         fields = ('device_a', 'interface_a', 'device_b', 'interface_b', 'reachable')
+        exclude = ('id', )

+ 0 - 4
netbox/dcim/tables/cables.py

@@ -17,10 +17,6 @@ __all__ = (
 
 
 class CableTable(BaseTable):
 class CableTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
-    id = tables.Column(
-        linkify=True,
-        verbose_name='ID'
-    )
     termination_a_parent = tables.TemplateColumn(
     termination_a_parent = tables.TemplateColumn(
         template_code=CABLE_TERMINATION_PARENT,
         template_code=CABLE_TERMINATION_PARENT,
         accessor=Accessor('termination_a'),
         accessor=Accessor('termination_a'),

+ 30 - 29
netbox/dcim/tables/devices.py

@@ -1,6 +1,5 @@
 import django_tables2 as tables
 import django_tables2 as tables
 from django_tables2.utils import Accessor
 from django_tables2.utils import Accessor
-from django.conf import settings
 
 
 from dcim.models import (
 from dcim.models import (
     ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem, Platform,
     ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem, Platform,
@@ -15,6 +14,7 @@ from .template_code import *
 
 
 __all__ = (
 __all__ = (
     'BaseInterfaceTable',
     'BaseInterfaceTable',
+    'CableTerminationTable',
     'ConsolePortTable',
     'ConsolePortTable',
     'ConsoleServerPortTable',
     'ConsoleServerPortTable',
     'DeviceBayTable',
     'DeviceBayTable',
@@ -88,7 +88,8 @@ class DeviceRoleTable(BaseTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = DeviceRole
         model = DeviceRole
         fields = (
         fields = (
-            'pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'tags', 'actions',
+            'pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'tags',
+            'actions',
         )
         )
         default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'actions')
         default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'actions')
 
 
@@ -120,7 +121,7 @@ class PlatformTable(BaseTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Platform
         model = Platform
         fields = (
         fields = (
-            'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args',
+            'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args',
             'description', 'tags', 'actions',
             'description', 'tags', 'actions',
         )
         )
         default_columns = (
         default_columns = (
@@ -193,8 +194,8 @@ class DeviceTable(BaseTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Device
         model = Device
         fields = (
         fields = (
-            'pk', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial',
-            'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'airflow', 'primary_ip', 'primary_ip4',
+            'pk', 'id', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial',
+            'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'airflow', 'primary_ip4',
             'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags',
             'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags',
         )
         )
         default_columns = (
         default_columns = (
@@ -224,7 +225,7 @@ class DeviceImportTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Device
         model = Device
-        fields = ('name', 'status', 'tenant', 'site', 'rack', 'position', 'device_role', 'device_type')
+        fields = ('id', 'name', 'status', 'tenant', 'site', 'rack', 'position', 'device_role', 'device_type')
         empty_text = False
         empty_text = False
 
 
 
 
@@ -287,7 +288,7 @@ class ConsolePortTable(DeviceComponentTable, PathEndpointTable):
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = ConsolePort
         model = ConsolePort
         fields = (
         fields = (
-            'pk', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
+            'pk', 'id', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
             'link_peer', 'connection', 'tags',
             'link_peer', 'connection', 'tags',
         )
         )
         default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
         default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
@@ -308,7 +309,7 @@ class DeviceConsolePortTable(ConsolePortTable):
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = ConsolePort
         model = ConsolePort
         fields = (
         fields = (
-            'pk', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
+            'pk', 'id', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
             'link_peer', 'connection', 'tags', 'actions'
             'link_peer', 'connection', 'tags', 'actions'
         )
         )
         default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions')
         default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions')
@@ -331,8 +332,8 @@ class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable):
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = ConsoleServerPort
         model = ConsoleServerPort
         fields = (
         fields = (
-            'pk', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
-            'link_peer', 'connection', 'tags',
+            'pk', 'id', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable',
+            'cable_color', 'link_peer', 'connection', 'tags',
         )
         )
         default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
         default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
 
 
@@ -353,7 +354,7 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable):
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = ConsoleServerPort
         model = ConsoleServerPort
         fields = (
         fields = (
-            'pk', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
+            'pk', 'id', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
             'link_peer', 'connection', 'tags', 'actions',
             'link_peer', 'connection', 'tags', 'actions',
         )
         )
         default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions')
         default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions')
@@ -376,8 +377,8 @@ class PowerPortTable(DeviceComponentTable, PathEndpointTable):
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = PowerPort
         model = PowerPort
         fields = (
         fields = (
-            'pk', 'name', 'device', 'label', 'type', 'description', 'mark_connected', 'maximum_draw', 'allocated_draw',
-            'cable', 'cable_color', 'link_peer', 'connection', 'tags',
+            'pk', 'id', 'name', 'device', 'label', 'type', 'description', 'mark_connected', 'maximum_draw',
+            'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'tags',
         )
         )
         default_columns = ('pk', 'name', 'device', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
         default_columns = ('pk', 'name', 'device', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
 
 
@@ -398,8 +399,8 @@ class DevicePowerPortTable(PowerPortTable):
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = PowerPort
         model = PowerPort
         fields = (
         fields = (
-            'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', 'cable',
-            'cable_color', 'link_peer', 'connection', 'tags', 'actions',
+            'pk', 'id', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected',
+            'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions',
         )
         )
         default_columns = (
         default_columns = (
             'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection',
             'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection',
@@ -427,8 +428,8 @@ class PowerOutletTable(DeviceComponentTable, PathEndpointTable):
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = PowerOutlet
         model = PowerOutlet
         fields = (
         fields = (
-            'pk', 'name', 'device', 'label', 'type', 'description', 'power_port', 'feed_leg', 'mark_connected', 'cable',
-            'cable_color', 'link_peer', 'connection', 'tags',
+            'pk', 'id', 'name', 'device', 'label', 'type', 'description', 'power_port', 'feed_leg', 'mark_connected',
+            'cable', 'cable_color', 'link_peer', 'connection', 'tags',
         )
         )
         default_columns = ('pk', 'name', 'device', 'label', 'type', 'power_port', 'feed_leg', 'description')
         default_columns = ('pk', 'name', 'device', 'label', 'type', 'power_port', 'feed_leg', 'description')
 
 
@@ -448,7 +449,7 @@ class DevicePowerOutletTable(PowerOutletTable):
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = PowerOutlet
         model = PowerOutlet
         fields = (
         fields = (
-            'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'mark_connected', 'cable',
+            'pk', 'id', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'mark_connected', 'cable',
             'cable_color', 'link_peer', 'connection', 'tags', 'actions',
             'cable_color', 'link_peer', 'connection', 'tags', 'actions',
         )
         )
         default_columns = (
         default_columns = (
@@ -497,7 +498,7 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = Interface
         model = Interface
         fields = (
         fields = (
-            'pk', 'name', 'device', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn',
+            'pk', 'id', 'name', 'device', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn',
             'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description',
             'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description',
             'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection',
             'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection',
             'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans',
             'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans',
@@ -532,7 +533,7 @@ class DeviceInterfaceTable(InterfaceTable):
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = Interface
         model = Interface
         fields = (
         fields = (
-            'pk', 'name', 'label', 'enabled', 'type', 'parent', 'bridge', 'lag', 'mgmt_only', 'mtu', 'mode',
+            'pk', 'id', 'name', 'label', 'enabled', 'type', 'parent', 'bridge', 'lag', 'mgmt_only', 'mtu', 'mode',
             'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_width', 'tx_power', 'description',
             'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_width', 'tx_power', 'description',
             'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection',
             'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection',
             'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', 'actions',
             'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', 'actions',
@@ -570,7 +571,7 @@ class FrontPortTable(DeviceComponentTable, CableTerminationTable):
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = FrontPort
         model = FrontPort
         fields = (
         fields = (
-            'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
+            'pk', 'id', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
             'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags',
             'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags',
         )
         )
         default_columns = (
         default_columns = (
@@ -594,7 +595,7 @@ class DeviceFrontPortTable(FrontPortTable):
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = FrontPort
         model = FrontPort
         fields = (
         fields = (
-            'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'mark_connected', 'cable',
+            'pk', 'id', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'mark_connected', 'cable',
             'cable_color', 'link_peer', 'tags', 'actions',
             'cable_color', 'link_peer', 'tags', 'actions',
         )
         )
         default_columns = (
         default_columns = (
@@ -621,7 +622,7 @@ class RearPortTable(DeviceComponentTable, CableTerminationTable):
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = RearPort
         model = RearPort
         fields = (
         fields = (
-            'pk', 'name', 'device', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable',
+            'pk', 'id', 'name', 'device', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable',
             'cable_color', 'link_peer', 'tags',
             'cable_color', 'link_peer', 'tags',
         )
         )
         default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'description')
         default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'description')
@@ -643,7 +644,7 @@ class DeviceRearPortTable(RearPortTable):
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = RearPort
         model = RearPort
         fields = (
         fields = (
-            'pk', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable', 'cable_color',
+            'pk', 'id', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable', 'cable_color',
             'link_peer', 'tags', 'actions',
             'link_peer', 'tags', 'actions',
         )
         )
         default_columns = (
         default_columns = (
@@ -673,7 +674,7 @@ class DeviceBayTable(DeviceComponentTable):
 
 
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = DeviceBay
         model = DeviceBay
-        fields = ('pk', 'name', 'device', 'label', 'status', 'installed_device', 'description', 'tags')
+        fields = ('pk', 'id', 'name', 'device', 'label', 'status', 'installed_device', 'description', 'tags')
         default_columns = ('pk', 'name', 'device', 'label', 'status', 'installed_device', 'description')
         default_columns = ('pk', 'name', 'device', 'label', 'status', 'installed_device', 'description')
 
 
 
 
@@ -693,7 +694,7 @@ class DeviceDeviceBayTable(DeviceBayTable):
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = DeviceBay
         model = DeviceBay
         fields = (
         fields = (
-            'pk', 'name', 'label', 'status', 'installed_device', 'description', 'tags', 'actions',
+            'pk', 'id', 'name', 'label', 'status', 'installed_device', 'description', 'tags', 'actions',
         )
         )
         default_columns = (
         default_columns = (
             'pk', 'name', 'label', 'status', 'installed_device', 'description', 'actions',
             'pk', 'name', 'label', 'status', 'installed_device', 'description', 'actions',
@@ -719,7 +720,7 @@ class InventoryItemTable(DeviceComponentTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = InventoryItem
         model = InventoryItem
         fields = (
         fields = (
-            'pk', 'name', 'device', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
+            'pk', 'id', 'name', 'device', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
             'discovered', 'tags',
             'discovered', 'tags',
         )
         )
         default_columns = ('pk', 'name', 'device', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag')
         default_columns = ('pk', 'name', 'device', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag')
@@ -740,7 +741,7 @@ class DeviceInventoryItemTable(InventoryItemTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = InventoryItem
         model = InventoryItem
         fields = (
         fields = (
-            'pk', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'discovered',
+            'pk', 'id', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'discovered',
             'tags', 'actions',
             'tags', 'actions',
         )
         )
         default_columns = (
         default_columns = (
@@ -772,5 +773,5 @@ class VirtualChassisTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = VirtualChassis
         model = VirtualChassis
-        fields = ('pk', 'name', 'domain', 'master', 'member_count', 'tags')
+        fields = ('pk', 'id', 'name', 'domain', 'master', 'member_count', 'tags')
         default_columns = ('pk', 'name', 'domain', 'master', 'member_count')
         default_columns = ('pk', 'name', 'domain', 'master', 'member_count')

+ 19 - 10
netbox/dcim/tables/devicetypes.py

@@ -49,9 +49,12 @@ class ManufacturerTable(BaseTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Manufacturer
         model = Manufacturer
         fields = (
         fields = (
-            'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'tags',
+            'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
             'actions',
             'actions',
         )
         )
+        default_columns = (
+            'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'actions',
+        )
 
 
 
 
 #
 #
@@ -80,7 +83,7 @@ class DeviceTypeTable(BaseTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = DeviceType
         model = DeviceType
         fields = (
         fields = (
-            'pk', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
+            'pk', 'id', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
             'airflow', 'comments', 'instance_count', 'tags',
             'airflow', 'comments', 'instance_count', 'tags',
         )
         )
         default_columns = (
         default_columns = (
@@ -94,10 +97,16 @@ class DeviceTypeTable(BaseTable):
 
 
 class ComponentTemplateTable(BaseTable):
 class ComponentTemplateTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
+    id = tables.Column(
+        verbose_name='ID'
+    )
     name = tables.Column(
     name = tables.Column(
         order_by=('_name',)
         order_by=('_name',)
     )
     )
 
 
+    class Meta(BaseTable.Meta):
+        exclude = ('id', )
+
 
 
 class ConsolePortTemplateTable(ComponentTemplateTable):
 class ConsolePortTemplateTable(ComponentTemplateTable):
     actions = ButtonsColumn(
     actions = ButtonsColumn(
@@ -106,7 +115,7 @@ class ConsolePortTemplateTable(ComponentTemplateTable):
         return_url_extra='%23tab_consoleports'
         return_url_extra='%23tab_consoleports'
     )
     )
 
 
-    class Meta(BaseTable.Meta):
+    class Meta(ComponentTemplateTable.Meta):
         model = ConsolePortTemplate
         model = ConsolePortTemplate
         fields = ('pk', 'name', 'label', 'type', 'description', 'actions')
         fields = ('pk', 'name', 'label', 'type', 'description', 'actions')
         empty_text = "None"
         empty_text = "None"
@@ -119,7 +128,7 @@ class ConsoleServerPortTemplateTable(ComponentTemplateTable):
         return_url_extra='%23tab_consoleserverports'
         return_url_extra='%23tab_consoleserverports'
     )
     )
 
 
-    class Meta(BaseTable.Meta):
+    class Meta(ComponentTemplateTable.Meta):
         model = ConsoleServerPortTemplate
         model = ConsoleServerPortTemplate
         fields = ('pk', 'name', 'label', 'type', 'description', 'actions')
         fields = ('pk', 'name', 'label', 'type', 'description', 'actions')
         empty_text = "None"
         empty_text = "None"
@@ -132,7 +141,7 @@ class PowerPortTemplateTable(ComponentTemplateTable):
         return_url_extra='%23tab_powerports'
         return_url_extra='%23tab_powerports'
     )
     )
 
 
-    class Meta(BaseTable.Meta):
+    class Meta(ComponentTemplateTable.Meta):
         model = PowerPortTemplate
         model = PowerPortTemplate
         fields = ('pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'actions')
         fields = ('pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'actions')
         empty_text = "None"
         empty_text = "None"
@@ -145,7 +154,7 @@ class PowerOutletTemplateTable(ComponentTemplateTable):
         return_url_extra='%23tab_poweroutlets'
         return_url_extra='%23tab_poweroutlets'
     )
     )
 
 
-    class Meta(BaseTable.Meta):
+    class Meta(ComponentTemplateTable.Meta):
         model = PowerOutletTemplate
         model = PowerOutletTemplate
         fields = ('pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'actions')
         fields = ('pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'actions')
         empty_text = "None"
         empty_text = "None"
@@ -161,7 +170,7 @@ class InterfaceTemplateTable(ComponentTemplateTable):
         return_url_extra='%23tab_interfaces'
         return_url_extra='%23tab_interfaces'
     )
     )
 
 
-    class Meta(BaseTable.Meta):
+    class Meta(ComponentTemplateTable.Meta):
         model = InterfaceTemplate
         model = InterfaceTemplate
         fields = ('pk', 'name', 'label', 'mgmt_only', 'type', 'description', 'actions')
         fields = ('pk', 'name', 'label', 'mgmt_only', 'type', 'description', 'actions')
         empty_text = "None"
         empty_text = "None"
@@ -178,7 +187,7 @@ class FrontPortTemplateTable(ComponentTemplateTable):
         return_url_extra='%23tab_frontports'
         return_url_extra='%23tab_frontports'
     )
     )
 
 
-    class Meta(BaseTable.Meta):
+    class Meta(ComponentTemplateTable.Meta):
         model = FrontPortTemplate
         model = FrontPortTemplate
         fields = ('pk', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', 'actions')
         fields = ('pk', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', 'actions')
         empty_text = "None"
         empty_text = "None"
@@ -192,7 +201,7 @@ class RearPortTemplateTable(ComponentTemplateTable):
         return_url_extra='%23tab_rearports'
         return_url_extra='%23tab_rearports'
     )
     )
 
 
-    class Meta(BaseTable.Meta):
+    class Meta(ComponentTemplateTable.Meta):
         model = RearPortTemplate
         model = RearPortTemplate
         fields = ('pk', 'name', 'label', 'type', 'color', 'positions', 'description', 'actions')
         fields = ('pk', 'name', 'label', 'type', 'color', 'positions', 'description', 'actions')
         empty_text = "None"
         empty_text = "None"
@@ -205,7 +214,7 @@ class DeviceBayTemplateTable(ComponentTemplateTable):
         return_url_extra='%23tab_devicebays'
         return_url_extra='%23tab_devicebays'
     )
     )
 
 
-    class Meta(BaseTable.Meta):
+    class Meta(ComponentTemplateTable.Meta):
         model = DeviceBayTemplate
         model = DeviceBayTemplate
         fields = ('pk', 'name', 'label', 'description', 'actions')
         fields = ('pk', 'name', 'label', 'description', 'actions')
         empty_text = "None"
         empty_text = "None"

+ 2 - 2
netbox/dcim/tables/power.py

@@ -33,7 +33,7 @@ class PowerPanelTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = PowerPanel
         model = PowerPanel
-        fields = ('pk', 'name', 'site', 'location', 'powerfeed_count', 'tags')
+        fields = ('pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'tags')
         default_columns = ('pk', 'name', 'site', 'location', 'powerfeed_count')
         default_columns = ('pk', 'name', 'site', 'location', 'powerfeed_count')
 
 
 
 
@@ -70,7 +70,7 @@ class PowerFeedTable(CableTerminationTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = PowerFeed
         model = PowerFeed
         fields = (
         fields = (
-            'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
+            'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
             'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'available_power',
             'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'available_power',
             'comments', 'tags',
             'comments', 'tags',
         )
         )

+ 3 - 3
netbox/dcim/tables/racks.py

@@ -31,7 +31,7 @@ class RackRoleTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = RackRole
         model = RackRole
-        fields = ('pk', 'name', 'rack_count', 'color', 'description', 'slug', 'tags', 'actions')
+        fields = ('pk', 'id', 'name', 'rack_count', 'color', 'description', 'slug', 'tags', 'actions')
         default_columns = ('pk', 'name', 'rack_count', 'color', 'description', 'actions')
         default_columns = ('pk', 'name', 'rack_count', 'color', 'description', 'actions')
 
 
 
 
@@ -79,7 +79,7 @@ class RackTable(BaseTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Rack
         model = Rack
         fields = (
         fields = (
-            'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type',
+            'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type',
             'width', 'u_height', 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'tags',
             'width', 'u_height', 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'tags',
         )
         )
         default_columns = (
         default_columns = (
@@ -118,7 +118,7 @@ class RackReservationTable(BaseTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = RackReservation
         model = RackReservation
         fields = (
         fields = (
-            'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'tags',
+            'pk', 'id', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'tags',
             'actions',
             'actions',
         )
         )
         default_columns = (
         default_columns = (

+ 5 - 4
netbox/dcim/tables/sites.py

@@ -36,7 +36,7 @@ class RegionTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Region
         model = Region
-        fields = ('pk', 'name', 'slug', 'site_count', 'description', 'tags', 'actions')
+        fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'actions')
         default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
         default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
 
 
 
 
@@ -61,7 +61,7 @@ class SiteGroupTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = SiteGroup
         model = SiteGroup
-        fields = ('pk', 'name', 'slug', 'site_count', 'description', 'tags', 'actions')
+        fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'actions')
         default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
         default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
 
 
 
 
@@ -90,7 +90,7 @@ class SiteTable(BaseTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Site
         model = Site
         fields = (
         fields = (
-            'pk', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'time_zone', 'description',
+            'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'time_zone', 'description',
             'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
             'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
             'contact_email', 'comments', 'tags',
             'contact_email', 'comments', 'tags',
         )
         )
@@ -131,6 +131,7 @@ class LocationTable(BaseTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Location
         model = Location
         fields = (
         fields = (
-            'pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'tags', 'actions',
+            'pk', 'id', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'tags',
+            'actions',
         )
         )
         default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'actions')
         default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'actions')

+ 158 - 0
netbox/extras/management/commands/runscript.py

@@ -0,0 +1,158 @@
+import json
+import logging
+import sys
+import traceback
+import uuid
+
+from django.contrib.auth.models import User
+from django.contrib.contenttypes.models import ContentType
+from django.core.management.base import BaseCommand, CommandError
+from django.db import transaction
+
+from extras.api.serializers import ScriptOutputSerializer
+from extras.choices import JobResultStatusChoices
+from extras.context_managers import change_logging
+from extras.models import JobResult
+from extras.scripts import get_script
+from utilities.exceptions import AbortTransaction
+from utilities.utils import NetBoxFakeRequest
+
+
+class Command(BaseCommand):
+    help = "Run a script in Netbox"
+
+    def add_arguments(self, parser):
+        parser.add_argument(
+            '--loglevel',
+            help="Logging Level (default: info)",
+            dest='loglevel',
+            default='info',
+            choices=['debug', 'info', 'warning', 'error', 'critical'])
+        parser.add_argument('--commit', help="Commit this script to database", action='store_true')
+        parser.add_argument('--user', help="User script is running as")
+        parser.add_argument('--data', help="Data as a string encapsulated JSON blob")
+        parser.add_argument('script', help="Script to run")
+
+    def handle(self, *args, **options):
+        def _run_script():
+            """
+            Core script execution task. We capture this within a subfunction to allow for conditionally wrapping it with
+            the change_logging context manager (which is bypassed if commit == False).
+            """
+            try:
+                with transaction.atomic():
+                    script.output = script.run(data=data, commit=commit)
+                    job_result.set_status(JobResultStatusChoices.STATUS_COMPLETED)
+
+                    if not commit:
+                        raise AbortTransaction()
+
+            except AbortTransaction:
+                script.log_info("Database changes have been reverted automatically.")
+
+            except Exception as e:
+                stacktrace = traceback.format_exc()
+                script.log_failure(
+                    f"An exception occurred: `{type(e).__name__}: {e}`\n```\n{stacktrace}\n```"
+                )
+                script.log_info("Database changes have been reverted due to error.")
+                logger.error(f"Exception raised during script execution: {e}")
+                job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
+
+            finally:
+                job_result.data = ScriptOutputSerializer(script).data
+                job_result.save()
+
+            logger.info(f"Script completed in {job_result.duration}")
+
+        # Params
+        script = options['script']
+        loglevel = options['loglevel']
+        commit = options['commit']
+        try:
+            data = json.loads(options['data'])
+        except TypeError:
+            data = {}
+
+        module, name = script.split('.', 1)
+
+        # Take user from command line if provided and exists, other
+        if options['user']:
+            try:
+                user = User.objects.get(username=options['user'])
+            except User.DoesNotExist:
+                user = User.objects.filter(is_superuser=True).order_by('pk')[0]
+        else:
+            user = User.objects.filter(is_superuser=True).order_by('pk')[0]
+
+        # Setup logging to Stdout
+        formatter = logging.Formatter(f'[%(asctime)s][%(levelname)s] - %(message)s')
+        stdouthandler = logging.StreamHandler(sys.stdout)
+        stdouthandler.setLevel(logging.DEBUG)
+        stdouthandler.setFormatter(formatter)
+
+        logger = logging.getLogger(f"netbox.scripts.{module}.{name}")
+        logger.addHandler(stdouthandler)
+
+        try:
+            logger.setLevel({
+                'critical': logging.CRITICAL,
+                'debug': logging.DEBUG,
+                'error': logging.ERROR,
+                'fatal': logging.FATAL,
+                'info': logging.INFO,
+                'warning': logging.WARNING,
+            }[loglevel])
+        except KeyError:
+            raise CommandError(f"Invalid log level: {loglevel}")
+
+        # Get the script
+        script = get_script(module, name)()
+        # Parse the parameters
+        form = script.as_form(data, None)
+
+        script_content_type = ContentType.objects.get(app_label='extras', model='script')
+
+        # Delete any previous terminal state results
+        JobResult.objects.filter(
+            obj_type=script_content_type,
+            name=script.full_name,
+            status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
+        ).delete()
+
+        # Create the job result
+        job_result = JobResult.objects.create(
+            name=script.full_name,
+            obj_type=script_content_type,
+            user=User.objects.filter(is_superuser=True).order_by('pk')[0],
+            job_id=uuid.uuid4()
+        )
+
+        request = NetBoxFakeRequest({
+            'META': {},
+            'POST': data,
+            'GET': {},
+            'FILES': {},
+            'user': user,
+            'path': '',
+            'id': job_result.job_id
+        })
+
+        if form.is_valid():
+            job_result.status = JobResultStatusChoices.STATUS_RUNNING
+            job_result.save()
+
+            logger.info(f"Running script (commit={commit})")
+            script.request = request
+
+            # Execute the script. If commit is True, wrap it with the change_logging context manager to ensure we process
+            # change logging, webhooks, etc.
+            with change_logging(request):
+                _run_script()
+        else:
+            logger.error('Data is not valid:')
+            for field, errors in form.errors.get_json_data().items():
+                for error in errors:
+                    logger.error(f'\t{field}: {error.get("message")}')
+            job_result.status = JobResultStatusChoices.STATUS_ERRORED
+            job_result.save()

+ 2 - 1
netbox/extras/models/customfields.py

@@ -8,6 +8,7 @@ from django.contrib.postgres.fields import ArrayField
 from django.core.validators import RegexValidator, ValidationError
 from django.core.validators import RegexValidator, ValidationError
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
+from django.utils.html import escape
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
 
 
 from extras.choices import *
 from extras.choices import *
@@ -306,7 +307,7 @@ class CustomField(ChangeLoggedModel):
         field.model = self
         field.model = self
         field.label = str(self)
         field.label = str(self)
         if self.description:
         if self.description:
-            field.help_text = self.description
+            field.help_text = escape(self.description)
 
 
         return field
         return field
 
 

+ 6 - 2
netbox/extras/scripts.py

@@ -4,7 +4,6 @@ import logging
 import os
 import os
 import pkgutil
 import pkgutil
 import traceback
 import traceback
-import warnings
 from collections import OrderedDict
 from collections import OrderedDict
 
 
 import yaml
 import yaml
@@ -345,9 +344,14 @@ class BaseScript:
         """
         """
         Return data from a YAML file
         Return data from a YAML file
         """
         """
+        try:
+            from yaml import CLoader as Loader
+        except ImportError:
+            from yaml import Loader
+
         file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
         file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
         with open(file_path, 'r') as datafile:
         with open(file_path, 'r') as datafile:
-            data = yaml.load(datafile)
+            data = yaml.load(datafile, Loader=Loader)
 
 
         return data
         return data
 
 

+ 24 - 12
netbox/extras/tables.py

@@ -57,8 +57,8 @@ class CustomFieldTable(BaseTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = CustomField
         model = CustomField
         fields = (
         fields = (
-            'pk', 'name', 'content_types', 'label', 'type', 'required', 'weight', 'default', 'description',
-            'filter_logic', 'choices',
+            'pk', 'id', 'name', 'content_types', 'label', 'type', 'required', 'weight', 'default',
+            'description', 'filter_logic', 'choices',
         )
         )
         default_columns = ('pk', 'name', 'content_types', 'label', 'type', 'required', 'description')
         default_columns = ('pk', 'name', 'content_types', 'label', 'type', 'required', 'description')
 
 
@@ -78,7 +78,8 @@ class CustomLinkTable(BaseTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = CustomLink
         model = CustomLink
         fields = (
         fields = (
-            'pk', 'name', 'content_type', 'link_text', 'link_url', 'weight', 'group_name', 'button_class', 'new_window',
+            'pk', 'id', 'name', 'content_type', 'link_text', 'link_url', 'weight', 'group_name',
+            'button_class', 'new_window',
         )
         )
         default_columns = ('pk', 'name', 'content_type', 'group_name', 'button_class', 'new_window')
         default_columns = ('pk', 'name', 'content_type', 'group_name', 'button_class', 'new_window')
 
 
@@ -98,7 +99,7 @@ class ExportTemplateTable(BaseTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = ExportTemplate
         model = ExportTemplate
         fields = (
         fields = (
-            'pk', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment',
+            'pk', 'id', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment',
         )
         )
         default_columns = (
         default_columns = (
             'pk', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment',
             'pk', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment',
@@ -132,7 +133,7 @@ class WebhookTable(BaseTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Webhook
         model = Webhook
         fields = (
         fields = (
-            'pk', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method',
+            'pk', 'id', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method',
             'payload_url', 'secret', 'ssl_validation', 'ca_file_path',
             'payload_url', 'secret', 'ssl_validation', 'ca_file_path',
         )
         )
         default_columns = (
         default_columns = (
@@ -155,10 +156,16 @@ class TagTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Tag
         model = Tag
-        fields = ('pk', 'name', 'items', 'slug', 'color', 'description', 'actions')
+        fields = ('pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'actions')
+        default_columns = ('pk', 'name', 'items', 'slug', 'color', 'description', 'actions')
 
 
 
 
 class TaggedItemTable(BaseTable):
 class TaggedItemTable(BaseTable):
+    id = tables.Column(
+        verbose_name='ID',
+        linkify=lambda record: record.content_object.get_absolute_url(),
+        accessor='content_object__id'
+    )
     content_type = ContentTypeColumn(
     content_type = ContentTypeColumn(
         verbose_name='Type'
         verbose_name='Type'
     )
     )
@@ -170,7 +177,7 @@ class TaggedItemTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = TaggedItem
         model = TaggedItem
-        fields = ('content_type', 'content_object')
+        fields = ('id', 'content_type', 'content_object')
 
 
 
 
 class ConfigContextTable(BaseTable):
 class ConfigContextTable(BaseTable):
@@ -185,8 +192,8 @@ class ConfigContextTable(BaseTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = ConfigContext
         model = ConfigContext
         fields = (
         fields = (
-            'pk', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'roles', 'platforms',
-            'cluster_groups', 'clusters', 'tenant_groups', 'tenants',
+            'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'roles',
+            'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants',
         )
         )
         default_columns = ('pk', 'name', 'weight', 'is_active', 'description')
         default_columns = ('pk', 'name', 'weight', 'is_active', 'description')
 
 
@@ -211,7 +218,7 @@ class ObjectChangeTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = ObjectChange
         model = ObjectChange
-        fields = ('time', 'user_name', 'action', 'changed_object_type', 'object_repr', 'request_id')
+        fields = ('id', 'time', 'user_name', 'action', 'changed_object_type', 'object_repr', 'request_id')
 
 
 
 
 class ObjectJournalTable(BaseTable):
 class ObjectJournalTable(BaseTable):
@@ -232,7 +239,7 @@ class ObjectJournalTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = JournalEntry
         model = JournalEntry
-        fields = ('created', 'created_by', 'kind', 'comments', 'actions')
+        fields = ('id', 'created', 'created_by', 'kind', 'comments', 'actions')
 
 
 
 
 class JournalEntryTable(ObjectJournalTable):
 class JournalEntryTable(ObjectJournalTable):
@@ -250,5 +257,10 @@ class JournalEntryTable(ObjectJournalTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = JournalEntry
         model = JournalEntry
         fields = (
         fields = (
-            'pk', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind', 'comments', 'actions'
+            'pk', 'id', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind',
+            'comments', 'actions'
+        )
+        default_columns = (
+            'pk', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind',
+            'comments', 'actions'
         )
         )

+ 46 - 0
netbox/extras/tests/test_scripts.py

@@ -1,3 +1,5 @@
+import tempfile
+
 from django.core.files.uploadedfile import SimpleUploadedFile
 from django.core.files.uploadedfile import SimpleUploadedFile
 from django.test import TestCase
 from django.test import TestCase
 from netaddr import IPAddress, IPNetwork
 from netaddr import IPAddress, IPNetwork
@@ -11,6 +13,50 @@ CHOICES = (
     ('0000ff', 'Blue')
     ('0000ff', 'Blue')
 )
 )
 
 
+YAML_DATA = """
+Foo: 123
+Bar: 456
+Baz:
+ - A
+ - B
+ - C
+"""
+
+JSON_DATA = """
+{
+  "Foo": 123,
+  "Bar": 456,
+  "Baz": ["A", "B", "C"]
+}
+"""
+
+
+class ScriptTest(TestCase):
+
+    def test_load_yaml(self):
+        datafile = tempfile.NamedTemporaryFile()
+        datafile.write(bytes(YAML_DATA, 'UTF-8'))
+        datafile.seek(0)
+
+        data = Script().load_yaml(datafile.name)
+        self.assertEqual(data, {
+            'Foo': 123,
+            'Bar': 456,
+            'Baz': ['A', 'B', 'C'],
+        })
+
+    def test_load_json(self):
+        datafile = tempfile.NamedTemporaryFile()
+        datafile.write(bytes(JSON_DATA, 'UTF-8'))
+        datafile.seek(0)
+
+        data = Script().load_json(datafile.name)
+        self.assertEqual(data, {
+            'Foo': 123,
+            'Bar': 456,
+            'Baz': ['A', 'B', 'C'],
+        })
+
 
 
 class ScriptVariablesTest(TestCase):
 class ScriptVariablesTest(TestCase):
 
 

+ 8 - 1
netbox/ipam/forms/bulk_import.py

@@ -258,11 +258,18 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
 
 
         device = self.cleaned_data.get('device')
         device = self.cleaned_data.get('device')
         virtual_machine = self.cleaned_data.get('virtual_machine')
         virtual_machine = self.cleaned_data.get('virtual_machine')
+        interface = self.cleaned_data.get('interface')
         is_primary = self.cleaned_data.get('is_primary')
         is_primary = self.cleaned_data.get('is_primary')
 
 
         # Validate is_primary
         # Validate is_primary
         if is_primary and not device and not virtual_machine:
         if is_primary and not device and not virtual_machine:
-            raise forms.ValidationError("No device or virtual machine specified; cannot set as primary IP")
+            raise forms.ValidationError({
+                "is_primary": "No device or virtual machine specified; cannot set as primary IP"
+            })
+        if is_primary and not interface:
+            raise forms.ValidationError({
+                "is_primary": "No interface specified; cannot set as primary IP"
+            })
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
 
 

+ 12 - 7
netbox/ipam/tables/ip.py

@@ -92,7 +92,7 @@ class RIRTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = RIR
         model = RIR
-        fields = ('pk', 'name', 'slug', 'is_private', 'aggregate_count', 'description', 'tags', 'actions')
+        fields = ('pk', 'id', 'name', 'slug', 'is_private', 'aggregate_count', 'description', 'tags', 'actions')
         default_columns = ('pk', 'name', 'is_private', 'aggregate_count', 'description', 'actions')
         default_columns = ('pk', 'name', 'is_private', 'aggregate_count', 'description', 'actions')
 
 
 
 
@@ -124,7 +124,7 @@ class AggregateTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Aggregate
         model = Aggregate
-        fields = ('pk', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description', 'tags')
+        fields = ('pk', 'id', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description', 'tags')
         default_columns = ('pk', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description')
         default_columns = ('pk', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description')
 
 
 
 
@@ -154,7 +154,7 @@ class RoleTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Role
         model = Role
-        fields = ('pk', 'name', 'slug', 'prefix_count', 'vlan_count', 'description', 'weight', 'tags', 'actions')
+        fields = ('pk', 'id', 'name', 'slug', 'prefix_count', 'vlan_count', 'description', 'weight', 'tags', 'actions')
         default_columns = ('pk', 'name', 'prefix_count', 'vlan_count', 'description', 'actions')
         default_columns = ('pk', 'name', 'prefix_count', 'vlan_count', 'description', 'actions')
 
 
 
 
@@ -236,7 +236,7 @@ class PrefixTable(BaseTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Prefix
         model = Prefix
         fields = (
         fields = (
-            'pk', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role',
+            'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role',
             'is_pool', 'mark_utilized', 'description', 'tags',
             'is_pool', 'mark_utilized', 'description', 'tags',
         )
         )
         default_columns = (
         default_columns = (
@@ -270,12 +270,15 @@ class IPRangeTable(BaseTable):
         accessor='utilization',
         accessor='utilization',
         orderable=False
         orderable=False
     )
     )
+    tags = TagColumn(
+        url_name='ipam:iprange_list'
+    )
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = IPRange
         model = IPRange
         fields = (
         fields = (
-            'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description',
-            'utilization',
+            'pk', 'id', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description',
+            'utilization', 'tags',
         )
         )
         default_columns = (
         default_columns = (
             'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description',
             'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description',
@@ -332,7 +335,7 @@ class IPAddressTable(BaseTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = IPAddress
         model = IPAddress
         fields = (
         fields = (
-            'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'assigned', 'dns_name', 'description',
+            'pk', 'id', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'assigned', 'dns_name', 'description',
             'tags',
             'tags',
         )
         )
         default_columns = (
         default_columns = (
@@ -356,6 +359,7 @@ class IPAddressAssignTable(BaseTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = IPAddress
         model = IPAddress
         fields = ('address', 'dns_name', 'vrf', 'status', 'role', 'tenant', 'assigned_object', 'description')
         fields = ('address', 'dns_name', 'vrf', 'status', 'role', 'tenant', 'assigned_object', 'description')
+        exclude = ('id', )
         orderable = False
         orderable = False
 
 
 
 
@@ -380,3 +384,4 @@ class AssignedIPAddressesTable(BaseTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = IPAddress
         model = IPAddress
         fields = ('address', 'vrf', 'status', 'role', 'tenant', 'description')
         fields = ('address', 'vrf', 'status', 'role', 'tenant', 'description')
+        exclude = ('id', )

+ 1 - 1
netbox/ipam/tables/services.py

@@ -31,5 +31,5 @@ class ServiceTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Service
         model = Service
-        fields = ('pk', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'tags')
+        fields = ('pk', 'id', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'tags')
         default_columns = ('pk', 'name', 'parent', 'protocol', 'ports', 'description')
         default_columns = ('pk', 'name', 'parent', 'protocol', 'ports', 'description')

+ 5 - 2
netbox/ipam/tables/vlans.py

@@ -84,7 +84,7 @@ class VLANGroupTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = VLANGroup
         model = VLANGroup
-        fields = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'slug', 'description', 'tags', 'actions')
+        fields = ('pk', 'id', 'name', 'scope_type', 'scope', 'vlan_count', 'slug', 'description', 'tags', 'actions')
         default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description', 'actions')
         default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description', 'actions')
 
 
 
 
@@ -122,7 +122,7 @@ class VLANTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = VLAN
         model = VLAN
-        fields = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description', 'tags')
+        fields = ('pk', 'id', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description', 'tags')
         default_columns = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description')
         default_columns = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description')
         row_attrs = {
         row_attrs = {
             'class': lambda record: 'success' if not isinstance(record, VLAN) else '',
             'class': lambda record: 'success' if not isinstance(record, VLAN) else '',
@@ -152,6 +152,7 @@ class VLANDevicesTable(VLANMembersTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Interface
         model = Interface
         fields = ('device', 'name', 'tagged', 'actions')
         fields = ('device', 'name', 'tagged', 'actions')
+        exclude = ('id', )
 
 
 
 
 class VLANVirtualMachinesTable(VLANMembersTable):
 class VLANVirtualMachinesTable(VLANMembersTable):
@@ -163,6 +164,7 @@ class VLANVirtualMachinesTable(VLANMembersTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = VMInterface
         model = VMInterface
         fields = ('virtual_machine', 'name', 'tagged', 'actions')
         fields = ('virtual_machine', 'name', 'tagged', 'actions')
+        exclude = ('id', )
 
 
 
 
 class InterfaceVLANTable(BaseTable):
 class InterfaceVLANTable(BaseTable):
@@ -190,6 +192,7 @@ class InterfaceVLANTable(BaseTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = VLAN
         model = VLAN
         fields = ('vid', 'tagged', 'site', 'group', 'name', 'tenant', 'status', 'role', 'description')
         fields = ('vid', 'tagged', 'site', 'group', 'name', 'tenant', 'status', 'role', 'description')
+        exclude = ('id', )
 
 
     def __init__(self, interface, *args, **kwargs):
     def __init__(self, interface, *args, **kwargs):
         self.interface = interface
         self.interface = interface

+ 2 - 2
netbox/ipam/tables/vrfs.py

@@ -47,7 +47,7 @@ class VRFTable(BaseTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = VRF
         model = VRF
         fields = (
         fields = (
-            'pk', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'tags',
+            'pk', 'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'tags',
         )
         )
         default_columns = ('pk', 'name', 'rd', 'tenant', 'description')
         default_columns = ('pk', 'name', 'rd', 'tenant', 'description')
 
 
@@ -68,5 +68,5 @@ class RouteTargetTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = RouteTarget
         model = RouteTarget
-        fields = ('pk', 'name', 'tenant', 'description', 'tags')
+        fields = ('pk', 'id', 'name', 'tenant', 'description', 'tags')
         default_columns = ('pk', 'name', 'tenant', 'description')
         default_columns = ('pk', 'name', 'tenant', 'description')

+ 7 - 1
netbox/netbox/constants.py

@@ -69,7 +69,13 @@ SEARCH_TYPES = OrderedDict((
     }),
     }),
     ('location', {
     ('location', {
         'queryset': Location.objects.add_related_count(
         'queryset': Location.objects.add_related_count(
-            Location.objects.all(),
+            Location.objects.add_related_count(
+                Location.objects.all(),
+                Device,
+                'location',
+                'device_count',
+                cumulative=True
+            ),
             Rack,
             Rack,
             'location',
             'location',
             'rack_count',
             'rack_count',

+ 0 - 5
netbox/netbox/models.py

@@ -40,11 +40,6 @@ class ChangeLoggingMixin(models.Model):
         blank=True,
         blank=True,
         null=True
         null=True
     )
     )
-    object_changes = GenericRelation(
-        to='extras.ObjectChange',
-        content_type_field='changed_object_type',
-        object_id_field='changed_object_id'
-    )
 
 
     class Meta:
     class Meta:
         abstract = True
         abstract = True

+ 14 - 13
netbox/netbox/views/generic.py

@@ -777,8 +777,21 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
         else:
         else:
             pk_list = request.POST.getlist('pk')
             pk_list = request.POST.getlist('pk')
 
 
+        # Include the PK list as initial data for the form
+        initial_data = {'pk': pk_list}
+
+        # Check for other contextual data needed for the form. We avoid passing all of request.GET because the
+        # filter values will conflict with the bulk edit form fields.
+        # TODO: Find a better way to accomplish this
+        if 'device' in request.GET:
+            initial_data['device'] = request.GET.get('device')
+        elif 'device_type' in request.GET:
+            initial_data['device_type'] = request.GET.get('device_type')
+        elif 'virtual_machine' in request.GET:
+            initial_data['virtual_machine'] = request.GET.get('virtual_machine')
+
         if '_apply' in request.POST:
         if '_apply' in request.POST:
-            form = self.form(model, request.POST)
+            form = self.form(model, request.POST, initial=initial_data)
             restrict_form_fields(form, request.user)
             restrict_form_fields(form, request.user)
 
 
             if form.is_valid():
             if form.is_valid():
@@ -867,18 +880,6 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
                 logger.debug("Form validation failed")
                 logger.debug("Form validation failed")
 
 
         else:
         else:
-            # Include the PK list as initial data for the form
-            initial_data = {'pk': pk_list}
-
-            # Check for other contextual data needed for the form. We avoid passing all of request.GET because the
-            # filter values will conflict with the bulk edit form fields.
-            # TODO: Find a better way to accomplish this
-            if 'device' in request.GET:
-                initial_data['device'] = request.GET.get('device')
-            elif 'device_type' in request.GET:
-                initial_data['device_type'] = request.GET.get('device_type')
-            elif 'virtual_machine' in request.GET:
-                initial_data['virtual_machine'] = request.GET.get('virtual_machine')
 
 
             form = self.form(model, initial=initial_data)
             form = self.form(model, initial=initial_data)
             restrict_form_fields(form, request.user)
             restrict_form_fields(form, request.user)

+ 72 - 49
netbox/templates/base/base.html

@@ -27,55 +27,78 @@
     <title>{% block title %}Home{% endblock %} | NetBox</title>
     <title>{% block title %}Home{% endblock %} | NetBox</title>
 
 
     <script type="text/javascript">
     <script type="text/javascript">
-        /**
-         * Set the color mode on the `<html/>` element and in local storage.
-         */
-        function setMode(mode) {
-            document.documentElement.setAttribute("data-netbox-color-mode", mode);
-            localStorage.setItem("netbox-color-mode", mode);
-        }
-        /**
-         * Determine the best initial color mode to use prior to rendering.
-         */
-        (function () {
-            try {
-                // Browser prefers dark color scheme.
-                var preferDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
-                // Browser prefers light color scheme.
-                var preferLight = window.matchMedia("(prefers-color-scheme: light)").matches;
-                // Client NetBox color-mode override.
-                var clientMode = localStorage.getItem("netbox-color-mode");
-                // NetBox server-rendered value.
-                var serverMode = document.documentElement.getAttribute("data-netbox-color-mode");
-
-                if (clientMode === null && (serverMode === "light" || serverMode === "dark")) {
-                    // If the client mode is not set but the server mode is, use the server mode.
-                    return setMode(serverMode);
-                }
-                if (clientMode !== null && clientMode !== serverMode) {
-                    // If the client mode is set and is different than the server mode, use the client mode
-                    // over the server mode, as it should be more recent.
-                    return setMode(clientMode);
-                }
-                if (clientMode === serverMode) {
-                    // If the client and server modes match, use that value.
-                    return setMode(clientMode);
-                }
-                if (preferDark && serverMode === "unset") {
-                    // If the server mode is not set but the browser prefers dark mode, use dark mode.
-                    return setMode("dark");
-                }
-                if (preferLight && serverMode === "unset") {
-                    // If the server mode is not set but the browser prefers light mode, use light mode.
-                    return setMode("light");
-                }
-            } catch (error) {
-                // In the event of an error, log it to the console and set the mode to light mode.
-                console.error(error);
-            }
-            return setMode("light");
-        })();
-
+      /**
+       * Set the color mode on the `<html/>` element and in local storage.
+       *
+       * @param mode {"dark" | "light"} NetBox Color Mode.
+       * @param inferred {boolean} Value is inferred from browser/system preference.
+       */
+      function setMode(mode, inferred) {
+          document.documentElement.setAttribute("data-netbox-color-mode", mode);
+          localStorage.setItem("netbox-color-mode", mode);
+          localStorage.setItem("netbox-color-mode-inferred", inferred);
+      }
+      /**
+       * Determine the best initial color mode to use prior to rendering.
+       */
+      (function () {
+          try {
+              // Browser prefers dark color scheme.
+              var preferDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
+              // Browser prefers light color scheme.
+              var preferLight = window.matchMedia("(prefers-color-scheme: light)").matches;
+              // Client NetBox color-mode override.
+              var clientMode = localStorage.getItem("netbox-color-mode");
+              // NetBox server-rendered value.
+              var serverMode = document.documentElement.getAttribute("data-netbox-color-mode");
+              // Color mode is inferred from browser/system preference and not deterministically set by
+              // the client or server.
+              var inferred = JSON.parse(localStorage.getItem("netbox-color-mode-inferred"));
+      
+              if (inferred === true && (serverMode === "light" || serverMode === "dark")) {
+                  // The color mode was previously inferred from browser/system preference, but
+                  // the server now has a value, so we should use the server's value.
+                  return setMode(serverMode, false);
+              }
+              if (clientMode === null && (serverMode === "light" || serverMode === "dark")) {
+                  // If the client mode is not set but the server mode is, use the server mode.
+                  return setMode(serverMode, false);
+              }
+              if (clientMode !== null && serverMode === "unset") {
+                  // The color mode has been set, deterministically or otherwise, and the server
+                  // has no preference or has not been set. Use the client mode, but allow it to
+                  /// be overridden by the server if/when a server value exists.
+                  return setMode(clientMode, true);
+              }
+              if (
+                  clientMode !== null &&
+                  (serverMode === "light" || serverMode === "dark") &&
+                  clientMode !== serverMode
+              ) {
+                  // If the client mode is set and is different than the server mode (which is also set),
+                  // use the client mode over the server mode, as it should be more recent.
+                  return setMode(clientMode, false);
+              }
+              if (clientMode === serverMode) {
+                  // If the client and server modes match, use that value.
+                  return setMode(clientMode, false);
+              }
+              if (preferDark && serverMode === "unset") {
+                  // If the server mode is not set but the browser prefers dark mode, use dark mode, but
+                  // allow it to be overridden by an explicit preference.
+                  return setMode("dark", true);
+              }
+              if (preferLight && serverMode === "unset") {
+                  // If the server mode is not set but the browser prefers light mode, use light mode,
+                  // but allow it to be overridden by an explicit preference.
+                  return setMode("light", true);
+              }
+          } catch (error) {
+              // In the event of an error, log it to the console and set the mode to light mode.
+              console.error(error);
+          }
+          return setMode("light", true);
+      })();
     </script>
     </script>
 
 
     {# Static resources #}
     {# Static resources #}

+ 7 - 0
netbox/templates/dcim/location.html

@@ -56,6 +56,13 @@
           <tr>
           <tr>
             <th scope="row">Racks</th>
             <th scope="row">Racks</th>
             <td>
             <td>
+              {% if rack_count %}
+                <div class="float-end noprint">
+                  <a href="{% url 'dcim:rack_elevation_list' %}?location_id={{ object.pk }}" class="btn btn-sm btn-primary" title="View elevations">
+                    <i class="mdi mdi-server"></i>
+                  </a>
+                </div>
+              {% endif %}
               <a href="{% url 'dcim:rack_list' %}?location_id={{ object.pk }}">{{ rack_count }}</a>
               <a href="{% url 'dcim:rack_list' %}?location_id={{ object.pk }}">{{ rack_count }}</a>
             </td>
             </td>
           </tr>
           </tr>

+ 1 - 1
netbox/templates/inc/panels/custom_fields.html

@@ -10,7 +10,7 @@
                 <table class="table table-hover attr-table">
                 <table class="table table-hover attr-table">
                     {% for field, value in custom_fields.items %}
                     {% for field, value in custom_fields.items %}
                         <tr>
                         <tr>
-                            <td><span title="{{ field.description }}">{{ field }}</span></td>
+                            <td><span title="{{ field.description|escape }}">{{ field }}</span></td>
                             <td>
                             <td>
                                 {% if field.type == 'longtext' and value %}
                                 {% if field.type == 'longtext' and value %}
                                     {{ value|render_markdown }}
                                     {{ value|render_markdown }}

+ 2 - 2
netbox/tenancy/tables.py

@@ -62,7 +62,7 @@ class TenantGroupTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = TenantGroup
         model = TenantGroup
-        fields = ('pk', 'name', 'tenant_count', 'description', 'slug', 'tags', 'actions')
+        fields = ('pk', 'id', 'name', 'tenant_count', 'description', 'slug', 'tags', 'actions')
         default_columns = ('pk', 'name', 'tenant_count', 'description', 'actions')
         default_columns = ('pk', 'name', 'tenant_count', 'description', 'actions')
 
 
 
 
@@ -81,7 +81,7 @@ class TenantTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Tenant
         model = Tenant
-        fields = ('pk', 'name', 'slug', 'group', 'description', 'comments', 'tags')
+        fields = ('pk', 'id', 'name', 'slug', 'group', 'description', 'comments', 'tags')
         default_columns = ('pk', 'name', 'group', 'description')
         default_columns = ('pk', 'name', 'group', 'description')
 
 
 
 

+ 8 - 3
netbox/utilities/paginator.py

@@ -68,17 +68,22 @@ def get_paginate_count(request):
     """
     """
     config = get_config()
     config = get_config()
 
 
+    def _max_allowed(page_size):
+        if config.MAX_PAGE_SIZE:
+            return min(page_size, config.MAX_PAGE_SIZE)
+        return page_size
+
     if 'per_page' in request.GET:
     if 'per_page' in request.GET:
         try:
         try:
             per_page = int(request.GET.get('per_page'))
             per_page = int(request.GET.get('per_page'))
             if request.user.is_authenticated:
             if request.user.is_authenticated:
                 request.user.config.set('pagination.per_page', per_page, commit=True)
                 request.user.config.set('pagination.per_page', per_page, commit=True)
-            return min(per_page, config.MAX_PAGE_SIZE)
+            return _max_allowed(per_page)
         except ValueError:
         except ValueError:
             pass
             pass
 
 
     if request.user.is_authenticated:
     if request.user.is_authenticated:
         per_page = request.user.config.get('pagination.per_page', config.PAGINATE_COUNT)
         per_page = request.user.config.get('pagination.per_page', config.PAGINATE_COUNT)
-        return min(per_page, config.MAX_PAGE_SIZE)
+        return _max_allowed(per_page)
 
 
-    return min(config.PAGINATE_COUNT, config.MAX_PAGE_SIZE)
+    return _max_allowed(config.PAGINATE_COUNT)

+ 4 - 0
netbox/utilities/tables.py

@@ -23,6 +23,10 @@ class BaseTable(tables.Table):
 
 
     :param user: Personalize table display for the given user (optional). Has no effect if AnonymousUser is passed.
     :param user: Personalize table display for the given user (optional). Has no effect if AnonymousUser is passed.
     """
     """
+    id = tables.Column(
+        linkify=True,
+        verbose_name='ID'
+    )
 
 
     class Meta:
     class Meta:
         attrs = {
         attrs = {

+ 6 - 7
netbox/virtualization/tables.py

@@ -1,5 +1,4 @@
 import django_tables2 as tables
 import django_tables2 as tables
-from django.conf import settings
 from dcim.tables.devices import BaseInterfaceTable
 from dcim.tables.devices import BaseInterfaceTable
 from tenancy.tables import TenantColumn
 from tenancy.tables import TenantColumn
 from utilities.tables import (
 from utilities.tables import (
@@ -45,7 +44,7 @@ class ClusterTypeTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = ClusterType
         model = ClusterType
-        fields = ('pk', 'name', 'slug', 'cluster_count', 'description', 'tags', 'actions')
+        fields = ('pk', 'id', 'name', 'slug', 'cluster_count', 'description', 'tags', 'actions')
         default_columns = ('pk', 'name', 'cluster_count', 'description', 'actions')
         default_columns = ('pk', 'name', 'cluster_count', 'description', 'actions')
 
 
 
 
@@ -68,7 +67,7 @@ class ClusterGroupTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = ClusterGroup
         model = ClusterGroup
-        fields = ('pk', 'name', 'slug', 'cluster_count', 'description', 'tags', 'actions')
+        fields = ('pk', 'id', 'name', 'slug', 'cluster_count', 'description', 'tags', 'actions')
         default_columns = ('pk', 'name', 'cluster_count', 'description', 'actions')
         default_columns = ('pk', 'name', 'cluster_count', 'description', 'actions')
 
 
 
 
@@ -104,7 +103,7 @@ class ClusterTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Cluster
         model = Cluster
-        fields = ('pk', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'device_count', 'vm_count', 'tags')
+        fields = ('pk', 'id', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'device_count', 'vm_count', 'tags')
         default_columns = ('pk', 'name', 'type', 'group', 'tenant', 'site', 'device_count', 'vm_count')
         default_columns = ('pk', 'name', 'type', 'group', 'tenant', 'site', 'device_count', 'vm_count')
 
 
 
 
@@ -144,7 +143,7 @@ class VirtualMachineTable(BaseTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = VirtualMachine
         model = VirtualMachine
         fields = (
         fields = (
-            'pk', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'primary_ip4',
+            'pk', 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'primary_ip4',
             'primary_ip6', 'primary_ip', 'comments', 'tags',
             'primary_ip6', 'primary_ip', 'comments', 'tags',
         )
         )
         default_columns = (
         default_columns = (
@@ -171,7 +170,7 @@ class VMInterfaceTable(BaseInterfaceTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = VMInterface
         model = VMInterface
         fields = (
         fields = (
-            'pk', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags',
+            'pk', 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags',
             'ip_addresses', 'untagged_vlan', 'tagged_vlans',
             'ip_addresses', 'untagged_vlan', 'tagged_vlans',
         )
         )
         default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description')
         default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description')
@@ -193,7 +192,7 @@ class VirtualMachineVMInterfaceTable(VMInterfaceTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = VMInterface
         model = VMInterface
         fields = (
         fields = (
-            'pk', 'name', 'enabled', 'parent', 'bridge', 'mac_address', 'mtu', 'mode', 'description', 'tags',
+            'pk', 'id', 'name', 'enabled', 'parent', 'bridge', 'mac_address', 'mtu', 'mode', 'description', 'tags',
             'ip_addresses', 'untagged_vlan', 'tagged_vlans', 'actions',
             'ip_addresses', 'untagged_vlan', 'tagged_vlans', 'actions',
         )
         )
         default_columns = (
         default_columns = (

+ 3 - 3
requirements.txt

@@ -1,4 +1,4 @@
-Django==3.2.8
+Django==3.2.9
 django-cors-headers==3.10.0
 django-cors-headers==3.10.0
 django-debug-toolbar==3.2.2
 django-debug-toolbar==3.2.2
 django-filter==21.1
 django-filter==21.1
@@ -18,7 +18,7 @@ gunicorn==20.1.0
 Jinja2==3.0.2
 Jinja2==3.0.2
 Markdown==3.3.4
 Markdown==3.3.4
 markdown-include==0.6.0
 markdown-include==0.6.0
-mkdocs-material==7.3.4
+mkdocs-material==7.3.6
 netaddr==0.8.0
 netaddr==0.8.0
 Pillow==8.4.0
 Pillow==8.4.0
 psycopg2-binary==2.9.1
 psycopg2-binary==2.9.1
@@ -26,7 +26,7 @@ PyYAML==6.0
 social-auth-app-django==5.0.0
 social-auth-app-django==5.0.0
 social-auth-core==4.1.0
 social-auth-core==4.1.0
 svgwrite==1.4.1
 svgwrite==1.4.1
-tablib==3.0.0
+tablib==3.1.0
 
 
 # Workaround for #7401
 # Workaround for #7401
 jsonschema==3.2.0
 jsonschema==3.2.0