Browse Source

Merge branch 'feature' of https://github.com/netbox-community/netbox into feature

# Conflicts:
#	netbox/templates/generic/object_list.html
checktheroads 4 years ago
parent
commit
ea472c844e

+ 3 - 1
docs/development/models.md

@@ -4,13 +4,15 @@
 
 
 A NetBox model represents a discrete object type such as a device or IP address. Each model is defined as a Python class and has its own SQL table. All NetBox data models can be categorized by type.
 A NetBox model represents a discrete object type such as a device or IP address. Each model is defined as a Python class and has its own SQL table. All NetBox data models can be categorized by type.
 
 
+The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/contenttypes/) framework can be used to reference models within the database. A ContentType instance references a model by its `app_label` and `name`: For example, the Site model is referred to as `dcim.site`. The content type combined with an object's primary key form a globally unique identifier for the object (e.g. `dcim.site:123`).
+
 ### Features Matrix
 ### Features Matrix
 
 
 * [Change logging](../additional-features/change-logging.md) - Changes to these objects are automatically recorded in the change log
 * [Change logging](../additional-features/change-logging.md) - Changes to these objects are automatically recorded in the change log
 * [Webhooks](../additional-features/webhooks.md) - NetBox is capable of generating outgoing webhooks for these objects
 * [Webhooks](../additional-features/webhooks.md) - NetBox is capable of generating outgoing webhooks for these objects
 * [Custom fields](../additional-features/custom-fields.md) - These models support the addition of user-defined fields
 * [Custom fields](../additional-features/custom-fields.md) - These models support the addition of user-defined fields
 * [Export templates](../additional-features/export-templates.md) - Users can create custom export templates for these models
 * [Export templates](../additional-features/export-templates.md) - Users can create custom export templates for these models
-* Tagging - The models can be tagged with user-defined tags
+* [Tagging](../models/extras/tag.md) - The models can be tagged with user-defined tags
 * [Journaling](../additional-features/journaling.md) - These models support persistent historical commentary
 * [Journaling](../additional-features/journaling.md) - These models support persistent historical commentary
 * Nesting - These models can be nested recursively to create a hierarchy
 * Nesting - These models can be nested recursively to create a hierarchy
 
 

+ 1 - 1
docs/models/circuits/providernetwork.md

@@ -1,4 +1,4 @@
-# Provider Network
+# Provider Networks
 
 
 This model can be used to represent the boundary of a provider network, the details of which are unknown or unimportant to the NetBox user. For example, it might represent a provider's regional MPLS network to which multiple circuits provide connectivity.
 This model can be used to represent the boundary of a provider network, the details of which are unknown or unimportant to the NetBox user. For example, it might represent a provider's regional MPLS network to which multiple circuits provide connectivity.
 
 

+ 12 - 1
docs/release-notes/version-2.10.md

@@ -1,13 +1,24 @@
 # NetBox v2.10
 # NetBox v2.10
 
 
