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

Merge branch 'develop' into feature

jeremystretch 4 лет назад
Родитель
Сommit
2c2e37e9f0
36 измененных файлов с 502 добавлено и 176 удалено
  1. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  3. 1 1
      docs/core-functionality/power.md
  4. 17 1
      docs/customization/custom-scripts.md
  5. 1 1
      docs/models/dcim/cable.md
  6. 24 1
      docs/release-notes/version-3.0.md
  7. 1 0
      netbox/circuits/signals.py
  8. 6 6
      netbox/circuits/tables.py
  9. 3 0
      netbox/dcim/tables/__init__.py
  10. 0 4
      netbox/dcim/tables/cables.py
  11. 30 29
      netbox/dcim/tables/devices.py
  12. 19 10
      netbox/dcim/tables/devicetypes.py
  13. 2 2
      netbox/dcim/tables/power.py
  14. 3 3
      netbox/dcim/tables/racks.py
  15. 5 4
      netbox/dcim/tables/sites.py
  16. 158 0
      netbox/extras/management/commands/runscript.py
  17. 2 1
      netbox/extras/models/customfields.py
  18. 6 2
      netbox/extras/scripts.py
  19. 24 12
      netbox/extras/tables.py
  20. 46 0
      netbox/extras/tests/test_scripts.py
  21. 8 1
      netbox/ipam/forms/bulk_import.py
  22. 12 7
      netbox/ipam/tables/ip.py
  23. 1 1
      netbox/ipam/tables/services.py
  24. 5 2
      netbox/ipam/tables/vlans.py
  25. 2 2
      netbox/ipam/tables/vrfs.py
  26. 7 1
      netbox/netbox/constants.py
  27. 0 5
      netbox/netbox/models.py
  28. 14 13
      netbox/netbox/views/generic.py
  29. 72 49
      netbox/templates/base/base.html
  30. 7 0
      netbox/templates/dcim/location.html
  31. 1 1
      netbox/templates/inc/panels/custom_fields.html
  32. 2 2
      netbox/tenancy/tables.py
  33. 8 3
      netbox/utilities/paginator.py
  34. 4 0
      netbox/utilities/tables.py
  35. 6 7
      netbox/virtualization/tables.py
  36. 3 3
      requirements.txt

+ 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
         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.)
-      placeholder: v3.0.8
+      placeholder: v3.0.9
     validations:
       required: true
   - type: dropdown

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

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

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

@@ -5,4 +5,4 @@
 
 # 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
     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
 
@@ -259,6 +259,22 @@ http://netbox/api/extras/scripts/example.MyReport/ \
 --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
 
 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:
 