-## v2.10.10 (FUTURE)
+## v2.10.10 (2021-04-15)
+
+### Enhancements
+
+* [#5796](https://github.com/netbox-community/netbox/issues/5796) - Add DC terminal power port, outlet types
+* [#5980](https://github.com/netbox-community/netbox/issues/5980) - Add Saf-D-Grid power port, outlet types
+* [#6157](https://github.com/netbox-community/netbox/issues/6157) - Support Markdown rendering for report logs
+* [#6160](https://github.com/netbox-community/netbox/issues/6160) - Add F connector port type
+* [#6168](https://github.com/netbox-community/netbox/issues/6168) - Add SFP56 50GE interface type
 
 
 ### Bug Fixes
 ### Bug Fixes
 
 
 * [#5419](https://github.com/netbox-community/netbox/issues/5419) - Update parent device/VM when deleting a primary IP
 * [#5419](https://github.com/netbox-community/netbox/issues/5419) - Update parent device/VM when deleting a primary IP
+* [#5643](https://github.com/netbox-community/netbox/issues/5643) - Fix VLAN assignment when editing VM interfaces in bulk
+* [#5652](https://github.com/netbox-community/netbox/issues/5652) - Update object data when renaming a custom field
 * [#6056](https://github.com/netbox-community/netbox/issues/6056) - Optimize change log cleanup
 * [#6056](https://github.com/netbox-community/netbox/issues/6056) - Optimize change log cleanup
 * [#6144](https://github.com/netbox-community/netbox/issues/6144) - Fix MAC address field display in VM interfaces search form
 * [#6144](https://github.com/netbox-community/netbox/issues/6144) - Fix MAC address field display in VM interfaces search form
 * [#6152](https://github.com/netbox-community/netbox/issues/6152) - Fix custom field filtering for cables, virtual chassis
 * [#6152](https://github.com/netbox-community/netbox/issues/6152) - Fix custom field filtering for cables, virtual chassis
+* [#6162](https://github.com/netbox-community/netbox/issues/6162) - Fix choice field filters (multiple models)
 
 
 ---
 ---
 
 

+ 2 - 0
docs/release-notes/version-2.11.md

@@ -28,6 +28,8 @@
 * [#6124](https://github.com/netbox-community/netbox/issues/6124) - Location `parent` filter should return all child locations (not just those directly assigned)
 * [#6124](https://github.com/netbox-community/netbox/issues/6124) - Location `parent` filter should return all child locations (not just those directly assigned)
 * [#6130](https://github.com/netbox-community/netbox/issues/6130) - Improve display of assigned models in custom fields list
 * [#6130](https://github.com/netbox-community/netbox/issues/6130) - Improve display of assigned models in custom fields list
 * [#6155](https://github.com/netbox-community/netbox/issues/6155) - Fix admin links for plugins, background tasks
 * [#6155](https://github.com/netbox-community/netbox/issues/6155) - Fix admin links for plugins, background tasks
+* [#6171](https://github.com/netbox-community/netbox/issues/6171) - Fix display of horizontally-scrolling object lists
+* [#6173](https://github.com/netbox-community/netbox/issues/6173) - Fix assigned device/VM count when bulk editing/deleting device roles
 
 
 ---
 ---
 
 

+ 4 - 4
netbox/circuits/tests/test_views.py

@@ -6,7 +6,7 @@ from django.urls import reverse
 from circuits.choices import *
 from circuits.choices import *
 from circuits.models import *
 from circuits.models import *
 from dcim.models import Cable, Interface, Site
 from dcim.models import Cable, Interface, Site
-from utilities.testing import ViewTestCases, create_test_device
+from utilities.testing import ViewTestCases, create_tags, create_test_device
 
 
 
 
 class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@@ -21,7 +21,7 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             Provider(name='Provider 3', slug='provider-3', asn=65003),
             Provider(name='Provider 3', slug='provider-3', asn=65003),
         ])
         ])
 
 
-        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         cls.form_data = {
             'name': 'Provider X',
             'name': 'Provider X',
@@ -106,7 +106,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             Circuit(cid='Circuit 3', provider=providers[0], type=circuittypes[0]),
             Circuit(cid='Circuit 3', provider=providers[0], type=circuittypes[0]),
         ])
         ])
 
 
-        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         cls.form_data = {
             'cid': 'Circuit X',
             'cid': 'Circuit X',
@@ -157,7 +157,7 @@ class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             ProviderNetwork(name='Provider Network 3', provider=providers[0]),
             ProviderNetwork(name='Provider Network 3', provider=providers[0]),
         ])
         ])
 
 
-        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         cls.form_data = {
             'name': 'Provider Network X',
             'name': 'Provider Network X',

+ 21 - 0
netbox/dcim/choices.py

@@ -337,6 +337,10 @@ class PowerPortTypeChoices(ChoiceSet):
     TYPE_USB_MICRO_B = 'usb-micro-b'
     TYPE_USB_MICRO_B = 'usb-micro-b'
     TYPE_USB_3_B = 'usb-3-b'
     TYPE_USB_3_B = 'usb-3-b'
     TYPE_USB_3_MICROB = 'usb-3-micro-b'
     TYPE_USB_3_MICROB = 'usb-3-micro-b'
+    # Direct current (DC)
+    TYPE_DC = 'dc-terminal'
+    # Proprietary
+    TYPE_SAF_D_GRID = 'saf-d-grid'
 
 
     CHOICES = (
     CHOICES = (
         ('IEC 60320', (
         ('IEC 60320', (
@@ -437,6 +441,12 @@ class PowerPortTypeChoices(ChoiceSet):
             (TYPE_USB_3_B, 'USB 3.0 Type B'),
             (TYPE_USB_3_B, 'USB 3.0 Type B'),
             (TYPE_USB_3_MICROB, 'USB 3.0 Micro B'),
             (TYPE_USB_3_MICROB, 'USB 3.0 Micro B'),
         )),
         )),
+        ('DC', (
+            (TYPE_DC, 'DC Terminal'),
+        )),
+        ('Proprietary', (
+            (TYPE_SAF_D_GRID, 'Saf-D-Grid'),
+        )),
     )
     )
 
 
 
 
@@ -530,8 +540,11 @@ class PowerOutletTypeChoices(ChoiceSet):
     TYPE_USB_A = 'usb-a'
     TYPE_USB_A = 'usb-a'
     TYPE_USB_MICROB = 'usb-micro-b'
     TYPE_USB_MICROB = 'usb-micro-b'
     TYPE_USB_C = 'usb-c'
     TYPE_USB_C = 'usb-c'
+    # Direct current (DC)
+    TYPE_DC = 'dc-terminal'
     # Proprietary
     # Proprietary
     TYPE_HDOT_CX = 'hdot-cx'
     TYPE_HDOT_CX = 'hdot-cx'
+    TYPE_SAF_D_GRID = 'saf-d-grid'
 
 
     CHOICES = (
     CHOICES = (
         ('IEC 60320', (
         ('IEC 60320', (
@@ -625,8 +638,12 @@ class PowerOutletTypeChoices(ChoiceSet):
             (TYPE_USB_MICROB, 'USB Micro B'),
             (TYPE_USB_MICROB, 'USB Micro B'),
             (TYPE_USB_C, 'USB Type C'),
             (TYPE_USB_C, 'USB Type C'),
         )),
         )),
+        ('DC', (
+            (TYPE_DC, 'DC Terminal'),
+        )),
         ('Proprietary', (
         ('Proprietary', (
             (TYPE_HDOT_CX, 'HDOT Cx'),
             (TYPE_HDOT_CX, 'HDOT Cx'),
+            (TYPE_SAF_D_GRID, 'Saf-D-Grid'),
         )),
         )),
     )
     )
 
 
@@ -668,6 +685,7 @@ class InterfaceTypeChoices(ChoiceSet):
     TYPE_10GE_XENPAK = '10gbase-x-xenpak'
     TYPE_10GE_XENPAK = '10gbase-x-xenpak'
     TYPE_10GE_X2 = '10gbase-x-x2'
     TYPE_10GE_X2 = '10gbase-x-x2'
     TYPE_25GE_SFP28 = '25gbase-x-sfp28'
     TYPE_25GE_SFP28 = '25gbase-x-sfp28'
+    TYPE_50GE_SFP56 = '50gbase-x-sfp56'
     TYPE_40GE_QSFP_PLUS = '40gbase-x-qsfpp'
     TYPE_40GE_QSFP_PLUS = '40gbase-x-qsfpp'
     TYPE_50GE_QSFP28 = '50gbase-x-sfp28'
     TYPE_50GE_QSFP28 = '50gbase-x-sfp28'
     TYPE_100GE_CFP = '100gbase-x-cfp'
     TYPE_100GE_CFP = '100gbase-x-cfp'
@@ -772,6 +790,7 @@ class InterfaceTypeChoices(ChoiceSet):
                 (TYPE_10GE_XENPAK, 'XENPAK (10GE)'),
                 (TYPE_10GE_XENPAK, 'XENPAK (10GE)'),
                 (TYPE_10GE_X2, 'X2 (10GE)'),
                 (TYPE_10GE_X2, 'X2 (10GE)'),
                 (TYPE_25GE_SFP28, 'SFP28 (25GE)'),
                 (TYPE_25GE_SFP28, 'SFP28 (25GE)'),
+                (TYPE_50GE_SFP56, 'SFP56 (50GE)'),
                 (TYPE_40GE_QSFP_PLUS, 'QSFP+ (40GE)'),
                 (TYPE_40GE_QSFP_PLUS, 'QSFP+ (40GE)'),
                 (TYPE_50GE_QSFP28, 'QSFP28 (50GE)'),
                 (TYPE_50GE_QSFP28, 'QSFP28 (50GE)'),
                 (TYPE_100GE_CFP, 'CFP (100GE)'),
                 (TYPE_100GE_CFP, 'CFP (100GE)'),
@@ -904,6 +923,7 @@ class PortTypeChoices(ChoiceSet):
     TYPE_TERA1P = 'tera-1p'
     TYPE_TERA1P = 'tera-1p'
     TYPE_110_PUNCH = '110-punch'
     TYPE_110_PUNCH = '110-punch'
     TYPE_BNC = 'bnc'
     TYPE_BNC = 'bnc'
+    TYPE_F = 'f'
     TYPE_MRJ21 = 'mrj21'
     TYPE_MRJ21 = 'mrj21'
     TYPE_ST = 'st'
     TYPE_ST = 'st'
     TYPE_SC = 'sc'
     TYPE_SC = 'sc'
@@ -933,6 +953,7 @@ class PortTypeChoices(ChoiceSet):
                 (TYPE_TERA1P, 'TERA 1P'),
                 (TYPE_TERA1P, 'TERA 1P'),
                 (TYPE_110_PUNCH, '110 Punch'),
                 (TYPE_110_PUNCH, '110 Punch'),
                 (TYPE_BNC, 'BNC'),
                 (TYPE_BNC, 'BNC'),
+                (TYPE_F, 'F Connector'),
                 (TYPE_MRJ21, 'MRJ21'),
                 (TYPE_MRJ21, 'MRJ21'),
             ),
             ),
         ),
         ),

+ 32 - 0
netbox/dcim/filters.py

@@ -498,6 +498,10 @@ class PowerPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
 
 
 
 
 class PowerOutletTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
 class PowerOutletTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
+    feed_leg = django_filters.MultipleChoiceFilter(
+        choices=PowerOutletFeedLegChoices,
+        null_value=None
+    )
 
 
     class Meta:
     class Meta:
         model = PowerOutletTemplate
         model = PowerOutletTemplate
@@ -505,6 +509,10 @@ class PowerOutletTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
 
 
 
 
 class InterfaceTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
 class InterfaceTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
+    type = django_filters.MultipleChoiceFilter(
+        choices=InterfaceTypeChoices,
+        null_value=None
+    )
 
 
     class Meta:
     class Meta:
         model = InterfaceTemplate
         model = InterfaceTemplate
@@ -512,6 +520,10 @@ class InterfaceTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
 
 
 
 
 class FrontPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
 class FrontPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
+    type = django_filters.MultipleChoiceFilter(
+        choices=PortTypeChoices,
+        null_value=None
+    )
 
 
     class Meta:
     class Meta:
         model = FrontPortTemplate
         model = FrontPortTemplate
@@ -519,6 +531,10 @@ class FrontPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
 
 
 
 
 class RearPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
 class RearPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
+    type = django_filters.MultipleChoiceFilter(
+        choices=PortTypeChoices,
+        null_value=None
+    )
 
 
     class Meta:
     class Meta:
         model = RearPortTemplate
         model = RearPortTemplate
@@ -895,6 +911,10 @@ class PowerOutletFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTermina
         choices=PowerOutletTypeChoices,
         choices=PowerOutletTypeChoices,
         null_value=None
         null_value=None
     )
     )
+    feed_leg = django_filters.MultipleChoiceFilter(
+        choices=PowerOutletFeedLegChoices,
+        null_value=None
+    )
 
 
     class Meta:
     class Meta:
         model = PowerOutlet
         model = PowerOutlet
@@ -1000,6 +1020,10 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati
 
 
 
 
 class FrontPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet):
 class FrontPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet):
+    type = django_filters.MultipleChoiceFilter(
+        choices=PortTypeChoices,
+        null_value=None
+    )
 
 
     class Meta:
     class Meta:
         model = FrontPort
         model = FrontPort
@@ -1007,6 +1031,10 @@ class FrontPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati
 
 
 
 
 class RearPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet):
 class RearPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet):
+    type = django_filters.MultipleChoiceFilter(
+        choices=PortTypeChoices,
+        null_value=None
+    )
 
 
     class Meta:
     class Meta:
         model = RearPort
         model = RearPort
@@ -1423,6 +1451,10 @@ class PowerFeedFilterSet(
         queryset=Rack.objects.all(),
         queryset=Rack.objects.all(),
         label='Rack (ID)',
         label='Rack (ID)',
     )
     )
+    status = django_filters.MultipleChoiceFilter(
+        choices=PowerFeedStatusChoices,
+        null_value=None
+    )
     tag = TagFilter()
     tag = TagFilter()
 
 
     class Meta:
     class Meta:

+ 16 - 24
netbox/dcim/tests/test_filters.py

@@ -940,9 +940,8 @@ class PowerOutletTemplateTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_feed_leg(self):
     def test_feed_leg(self):
-        # TODO: Support filtering for multiple values
-        params = {'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_A}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'feed_leg': [PowerOutletFeedLegChoices.FEED_LEG_A, PowerOutletFeedLegChoices.FEED_LEG_B]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
 class InterfaceTemplateTestCase(TestCase):
 class InterfaceTemplateTestCase(TestCase):
@@ -981,9 +980,8 @@ class InterfaceTemplateTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_type(self):
     def test_type(self):
-        # TODO: Support filtering for multiple values
-        params = {'type': InterfaceTypeChoices.TYPE_1GE_FIXED}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'type': [InterfaceTypeChoices.TYPE_1GE_FIXED, InterfaceTypeChoices.TYPE_1GE_GBIC]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_mgmt_only(self):
     def test_mgmt_only(self):
         params = {'mgmt_only': 'true'}
         params = {'mgmt_only': 'true'}
@@ -1035,9 +1033,8 @@ class FrontPortTemplateTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_type(self):
     def test_type(self):
-        # TODO: Support filtering for multiple values
-        params = {'type': PortTypeChoices.TYPE_8P8C}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'type': [PortTypeChoices.TYPE_8P8C, PortTypeChoices.TYPE_110_PUNCH]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
 class RearPortTemplateTestCase(TestCase):
 class RearPortTemplateTestCase(TestCase):
@@ -1076,9 +1073,8 @@ class RearPortTemplateTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_type(self):
     def test_type(self):
-        # TODO: Support filtering for multiple values
-        params = {'type': PortTypeChoices.TYPE_8P8C}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'type': [PortTypeChoices.TYPE_8P8C, PortTypeChoices.TYPE_110_PUNCH]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_positions(self):
     def test_positions(self):
         params = {'positions': [1, 2]}
         params = {'positions': [1, 2]}
@@ -1985,9 +1981,8 @@ class PowerOutletTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_feed_leg(self):
     def test_feed_leg(self):
-        # TODO: Support filtering for multiple values
-        params = {'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_A}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'feed_leg': [PowerOutletFeedLegChoices.FEED_LEG_A, PowerOutletFeedLegChoices.FEED_LEG_B]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_connected(self):
     def test_connected(self):
         params = {'connected': True}
         params = {'connected': True}
@@ -2284,9 +2279,8 @@ class FrontPortTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_type(self):
     def test_type(self):
-        # TODO: Test for multiple values
-        params = {'type': PortTypeChoices.TYPE_8P8C}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'type': [PortTypeChoices.TYPE_8P8C, PortTypeChoices.TYPE_110_PUNCH]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_description(self):
     def test_description(self):
         params = {'description': ['First', 'Second']}
         params = {'description': ['First', 'Second']}
@@ -2396,9 +2390,8 @@ class RearPortTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_type(self):
     def test_type(self):
-        # TODO: Test for multiple values
-        params = {'type': PortTypeChoices.TYPE_8P8C}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'type': [PortTypeChoices.TYPE_8P8C, PortTypeChoices.TYPE_110_PUNCH]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_positions(self):
     def test_positions(self):
         params = {'positions': [1, 2]}
         params = {'positions': [1, 2]}
@@ -3045,9 +3038,8 @@ class PowerFeedTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_status(self):
     def test_status(self):
-        # TODO: Test for multiple values
-        params = {'status': PowerFeedStatusChoices.STATUS_ACTIVE}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'status': [PowerFeedStatusChoices.STATUS_ACTIVE, PowerFeedStatusChoices.STATUS_FAILED]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_type(self):
     def test_type(self):
         params = {'type': PowerFeedTypeChoices.TYPE_PRIMARY}
         params = {'type': PowerFeedTypeChoices.TYPE_PRIMARY}

+ 18 - 18
netbox/dcim/tests/test_views.py

@@ -12,7 +12,7 @@ from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.models import *
 from dcim.models import *
 from ipam.models import VLAN
 from ipam.models import VLAN
-from utilities.testing import ViewTestCases, create_test_device
+from utilities.testing import ViewTestCases, create_tags, create_test_device
 
 
 
 
 class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
 class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
@@ -109,7 +109,7 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             Site(name='Site 3', slug='site-3', region=regions[0], group=groups[1]),
             Site(name='Site 3', slug='site-3', region=regions[0], group=groups[1]),
         ])
         ])
 
 
-        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         cls.form_data = {
             'name': 'Site X',
             'name': 'Site X',
@@ -242,7 +242,7 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             RackReservation(rack=rack, user=user2, units=[7, 8, 9], description='Reservation 3'),
             RackReservation(rack=rack, user=user2, units=[7, 8, 9], description='Reservation 3'),
         ])
         ])
 
 
-        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         cls.form_data = {
             'rack': rack.pk,
             'rack': rack.pk,
@@ -298,7 +298,7 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             Rack(name='Rack 3', site=sites[0]),
             Rack(name='Rack 3', site=sites[0]),
         ))
         ))
 
 
-        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         cls.form_data = {
             'name': 'Rack X',
             'name': 'Rack X',
@@ -413,7 +413,7 @@ class DeviceTypeTestCase(
             DeviceType(model='Device Type 3', slug='device-type-3', manufacturer=manufacturers[0]),
             DeviceType(model='Device Type 3', slug='device-type-3', manufacturer=manufacturers[0]),
         ])
         ])
 
 
-        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         cls.form_data = {
             'manufacturer': manufacturers[1].pk,
             'manufacturer': manufacturers[1].pk,
@@ -1021,7 +1021,7 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             Device(name='Device 3', site=sites[0], rack=racks[0], device_type=devicetypes[0], device_role=deviceroles[0], platform=platforms[0]),
             Device(name='Device 3', site=sites[0], rack=racks[0], device_type=devicetypes[0], device_role=deviceroles[0], platform=platforms[0]),
         ])
         ])
 
 
-        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         cls.form_data = {
             'device_type': devicetypes[1].pk,
             'device_type': devicetypes[1].pk,
@@ -1201,7 +1201,7 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             ConsolePort(device=device, name='Console Port 3'),
             ConsolePort(device=device, name='Console Port 3'),
         ])
         ])
 
 