-![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:
 

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

@@ -1,6 +1,29 @@
 # 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.
     """
     termination_name = f'termination_{instance.term_side.lower()}'
+    instance.circuit.refresh_from_db()
     setattr(instance.circuit, termination_name, instance)
     instance.circuit.save()
 

+ 6 - 6
netbox/circuits/tables.py

@@ -44,8 +44,8 @@ class ProviderTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = Provider
         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')
 
@@ -69,7 +69,7 @@ class ProviderNetworkTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         model = ProviderNetwork
-        fields = ('pk', 'name', 'provider', 'description', 'comments', 'tags')
+        fields = ('pk', 'id', 'name', 'provider', 'description', 'comments', 'tags')
         default_columns = ('pk', 'name', 'provider', 'description')
 
 
@@ -92,7 +92,7 @@ class CircuitTypeTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         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')
 
 
@@ -104,7 +104,7 @@ class CircuitTable(BaseTable):
     pk = ToggleColumn()
     cid = tables.Column(
         linkify=True,
-        verbose_name='ID'
+        verbose_name='Circuit ID'
     )
     provider = tables.Column(
         linkify=True
@@ -127,7 +127,7 @@ class CircuitTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = Circuit
         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',
         )
         default_columns = (

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

@@ -43,6 +43,7 @@ class ConsoleConnectionTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = ConsolePort
         fields = ('device', 'name', 'console_server', 'console_server_port', 'reachable')
+        exclude = ('id', )
 
 
 class PowerConnectionTable(BaseTable):
@@ -73,6 +74,7 @@ class PowerConnectionTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = PowerPort
         fields = ('device', 'name', 'pdu', 'outlet', 'reachable')
+        exclude = ('id', )
 
 
 class InterfaceConnectionTable(BaseTable):
@@ -106,3 +108,4 @@ class InterfaceConnectionTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = Interface
         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):
     pk = ToggleColumn()
-    id = tables.Column(
-        linkify=True,
-        verbose_name='ID'
-    )
     termination_a_parent = tables.TemplateColumn(
         template_code=CABLE_TERMINATION_PARENT,
         accessor=Accessor('termination_a'),

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

@@ -1,6 +1,5 @@
 import django_tables2 as tables
 from django_tables2.utils import Accessor
-from django.conf import settings
 
 from dcim.models import (
     ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem, Platform,
@@ -15,6 +14,7 @@ from .template_code import *
 
 __all__ = (
     'BaseInterfaceTable',
+    'CableTerminationTable',
     'ConsolePortTable',
     'ConsoleServerPortTable',
     'DeviceBayTable',
@@ -88,7 +88,8 @@ class DeviceRoleTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = DeviceRole
         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')
 
@@ -120,7 +121,7 @@ class PlatformTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = Platform
         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',
         )
         default_columns = (
@@ -193,8 +194,8 @@ class DeviceTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = Device
         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',
         )
         default_columns = (
@@ -224,7 +225,7 @@ class DeviceImportTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         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
 
 
@@ -287,7 +288,7 @@ class ConsolePortTable(DeviceComponentTable, PathEndpointTable):
     class Meta(DeviceComponentTable.Meta):
         model = ConsolePort
         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',
         )
         default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
@@ -308,7 +309,7 @@ class DeviceConsolePortTable(ConsolePortTable):
     class Meta(DeviceComponentTable.Meta):
         model = ConsolePort
         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'
         )
         default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions')
@@ -331,8 +332,8 @@ class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable):
     class Meta(DeviceComponentTable.Meta):
         model = ConsoleServerPort
         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')
 
@@ -353,7 +354,7 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable):
     class Meta(DeviceComponentTable.Meta):
         model = ConsoleServerPort
         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',
         )
         default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions')
@@ -376,8 +377,8 @@ class PowerPortTable(DeviceComponentTable, PathEndpointTable):
     class Meta(DeviceComponentTable.Meta):
         model = PowerPort
         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')
 
@@ -398,8 +399,8 @@ class DevicePowerPortTable(PowerPortTable):
     class Meta(DeviceComponentTable.Meta):
         model = PowerPort
         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 = (
             'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection',
@@ -427,8 +428,8 @@ class PowerOutletTable(DeviceComponentTable, PathEndpointTable):
     class Meta(DeviceComponentTable.Meta):
         model = PowerOutlet
         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')
 
@@ -448,7 +449,7 @@ class DevicePowerOutletTable(PowerOutletTable):
     class Meta(DeviceComponentTable.Meta):
         model = PowerOutlet
         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',
         )
         default_columns = (
@@ -497,7 +498,7 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable
     class Meta(DeviceComponentTable.Meta):
         model = Interface
         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',
             'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection',
             'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans',
@@ -532,7 +533,7 @@ class DeviceInterfaceTable(InterfaceTable):
     class Meta(DeviceComponentTable.Meta):
         model = Interface
         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',
             'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection',
             'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', 'actions',
@@ -570,7 +571,7 @@ class FrontPortTable(DeviceComponentTable, CableTerminationTable):
     class Meta(DeviceComponentTable.Meta):
         model = FrontPort
         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',
         )
         default_columns = (
@@ -594,7 +595,7 @@ class DeviceFrontPortTable(FrontPortTable):
     class Meta(DeviceComponentTable.Meta):
         model = FrontPort
         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',
         )
         default_columns = (
@@ -621,7 +622,7 @@ class RearPortTable(DeviceComponentTable, CableTerminationTable):
     class Meta(DeviceComponentTable.Meta):
         model = RearPort
         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',
         )
         default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'description')
@@ -643,7 +644,7 @@ class DeviceRearPortTable(RearPortTable):
     class Meta(DeviceComponentTable.Meta):
         model = RearPort
         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',
         )
         default_columns = (
@@ -673,7 +674,7 @@ class DeviceBayTable(DeviceComponentTable):
 
     class Meta(DeviceComponentTable.Meta):
         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')
 
 
@@ -693,7 +694,7 @@ class DeviceDeviceBayTable(DeviceBayTable):
     class Meta(DeviceComponentTable.Meta):
         model = DeviceBay
         fields = (
-            'pk', 'name', 'label', 'status', 'installed_device', 'description', 'tags', 'actions',
+            'pk', 'id', 'name', 'label', 'status', 'installed_device', 'description', 'tags', 'actions',
         )
         default_columns = (
             'pk', 'name', 'label', 'status', 'installed_device', 'description', 'actions',
@@ -719,7 +720,7 @@ class InventoryItemTable(DeviceComponentTable):
     class Meta(BaseTable.Meta):
         model = InventoryItem
         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',
         )
         default_columns = ('pk', 'name', 'device', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag')
@@ -740,7 +741,7 @@ class DeviceInventoryItemTable(InventoryItemTable):
     class Meta(BaseTable.Meta):
         model = InventoryItem
         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',
         )
         default_columns = (
@@ -772,5 +773,5 @@ class VirtualChassisTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         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')

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

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

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

@@ -33,7 +33,7 @@ class PowerPanelTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         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')
 
 
@@ -70,7 +70,7 @@ class PowerFeedTable(CableTerminationTable):
     class Meta(BaseTable.Meta):
         model = PowerFeed
         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',
             'comments', 'tags',
         )

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

@@ -31,7 +31,7 @@ class RackRoleTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         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')
 
 
@@ -79,7 +79,7 @@ class RackTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = Rack
         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',
         )
         default_columns = (
@@ -118,7 +118,7 @@ class RackReservationTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = RackReservation
         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',
         )
         default_columns = (

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

@@ -36,7 +36,7 @@ class RegionTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         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')
 
 
@@ -61,7 +61,7 @@ class SiteGroupTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         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')
 
 
@@ -90,7 +90,7 @@ class SiteTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = Site
         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',
             'contact_email', 'comments', 'tags',
         )
@@ -131,6 +131,7 @@ class LocationTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = Location
         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')

+ 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.db import models
 from django.urls import reverse
+from django.utils.html import escape
 from django.utils.safestring import mark_safe
 
 from extras.choices import *
@@ -306,7 +307,7 @@ class CustomField(ChangeLoggedModel):
         field.model = self
         field.label = str(self)
         if self.description:
-            field.help_text = self.description
+            field.help_text = escape(self.description)
 
         return field
 

+ 6 - 2
netbox/extras/scripts.py

@@ -4,7 +4,6 @@ import logging
 import os
 import pkgutil
 import traceback
-import warnings
 from collections import OrderedDict
 
 import yaml
@@ -345,9 +344,14 @@ class BaseScript:
         """
         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)
         with open(file_path, 'r') as datafile:
-            data = yaml.load(datafile)
+            data = yaml.load(datafile, Loader=Loader)
 
         return data
 

+ 24 - 12
netbox/extras/tables.py

@@ -57,8 +57,8 @@ class CustomFieldTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = CustomField
         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')
 
@@ -78,7 +78,8 @@ class CustomLinkTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = CustomLink
         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')
 
@@ -98,7 +99,7 @@ class ExportTemplateTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = ExportTemplate
         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 = (
             'pk', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment',
@@ -132,7 +133,7 @@ class WebhookTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = Webhook
         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',
         )
         default_columns = (
@@ -155,10 +156,16 @@ class TagTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         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):
+    id = tables.Column(
+        verbose_name='ID',
+        linkify=lambda record: record.content_object.get_absolute_url(),
+        accessor='content_object__id'
+    )
     content_type = ContentTypeColumn(
         verbose_name='Type'
     )
@@ -170,7 +177,7 @@ class TaggedItemTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         model = TaggedItem
-        fields = ('content_type', 'content_object')
+        fields = ('id', 'content_type', 'content_object')
 
 
 class ConfigContextTable(BaseTable):
@@ -185,8 +192,8 @@ class ConfigContextTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = ConfigContext
         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')
 
@@ -211,7 +218,7 @@ class ObjectChangeTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         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):
@@ -232,7 +239,7 @@ class ObjectJournalTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         model = JournalEntry
-        fields = ('created', 'created_by', 'kind', 'comments', 'actions')
+        fields = ('id', 'created', 'created_by', 'kind', 'comments', 'actions')
 
 
 class JournalEntryTable(ObjectJournalTable):
@@ -250,5 +257,10 @@ class JournalEntryTable(ObjectJournalTable):
     class Meta(BaseTable.Meta):
         model = JournalEntry
         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.test import TestCase
 from netaddr import IPAddress, IPNetwork
@@ -11,6 +13,50 @@ CHOICES = (
     ('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):
 

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

@@ -258,11 +258,18 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
 
         device = self.cleaned_data.get('device')
         virtual_machine = self.cleaned_data.get('virtual_machine')
+        interface = self.cleaned_data.get('interface')
         is_primary = self.cleaned_data.get('is_primary')
 
         # Validate is_primary
         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):
 

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

@@ -92,7 +92,7 @@ class RIRTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         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')
 
 
@@ -124,7 +124,7 @@ class AggregateTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         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')
 
 
@@ -154,7 +154,7 @@ class RoleTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         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')
 
 
@@ -236,7 +236,7 @@ class PrefixTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = Prefix
         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',
         )
         default_columns = (
@@ -270,12 +270,15 @@ class IPRangeTable(BaseTable):
         accessor='utilization',
         orderable=False
     )