-        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         cls.form_data = {
             'device': device.pk,
             'device': device.pk,
@@ -1259,7 +1259,7 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             ConsoleServerPort(device=device, name='Console Server Port 3'),
             ConsoleServerPort(device=device, name='Console Server Port 3'),
         ])
         ])
 
 
-        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         cls.form_data = {
             'device': device.pk,
             'device': device.pk,
@@ -1315,7 +1315,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             PowerPort(device=device, name='Power Port 3'),
             PowerPort(device=device, name='Power Port 3'),
         ])
         ])
 
 
-        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         cls.form_data = {
             'device': device.pk,
             'device': device.pk,
@@ -1383,7 +1383,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
             PowerOutlet(device=device, name='Power Outlet 3', power_port=powerports[0]),
             PowerOutlet(device=device, name='Power Outlet 3', power_port=powerports[0]),
         ])
         ])
 
 
-        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         cls.form_data = {
             'device': device.pk,
             'device': device.pk,
@@ -1452,7 +1452,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
         )
         )
         VLAN.objects.bulk_create(vlans)
         VLAN.objects.bulk_create(vlans)
 
 
-        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         cls.form_data = {
             'device': device.pk,
             'device': device.pk,
@@ -1539,7 +1539,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             FrontPort(device=device, name='Front Port 3', rear_port=rearports[2]),
             FrontPort(device=device, name='Front Port 3', rear_port=rearports[2]),
         ])
         ])
 
 
-        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         cls.form_data = {
             'device': device.pk,
             'device': device.pk,
@@ -1600,7 +1600,7 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             RearPort(device=device, name='Rear Port 3'),
             RearPort(device=device, name='Rear Port 3'),
         ])
         ])
 
 
-        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         cls.form_data = {
             'device': device.pk,
             'device': device.pk,
@@ -1661,7 +1661,7 @@ class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
             DeviceBay(device=device, name='Device Bay 3'),
             DeviceBay(device=device, name='Device Bay 3'),
         ])
         ])
 
 
-        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         cls.form_data = {
             'device': device.pk,
             'device': device.pk,
@@ -1701,7 +1701,7 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
         InventoryItem.objects.create(device=device, name='Inventory Item 2')
         InventoryItem.objects.create(device=device, name='Inventory Item 2')
         InventoryItem.objects.create(device=device, name='Inventory Item 3')
         InventoryItem.objects.create(device=device, name='Inventory Item 3')
 
 
-        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         cls.form_data = {
             'device': device.pk,
             'device': device.pk,
@@ -1791,7 +1791,7 @@ class CableTestCase(
         Cable(termination_a=interfaces[1], termination_b=interfaces[4], type=CableTypeChoices.TYPE_CAT6).save()
         Cable(termination_a=interfaces[1], termination_b=interfaces[4], type=CableTypeChoices.TYPE_CAT6).save()
         Cable(termination_a=interfaces[2], termination_b=interfaces[5], type=CableTypeChoices.TYPE_CAT6).save()
         Cable(termination_a=interfaces[2], termination_b=interfaces[5], type=CableTypeChoices.TYPE_CAT6).save()
 
 
-        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         interface_ct = ContentType.objects.get_for_model(Interface)
         interface_ct = ContentType.objects.get_for_model(Interface)
         cls.form_data = {
         cls.form_data = {
@@ -1918,7 +1918,7 @@ class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             PowerPanel(site=sites[0], location=locations[0], name='Power Panel 3'),
             PowerPanel(site=sites[0], location=locations[0], name='Power Panel 3'),
         ))
         ))
 
 
-        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         cls.form_data = {
             'site': sites[1].pk,
             'site': sites[1].pk,
@@ -1966,7 +1966,7 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             PowerFeed(name='Power Feed 3', power_panel=powerpanels[0], rack=racks[0]),
             PowerFeed(name='Power Feed 3', power_panel=powerpanels[0], rack=racks[0]),
         ))
         ))
 
 
-        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         cls.form_data = {
             'name': 'Power Feed X',
             'name': 'Power Feed X',

+ 8 - 2
netbox/dcim/views.py

@@ -1172,14 +1172,20 @@ class DeviceRoleBulkImportView(generic.BulkImportView):
 
 
 
 
 class DeviceRoleBulkEditView(generic.BulkEditView):
 class DeviceRoleBulkEditView(generic.BulkEditView):
-    queryset = DeviceRole.objects.all()
+    queryset = DeviceRole.objects.annotate(
+        device_count=count_related(Device, 'device_role'),
+        vm_count=count_related(VirtualMachine, 'role')
+    )
     filterset = filters.DeviceRoleFilterSet
     filterset = filters.DeviceRoleFilterSet
     table = tables.DeviceRoleTable
     table = tables.DeviceRoleTable
     form = forms.DeviceRoleBulkEditForm
     form = forms.DeviceRoleBulkEditForm
 
 
 
 
 class DeviceRoleBulkDeleteView(generic.BulkDeleteView):
 class DeviceRoleBulkDeleteView(generic.BulkDeleteView):
-    queryset = DeviceRole.objects.all()
+    queryset = DeviceRole.objects.annotate(
+        device_count=count_related(Device, 'device_role'),
+        vm_count=count_related(VirtualMachine, 'role')
+    )
     table = tables.DeviceRoleTable
     table = tables.DeviceRoleTable
 
 
 
 

+ 18 - 0
netbox/extras/models/customfields.py

@@ -114,6 +114,24 @@ class CustomField(BigIDModel):
     def __str__(self):
     def __str__(self):
         return self.label or self.name.replace('_', ' ').capitalize()
         return self.label or self.name.replace('_', ' ').capitalize()
 
 
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Cache instance's original name so we can check later whether it has changed
+        self._name = self.name
+
+    def rename_object_data(self, old_name, new_name):
+        """
+        Called when a CustomField has been renamed. Updates all assigned object data.
+        """
+        for ct in self.content_types.all():
+            model = ct.model_class()
+            params = {f'custom_field_data__{old_name}__isnull': False}
+            instances = model.objects.filter(**params)
+            for instance in instances:
+                instance.custom_field_data[new_name] = instance.custom_field_data.pop(old_name)
+            model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
+
     def remove_stale_data(self, content_types):
     def remove_stale_data(self, content_types):
         """
         """
         Delete custom field data which is no longer relevant (either because the CustomField is
         Delete custom field data which is no longer relevant (either because the CustomField is

+ 2 - 0
netbox/extras/models/models.py

@@ -391,6 +391,8 @@ class ImageAttachment(BigIDModel):
 # Journal entries
 # Journal entries
 #
 #
 
 
+
+@extras_features('webhooks')
 class JournalEntry(ChangeLoggedModel):
 class JournalEntry(ChangeLoggedModel):
     """
     """
     A historical remark concerning an object; collectively, these form an object's journal. The journal is used to
     A historical remark concerning an object; collectively, these form an object's journal. The journal is used to

+ 10 - 1
netbox/extras/signals.py

@@ -5,7 +5,7 @@ from cacheops.signals import cache_invalidated, cache_read
 from django.conf import settings
 from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.db import DEFAULT_DB_ALIAS
 from django.db import DEFAULT_DB_ALIAS
-from django.db.models.signals import m2m_changed, pre_delete
+from django.db.models.signals import m2m_changed, post_save, pre_delete
 from django.utils import timezone
 from django.utils import timezone
 from django_prometheus.models import model_deletes, model_inserts, model_updates
 from django_prometheus.models import model_deletes, model_inserts, model_updates
 from prometheus_client import Counter
 from prometheus_client import Counter
@@ -98,6 +98,14 @@ def handle_cf_removed_obj_types(instance, action, pk_set, **kwargs):
         instance.remove_stale_data(ContentType.objects.filter(pk__in=pk_set))
         instance.remove_stale_data(ContentType.objects.filter(pk__in=pk_set))
 
 
 
 
+def handle_cf_renamed(instance, created, **kwargs):
+    """
+    Handle the renaming of custom field data on objects when a CustomField is renamed.
+    """
+    if not created and instance.name != instance._name:
+        instance.rename_object_data(old_name=instance._name, new_name=instance.name)
+
+
 def handle_cf_deleted(instance, **kwargs):
 def handle_cf_deleted(instance, **kwargs):
     """
     """
     Handle the cleanup of old custom field data when a CustomField is deleted.
     Handle the cleanup of old custom field data when a CustomField is deleted.
@@ -106,6 +114,7 @@ def handle_cf_deleted(instance, **kwargs):
 
 
 
 
 m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.content_types.through)
 m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.content_types.through)
+post_save.connect(handle_cf_renamed, sender=CustomField)
 pre_delete.connect(handle_cf_deleted, sender=CustomField)
 pre_delete.connect(handle_cf_deleted, sender=CustomField)
 
 
 
 

+ 4 - 4
netbox/extras/tests/test_changelog.py

@@ -7,7 +7,7 @@ from dcim.models import Site
 from extras.choices import *
 from extras.choices import *
 from extras.models import CustomField, ObjectChange, Tag
 from extras.models import CustomField, ObjectChange, Tag
 from utilities.testing import APITestCase
 from utilities.testing import APITestCase
-from utilities.testing.utils import post_data
+from utilities.testing.utils import create_tags, post_data
 from utilities.testing.views import ModelViewTestCase
 from utilities.testing.views import ModelViewTestCase
 
 
 
 
@@ -38,7 +38,7 @@ class ChangeLogViewTest(ModelViewTestCase):
         cf_select.content_types.set([ct])
         cf_select.content_types.set([ct])
 
 
     def test_create_object(self):
     def test_create_object(self):
-        tags = self.create_tags('Tag 1', 'Tag 2')
+        tags = create_tags('Tag 1', 'Tag 2')
         form_data = {
         form_data = {
             'name': 'Site 1',
             'name': 'Site 1',
             'slug': 'site-1',
             'slug': 'site-1',
@@ -72,7 +72,7 @@ class ChangeLogViewTest(ModelViewTestCase):
     def test_update_object(self):
     def test_update_object(self):
         site = Site(name='Site 1', slug='site-1')
         site = Site(name='Site 1', slug='site-1')
         site.save()
         site.save()
-        tags = self.create_tags('Tag 1', 'Tag 2', 'Tag 3')
+        tags = create_tags('Tag 1', 'Tag 2', 'Tag 3')
         site.tags.set('Tag 1', 'Tag 2')
         site.tags.set('Tag 1', 'Tag 2')
 
 
         form_data = {
         form_data = {
@@ -116,7 +116,7 @@ class ChangeLogViewTest(ModelViewTestCase):
             }
             }
         )
         )
         site.save()
         site.save()
-        self.create_tags('Tag 1', 'Tag 2')
+        create_tags('Tag 1', 'Tag 2')
         site.tags.set('Tag 1', 'Tag 2')
         site.tags.set('Tag 1', 'Tag 2')
 
 
         request = {
         request = {

+ 27 - 0
netbox/extras/tests/test_customfields.py

@@ -91,6 +91,33 @@ class CustomFieldTest(TestCase):
         # Delete the custom field
         # Delete the custom field
         cf.delete()
         cf.delete()
 
 
+    def test_rename_customfield(self):
+        obj_type = ContentType.objects.get_for_model(Site)
+        FIELD_DATA = 'abc'
+
+        # Create a custom field
+        cf = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='field1')
+        cf.save()
+        cf.content_types.set([obj_type])
+
+        # Assign custom field data to an object
+        site = Site.objects.create(
+            name='Site 1',
+            slug='site-1',
+            custom_field_data={'field1': FIELD_DATA}
+        )
+        site.refresh_from_db()
+        self.assertEqual(site.custom_field_data['field1'], FIELD_DATA)
+
+        # Rename the custom field
+        cf.name = 'field2'
+        cf.save()
+
+        # Check that custom field data on the object has been updated
+        site.refresh_from_db()
+        self.assertNotIn('field1', site.custom_field_data)
+        self.assertEqual(site.custom_field_data['field2'], FIELD_DATA)
+
 
 
 class CustomFieldManagerTest(TestCase):
 class CustomFieldManagerTest(TestCase):
 
 

+ 3 - 3
netbox/extras/tests/test_tags.py

@@ -2,7 +2,7 @@ from django.urls import reverse
 from rest_framework import status
 from rest_framework import status
 
 
 from dcim.models import Site
 from dcim.models import Site
-from utilities.testing import APITestCase
+from utilities.testing import APITestCase, create_tags
 
 
 
 
 class TaggedItemTest(APITestCase):
 class TaggedItemTest(APITestCase):
@@ -10,7 +10,7 @@ class TaggedItemTest(APITestCase):
     Test the application of Tags to and item (a Site, for example) upon creation (POST) and modification (PATCH).
     Test the application of Tags to and item (a Site, for example) upon creation (POST) and modification (PATCH).
     """
     """
     def test_create_tagged_item(self):
     def test_create_tagged_item(self):
-        tags = self.create_tags("Foo", "Bar", "Baz")
+        tags = create_tags("Foo", "Bar", "Baz")
         data = {
         data = {
             'name': 'Test Site',
             'name': 'Test Site',
             'slug': 'test-site',
             'slug': 'test-site',
@@ -37,7 +37,7 @@ class TaggedItemTest(APITestCase):
             slug='test-site'
             slug='test-site'
         )
         )
         site.tags.add("Foo", "Bar", "Baz")
         site.tags.add("Foo", "Bar", "Baz")
-        self.create_tags("New Tag")
+        create_tags("New Tag")
         data = {
         data = {
             'tags': [
             'tags': [
                 {"name": "Foo"},
                 {"name": "Foo"},

+ 8 - 8
netbox/ipam/tests/test_views.py

@@ -6,7 +6,7 @@ from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
 from ipam.choices import *
 from ipam.choices import *
 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF
 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF
 from tenancy.models import Tenant
 from tenancy.models import Tenant
-from utilities.testing import ViewTestCases
+from utilities.testing import ViewTestCases, create_tags
 
 
 
 
 class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@@ -27,7 +27,7 @@ class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             VRF(name='VRF 3', rd='65000:3'),
             VRF(name='VRF 3', rd='65000:3'),
         ])
         ])
 
 
-        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         cls.form_data = {
             'name': 'VRF X',
             'name': 'VRF X',
@@ -64,7 +64,7 @@ class RouteTargetTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         )
         )
         Tenant.objects.bulk_create(tenants)
         Tenant.objects.bulk_create(tenants)
 
 
-        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         route_targets = (
         route_targets = (
             RouteTarget(name='65000:1001', tenant=tenants[0]),
             RouteTarget(name='65000:1001', tenant=tenants[0]),
@@ -141,7 +141,7 @@ class AggregateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             Aggregate(prefix=IPNetwork('10.3.0.0/16'), rir=rirs[0]),
             Aggregate(prefix=IPNetwork('10.3.0.0/16'), rir=rirs[0]),
         ])
         ])
 
 