+    tags = TagColumn(
+        url_name='ipam:iprange_list'
+    )
 
     class Meta(BaseTable.Meta):
         model = IPRange
         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 = (
             'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description',
@@ -332,7 +335,7 @@ class IPAddressTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = IPAddress
         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',
         )
         default_columns = (
@@ -356,6 +359,7 @@ class IPAddressAssignTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = IPAddress
         fields = ('address', 'dns_name', 'vrf', 'status', 'role', 'tenant', 'assigned_object', 'description')
+        exclude = ('id', )
         orderable = False
 
 
@@ -380,3 +384,4 @@ class AssignedIPAddressesTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = IPAddress
         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):
         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')

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

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

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

@@ -47,7 +47,7 @@ class VRFTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = VRF
         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')
 
@@ -68,5 +68,5 @@ class RouteTargetTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         model = RouteTarget
-        fields = ('pk', 'name', 'tenant', 'description', 'tags')
+        fields = ('pk', 'id', 'name', 'tenant', 'description', 'tags')
         default_columns = ('pk', 'name', 'tenant', 'description')

+ 7 - 1
netbox/netbox/constants.py

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

+ 0 - 5
netbox/netbox/models.py

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

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

@@ -777,8 +777,21 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
         else:
             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:
-            form = self.form(model, request.POST)
+            form = self.form(model, request.POST, initial=initial_data)
             restrict_form_fields(form, request.user)
 
             if form.is_valid():
@@ -867,18 +880,6 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
                 logger.debug("Form validation failed")
 
         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)
             restrict_form_fields(form, request.user)

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

@@ -27,55 +27,78 @@
     <title>{% block title %}Home{% endblock %} | NetBox</title>
 
     <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>
 
     {# Static resources #}

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

@@ -56,6 +56,13 @@
           <tr>
             <th scope="row">Racks</th>
             <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>
             </td>
           </tr>

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

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

+ 2 - 2
netbox/tenancy/tables.py

@@ -62,7 +62,7 @@ class TenantGroupTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         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')
 
 
@@ -81,7 +81,7 @@ class TenantTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         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')
 
 

+ 8 - 3
netbox/utilities/paginator.py

@@ -68,17 +68,22 @@ def get_paginate_count(request):
     """
     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:
         try:
             per_page = int(request.GET.get('per_page'))
             if request.user.is_authenticated:
                 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:
             pass
 
     if request.user.is_authenticated:
         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.
     """
+    id = tables.Column(
+        linkify=True,
+        verbose_name='ID'
+    )
 
     class Meta:
         attrs = {

+ 6 - 7
netbox/virtualization/tables.py

@@ -1,5 +1,4 @@
 import django_tables2 as tables
-from django.conf import settings
 from dcim.tables.devices import BaseInterfaceTable
 from tenancy.tables import TenantColumn
 from utilities.tables import (
@@ -45,7 +44,7 @@ class ClusterTypeTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         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')
 
 
@@ -68,7 +67,7 @@ class ClusterGroupTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         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')
 
 
@@ -104,7 +103,7 @@ class ClusterTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         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')
 
 
@@ -144,7 +143,7 @@ class VirtualMachineTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = VirtualMachine
         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',
         )
         default_columns = (
@@ -171,7 +170,7 @@ class VMInterfaceTable(BaseInterfaceTable):
     class Meta(BaseTable.Meta):
         model = VMInterface
         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',
         )
         default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description')
@@ -193,7 +192,7 @@ class VirtualMachineVMInterfaceTable(VMInterfaceTable):
     class Meta(BaseTable.Meta):
         model = VMInterface
         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',
         )
         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-debug-toolbar==3.2.2
 django-filter==21.1
@@ -18,7 +18,7 @@ gunicorn==20.1.0
 Jinja2==3.0.2
 Markdown==3.3.4
 markdown-include==0.6.0
-mkdocs-material==7.3.4
+mkdocs-material==7.3.6
 netaddr==0.8.0
 Pillow==8.4.0
 psycopg2-binary==2.9.1
@@ -26,7 +26,7 @@ PyYAML==6.0
 social-auth-app-django==5.0.0
 social-auth-core==4.1.0
 svgwrite==1.4.1
-tablib==3.0.0
+tablib==3.1.0
 
 # Workaround for #7401
 jsonschema==3.2.0