-        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         cls.form_data = {
             'prefix': IPNetwork('10.99.0.0/16'),
             'prefix': IPNetwork('10.99.0.0/16'),
@@ -226,7 +226,7 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             Prefix(prefix=IPNetwork('10.3.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]),
             Prefix(prefix=IPNetwork('10.3.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]),
         ])
         ])
 
 
-        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         cls.form_data = {
             'prefix': IPNetwork('192.0.2.0/24'),
             'prefix': IPNetwork('192.0.2.0/24'),
@@ -277,7 +277,7 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             IPAddress(address=IPNetwork('192.0.2.3/24'), vrf=vrfs[0]),
             IPAddress(address=IPNetwork('192.0.2.3/24'), vrf=vrfs[0]),
         ])
         ])
 
 
-        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         cls.form_data = {
             'vrf': vrfs[1].pk,
             'vrf': vrfs[1].pk,
@@ -374,7 +374,7 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             VLAN(group=vlangroups[0], vid=103, name='VLAN103', site=sites[0], role=roles[0]),
             VLAN(group=vlangroups[0], vid=103, name='VLAN103', site=sites[0], role=roles[0]),
         ])
         ])
 
 
-        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         cls.form_data = {
             'site': sites[1].pk,
             'site': sites[1].pk,
@@ -434,7 +434,7 @@ class ServiceTestCase(
             Service(device=device, name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[103]),
             Service(device=device, name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[103]),
         ])
         ])
 
 
-        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         cls.form_data = {
             'device': device.pk,
             'device': device.pk,

+ 3 - 1
netbox/templates/extras/report_result.html

@@ -73,9 +73,11 @@
                                                 <a href="{{ url }}">{{ obj }}</a>
                                                 <a href="{{ url }}">{{ obj }}</a>
                                             {% elif obj %}
                                             {% elif obj %}
                                                 {{ obj }}
                                                 {{ obj }}
+                                            {% else %}
+                                                <span class="muted">&mdash;</span>
                                             {% endif %}
                                             {% endif %}
                                         </td>
                                         </td>
-                                        <td>{{ message }}</td>
+                                        <td class="rendered-markdown">{{ message|render_markdown }}</td>
                                     </tr>
                                     </tr>
                                 {% endfor %}
                                 {% endfor %}
                             {% endfor %}
                             {% endfor %}

+ 2 - 2
netbox/tenancy/tests/test_views.py

@@ -1,5 +1,5 @@
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
-from utilities.testing import ViewTestCases
+from utilities.testing import ViewTestCases, create_tags
 
 
 
 
 class TenantGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
 class TenantGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
@@ -53,7 +53,7 @@ class TenantTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[0]),
             Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[0]),
         ])
         ])
 
 
-        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         cls.form_data = {
             'name': 'Tenant X',
             'name': 'Tenant X',

+ 1 - 0
netbox/utilities/testing/__init__.py

@@ -1,3 +1,4 @@
 from .api import *
 from .api import *
+from .base import *
 from .utils import *
 from .utils import *
 from .views import *
 from .views import *

+ 1 - 1
netbox/utilities/testing/api.py

@@ -9,8 +9,8 @@ from rest_framework.test import APIClient
 from extras.choices import ObjectChangeActionChoices
 from extras.choices import ObjectChangeActionChoices
 from extras.models import ObjectChange
 from extras.models import ObjectChange
 from users.models import ObjectPermission, Token
 from users.models import ObjectPermission, Token
+from .base import ModelTestCase
 from .utils import disable_warnings
 from .utils import disable_warnings
-from .views import ModelTestCase
 
 
 
 
 __all__ = (
 __all__ = (

+ 161 - 0
netbox/utilities/testing/base.py

@@ -0,0 +1,161 @@
+from django.contrib.auth.models import User
+from django.contrib.contenttypes.models import ContentType
+from django.contrib.postgres.fields import ArrayField
+from django.core.exceptions import FieldDoesNotExist
+from django.db.models import ManyToManyField
+from django.forms.models import model_to_dict
+from django.test import Client, TestCase as _TestCase
+from netaddr import IPNetwork
+from taggit.managers import TaggableManager
+
+from users.models import ObjectPermission
+from utilities.permissions import resolve_permission_ct
+from .utils import extract_form_failures
+
+__all__ = (
+    'ModelTestCase',
+    'TestCase',
+)
+
+
+class TestCase(_TestCase):
+    user_permissions = ()
+
+    def setUp(self):
+
+        # Create the test user and assign permissions
+        self.user = User.objects.create_user(username='testuser')
+        self.add_permissions(*self.user_permissions)
+
+        # Initialize the test client
+        self.client = Client()
+        self.client.force_login(self.user)
+
+    #
+    # Permissions management
+    #
+
+    def add_permissions(self, *names):
+        """
+        Assign a set of permissions to the test user. Accepts permission names in the form <app>.<action>_<model>.
+        """
+        for name in names:
+            ct, action = resolve_permission_ct(name)
+            obj_perm = ObjectPermission(name=name, actions=[action])
+            obj_perm.save()
+            obj_perm.users.add(self.user)
+            obj_perm.object_types.add(ct)
+
+    #
+    # Custom assertions
+    #
+
+    def assertHttpStatus(self, response, expected_status):
+        """
+        TestCase method. Provide more detail in the event of an unexpected HTTP response.
+        """
+        err_message = None
+        # Construct an error message only if we know the test is going to fail
+        if response.status_code != expected_status:
+            if hasattr(response, 'data'):
+                # REST API response; pass the response data through directly
+                err = response.data
+            else:
+                # Attempt to extract form validation errors from the response HTML
+                form_errors = extract_form_failures(response.content)
+                err = form_errors or response.content or 'No data'
+            err_message = f"Expected HTTP status {expected_status}; received {response.status_code}: {err}"
+        self.assertEqual(response.status_code, expected_status, err_message)
+
+
+class ModelTestCase(TestCase):
+    """
+    Parent class for TestCases which deal with models.
+    """
+    model = None
+
+    def _get_queryset(self):
+        """
+        Return a base queryset suitable for use in test methods.
+        """
+        return self.model.objects.all()
+
+    def prepare_instance(self, instance):
+        """
+        Test cases can override this method to perform any necessary manipulation of an instance prior to its evaluation
+        against test data. For example, it can be used to decrypt a Secret's plaintext attribute.
+        """
+        return instance
+
+    def model_to_dict(self, instance, fields, api=False):
+        """
+        Return a dictionary representation of an instance.
+        """
+        # Prepare the instance and call Django's model_to_dict() to extract all fields
+        model_dict = model_to_dict(self.prepare_instance(instance), fields=fields)
+
+        # Map any additional (non-field) instance attributes that were specified
+        for attr in fields:
+            if hasattr(instance, attr) and attr not in model_dict:
+                model_dict[attr] = getattr(instance, attr)
+
+        for key, value in list(model_dict.items()):
+            try:
+                field = instance._meta.get_field(key)
+            except FieldDoesNotExist:
+                # Attribute is not a model field
+                continue
+
+            # Handle ManyToManyFields
+            if value and type(field) in (ManyToManyField, TaggableManager):
+
+                if field.related_model is ContentType:
+                    model_dict[key] = sorted([f'{ct.app_label}.{ct.model}' for ct in value])
+                else:
+                    model_dict[key] = sorted([obj.pk for obj in value])
+
+            if api:
+
+                # Replace ContentType numeric IDs with <app_label>.<model>
+                if type(getattr(instance, key)) is ContentType:
+                    ct = ContentType.objects.get(pk=value)
+                    model_dict[key] = f'{ct.app_label}.{ct.model}'
+
+                # Convert IPNetwork instances to strings
+                elif type(value) is IPNetwork:
+                    model_dict[key] = str(value)
+
+            else:
+
+                # Convert ArrayFields to CSV strings
+                if type(instance._meta.get_field(key)) is ArrayField:
+                    model_dict[key] = ','.join([str(v) for v in value])
+
+        return model_dict
+
+    #
+    # Custom assertions
+    #
+
+    def assertInstanceEqual(self, instance, data, exclude=None, api=False):
+        """
+        Compare a model instance to a dictionary, checking that its attribute values match those specified
+        in the dictionary.
+
+        :param instance: Python object instance
+        :param data: Dictionary of test data used to define the instance
+        :param exclude: List of fields to exclude from comparison (e.g. passwords, which get hashed)
+        :param api: Set to True is the data is a JSON representation of the instance
+        """
+        if exclude is None:
+            exclude = []
+
+        fields = [k for k in data.keys() if k not in exclude]
+        model_dict = self.model_to_dict(instance, fields=fields, api=api)
+
+        # Omit any dictionary keys which are not instance attributes or have been excluded
+        relevant_data = {
+            k: v for k, v in data.items() if hasattr(instance, k) and k not in exclude
+        }
+
+        self.assertDictEqual(model_dict, relevant_data)

+ 11 - 0
netbox/utilities/testing/utils.py

@@ -3,8 +3,10 @@ import re
 from contextlib import contextmanager
 from contextlib import contextmanager
 
 
 from django.contrib.auth.models import Permission, User
 from django.contrib.auth.models import Permission, User
+from django.utils.text import slugify
 
 
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
+from extras.models import Tag
 
 
 
 
 def post_data(data):
 def post_data(data):
@@ -59,6 +61,15 @@ def create_test_user(username='testuser', permissions=None):
     return user
     return user
 
 
 
 
+def create_tags(*names):
+    """
+    Create and return a Tag instance for each name given.
+    """
+    tags = [Tag(name=name, slug=slugify(name)) for name in names]
+    Tag.objects.bulk_create(tags)
+    return tags
+
+
 def extract_form_failures(content):
 def extract_form_failures(content):
     """
     """
     Given raw HTML content from an HTTP response, return a list of form errors.
     Given raw HTML content from an HTTP response, return a list of form errors.

+ 5 - 168
netbox/utilities/testing/views.py

@@ -1,182 +1,20 @@
-from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
-from django.contrib.postgres.fields import ArrayField
-from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist
-from django.db.models import ManyToManyField
-from django.forms.models import model_to_dict
-from django.test import Client, TestCase as _TestCase, override_settings
+from django.core.exceptions import ObjectDoesNotExist
+from django.test import override_settings
 from django.urls import reverse
 from django.urls import reverse
-from django.utils.text import slugify
-from netaddr import IPNetwork
-from taggit.managers import TaggableManager
 
 
 from extras.choices import ObjectChangeActionChoices
 from extras.choices import ObjectChangeActionChoices
-from extras.models import ObjectChange, Tag
+from extras.models import ObjectChange
 from users.models import ObjectPermission
 from users.models import ObjectPermission
-from utilities.permissions import resolve_permission_ct
-from .utils import disable_warnings, extract_form_failures, post_data
-
+from .base import ModelTestCase
+from .utils import disable_warnings, post_data
 
 
 __all__ = (
 __all__ = (
-    'TestCase',
-    'ModelTestCase',
     'ModelViewTestCase',
     'ModelViewTestCase',
     'ViewTestCases',
     'ViewTestCases',
 )
 )
 
 
 
 
-class TestCase(_TestCase):
-    user_permissions = ()
-
-    def setUp(self):
-
-        # Create the test user and assign permissions
-        self.user = User.objects.create_user(username='testuser')
-        self.add_permissions(*self.user_permissions)
-
-        # Initialize the test client
-        self.client = Client()
-        self.client.force_login(self.user)
-
-    def prepare_instance(self, instance):
-        """
-        Test cases can override this method to perform any necessary manipulation of an instance prior to its evaluation
-        against test data. For example, it can be used to decrypt a Secret's plaintext attribute.
-        """
-        return instance
-
-    def model_to_dict(self, instance, fields, api=False):
-        """
-        Return a dictionary representation of an instance.
-        """
-        # Prepare the instance and call Django's model_to_dict() to extract all fields
-        model_dict = model_to_dict(self.prepare_instance(instance), fields=fields)
-
-        # Map any additional (non-field) instance attributes that were specified
-        for attr in fields:
-            if hasattr(instance, attr) and attr not in model_dict:
-                model_dict[attr] = getattr(instance, attr)
-
-        for key, value in list(model_dict.items()):
-            try:
-                field = instance._meta.get_field(key)
-            except FieldDoesNotExist:
-                # Attribute is not a model field
-                continue
-
-            # Handle ManyToManyFields
-            if value and type(field) in (ManyToManyField, TaggableManager):
-
-                if field.related_model is ContentType:
-                    model_dict[key] = sorted([f'{ct.app_label}.{ct.model}' for ct in value])
-                else:
-                    model_dict[key] = sorted([obj.pk for obj in value])
-
-            if api:
-
-                # Replace ContentType numeric IDs with <app_label>.<model>
-                if type(getattr(instance, key)) is ContentType:
-                    ct = ContentType.objects.get(pk=value)
-                    model_dict[key] = f'{ct.app_label}.{ct.model}'
-
-                # Convert IPNetwork instances to strings
-                elif type(value) is IPNetwork:
-                    model_dict[key] = str(value)
-
-            else:
-
-                # Convert ArrayFields to CSV strings
-                if type(instance._meta.get_field(key)) is ArrayField:
-                    model_dict[key] = ','.join([str(v) for v in value])
-
-        return model_dict
-
-    #
-    # Permissions management
-    #
-
-    def add_permissions(self, *names):
-        """
-        Assign a set of permissions to the test user. Accepts permission names in the form <app>.<action>_<model>.
-        """
-        for name in names:
-            ct, action = resolve_permission_ct(name)
-            obj_perm = ObjectPermission(name=name, actions=[action])
-            obj_perm.save()
-            obj_perm.users.add(self.user)
-            obj_perm.object_types.add(ct)
-
-    #
-    # Custom assertions
-    #
-
-    def assertHttpStatus(self, response, expected_status):
-        """
-        TestCase method. Provide more detail in the event of an unexpected HTTP response.
-        """
-        err_message = None
-        # Construct an error message only if we know the test is going to fail
-        if response.status_code != expected_status:
-            if hasattr(response, 'data'):
-                # REST API response; pass the response data through directly
-                err = response.data
-            else:
-                # Attempt to extract form validation errors from the response HTML
-                form_errors = extract_form_failures(response.content)
-                err = form_errors or response.content or 'No data'
-            err_message = f"Expected HTTP status {expected_status}; received {response.status_code}: {err}"
-        self.assertEqual(response.status_code, expected_status, err_message)
-
-    def assertInstanceEqual(self, instance, data, exclude=None, api=False):
-        """
-        Compare a model instance to a dictionary, checking that its attribute values match those specified
-        in the dictionary.
-
-        :param instance: Python object instance
-        :param data: Dictionary of test data used to define the instance
-        :param exclude: List of fields to exclude from comparison (e.g. passwords, which get hashed)
-        :param api: Set to True is the data is a JSON representation of the instance
-        """
-        if exclude is None:
-            exclude = []
-
-        fields = [k for k in data.keys() if k not in exclude]
-        model_dict = self.model_to_dict(instance, fields=fields, api=api)
-
-        # Omit any dictionary keys which are not instance attributes or have been excluded
-        relevant_data = {
-            k: v for k, v in data.items() if hasattr(instance, k) and k not in exclude
-        }
-
-        self.assertDictEqual(model_dict, relevant_data)
-
-    #
-    # Convenience methods
-    #
-
-    @classmethod
-    def create_tags(cls, *names):
-        """
-        Create and return a Tag instance for each name given.
-        """
-        tags = [Tag(name=name, slug=slugify(name)) for name in names]
-        Tag.objects.bulk_create(tags)
-        return tags
-
-
-class ModelTestCase(TestCase):
-    """
-    Parent class for TestCases which deal with models.
-    """
-    model = None
-
-    def _get_queryset(self):
-        """
-        Return a base queryset suitable for use in test methods.
-        """
-        return self.model.objects.all()
-
-
 #
 #
 # UI Tests
 # UI Tests
 #
 #
@@ -185,7 +23,6 @@ class ModelViewTestCase(ModelTestCase):
     """
     """
     Base TestCase for model views. Subclass to test individual views.
     Base TestCase for model views. Subclass to test individual views.
     """
     """
-
     def _get_base_url(self):
     def _get_base_url(self):
         """
         """
         Return the base format for a URL for the test's model. Override this to test for a model which belongs
         Return the base format for a URL for the test's model. Override this to test for a model which belongs

+ 28 - 6
netbox/virtualization/forms.py

@@ -789,14 +789,36 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
-        vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine')
+        if 'virtual_machine' in self.initial:
+            vm_id = self.initial.get('virtual_machine')
 
 
-        # Restrict parent interface assignment by VM
-        self.fields['parent'].widget.add_query_param('virtualmachine_id', vm_id)
+            # Restrict parent interface assignment by VM
+            self.fields['parent'].widget.add_query_param('virtualmachine_id', vm_id)
 
 
-        # Limit VLAN choices by virtual machine
-        self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id)
-        self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id)
+            # Limit VLAN choices by virtual machine
+            self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id)
+            self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id)
+
+        else:
+            # See 5643
+            if 'pk' in self.initial:
+                site = None
+                interfaces = VMInterface.objects.filter(pk__in=self.initial['pk']).prefetch_related(
+                    'virtual_machine__cluster__site'
+                )
+
+                # Check interface sites.  First interface should set site, further interfaces will either continue the
+                # loop or reset back to no site and break the loop.
+                for interface in interfaces:
+                    if site is None:
+                        site = interface.virtual_machine.cluster.site
+                    elif interface.virtual_machine.cluster.site is not site:
+                        site = None
+                        break
+
+                if site is not None:
+                    self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk)
+                    self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk)
 
 
 
 
 class VMInterfaceBulkRenameForm(BulkRenameForm):
 class VMInterfaceBulkRenameForm(BulkRenameForm):

+ 4 - 4
netbox/virtualization/tests/test_views.py

@@ -5,7 +5,7 @@ from netaddr import EUI
 from dcim.choices import InterfaceModeChoices
 from dcim.choices import InterfaceModeChoices
 from dcim.models import DeviceRole, Platform, Site
 from dcim.models import DeviceRole, Platform, Site
 from ipam.models import VLAN
 from ipam.models import VLAN
-from utilities.testing import ViewTestCases
+from utilities.testing import ViewTestCases, create_tags
 from virtualization.choices import *
 from virtualization.choices import *
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 
 
@@ -100,7 +100,7 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             Cluster(name='Cluster 3', group=clustergroups[0], type=clustertypes[0], site=sites[0]),
             Cluster(name='Cluster 3', group=clustergroups[0], type=clustertypes[0], site=sites[0]),
         ])
         ])
 
 
-        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         cls.form_data = {
             'name': 'Cluster X',
             'name': 'Cluster X',
@@ -174,7 +174,7 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             VirtualMachine(name='Virtual Machine 3', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]),
             VirtualMachine(name='Virtual Machine 3', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]),
         ])
         ])
 
 
-        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         cls.form_data = {
             'cluster': clusters[1].pk,
             'cluster': clusters[1].pk,
@@ -256,7 +256,7 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
         )
         )
         VLAN.objects.bulk_create(vlans)
         VLAN.objects.bulk_create(vlans)
 
 
-        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         cls.form_data = {
             'virtual_machine': virtualmachines[1].pk,
             'virtual_machine': virtualmachines[1].pk,