diff --git a/docs/development/models.md b/docs/development/models.md index 1e9b17d22..7dec2cb61 100644 --- a/docs/development/models.md +++ b/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. +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 * [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 * [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 -* 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 * Nesting - These models can be nested recursively to create a hierarchy diff --git a/docs/models/circuits/providernetwork.md b/docs/models/circuits/providernetwork.md index 970a9f8a8..f5a428e96 100644 --- a/docs/models/circuits/providernetwork.md +++ b/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. diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index 69db03724..d356fc235 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -1,13 +1,24 @@ # 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 * [#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 * [#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 +* [#6162](https://github.com/netbox-community/netbox/issues/6162) - Fix choice field filters (multiple models) --- diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 0e7bad118..77bb1d04b 100644 --- a/docs/release-notes/version-2.11.md +++ b/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) * [#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 +* [#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 --- diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index 62e3e3a22..b6401b2fa 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -6,7 +6,7 @@ from django.urls import reverse from circuits.choices import * from circuits.models import * 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): @@ -21,7 +21,7 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase): 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 = { 'name': 'Provider X', @@ -106,7 +106,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): 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 = { 'cid': 'Circuit X', @@ -157,7 +157,7 @@ class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase): ProviderNetwork(name='Provider Network 3', provider=providers[0]), ]) - tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'name': 'Provider Network X', diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index eac36b1ad..2483c8d12 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -337,6 +337,10 @@ class PowerPortTypeChoices(ChoiceSet): TYPE_USB_MICRO_B = 'usb-micro-b' TYPE_USB_3_B = 'usb-3-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 = ( ('IEC 60320', ( @@ -437,6 +441,12 @@ class PowerPortTypeChoices(ChoiceSet): (TYPE_USB_3_B, 'USB 3.0 Type 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_MICROB = 'usb-micro-b' TYPE_USB_C = 'usb-c' + # Direct current (DC) + TYPE_DC = 'dc-terminal' # Proprietary TYPE_HDOT_CX = 'hdot-cx' + TYPE_SAF_D_GRID = 'saf-d-grid' CHOICES = ( ('IEC 60320', ( @@ -625,8 +638,12 @@ class PowerOutletTypeChoices(ChoiceSet): (TYPE_USB_MICROB, 'USB Micro B'), (TYPE_USB_C, 'USB Type C'), )), + ('DC', ( + (TYPE_DC, 'DC Terminal'), + )), ('Proprietary', ( (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_X2 = '10gbase-x-x2' TYPE_25GE_SFP28 = '25gbase-x-sfp28' + TYPE_50GE_SFP56 = '50gbase-x-sfp56' TYPE_40GE_QSFP_PLUS = '40gbase-x-qsfpp' TYPE_50GE_QSFP28 = '50gbase-x-sfp28' TYPE_100GE_CFP = '100gbase-x-cfp' @@ -772,6 +790,7 @@ class InterfaceTypeChoices(ChoiceSet): (TYPE_10GE_XENPAK, 'XENPAK (10GE)'), (TYPE_10GE_X2, 'X2 (10GE)'), (TYPE_25GE_SFP28, 'SFP28 (25GE)'), + (TYPE_50GE_SFP56, 'SFP56 (50GE)'), (TYPE_40GE_QSFP_PLUS, 'QSFP+ (40GE)'), (TYPE_50GE_QSFP28, 'QSFP28 (50GE)'), (TYPE_100GE_CFP, 'CFP (100GE)'), @@ -904,6 +923,7 @@ class PortTypeChoices(ChoiceSet): TYPE_TERA1P = 'tera-1p' TYPE_110_PUNCH = '110-punch' TYPE_BNC = 'bnc' + TYPE_F = 'f' TYPE_MRJ21 = 'mrj21' TYPE_ST = 'st' TYPE_SC = 'sc' @@ -933,6 +953,7 @@ class PortTypeChoices(ChoiceSet): (TYPE_TERA1P, 'TERA 1P'), (TYPE_110_PUNCH, '110 Punch'), (TYPE_BNC, 'BNC'), + (TYPE_F, 'F Connector'), (TYPE_MRJ21, 'MRJ21'), ), ), diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 202a50cb4..7ae016dc4 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -498,6 +498,10 @@ class PowerPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet): class PowerOutletTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet): + feed_leg = django_filters.MultipleChoiceFilter( + choices=PowerOutletFeedLegChoices, + null_value=None + ) class Meta: model = PowerOutletTemplate @@ -505,6 +509,10 @@ class PowerOutletTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet): class InterfaceTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet): + type = django_filters.MultipleChoiceFilter( + choices=InterfaceTypeChoices, + null_value=None + ) class Meta: model = InterfaceTemplate @@ -512,6 +520,10 @@ class InterfaceTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet): class FrontPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet): + type = django_filters.MultipleChoiceFilter( + choices=PortTypeChoices, + null_value=None + ) class Meta: model = FrontPortTemplate @@ -519,6 +531,10 @@ class FrontPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet): class RearPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet): + type = django_filters.MultipleChoiceFilter( + choices=PortTypeChoices, + null_value=None + ) class Meta: model = RearPortTemplate @@ -895,6 +911,10 @@ class PowerOutletFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTermina choices=PowerOutletTypeChoices, null_value=None ) + feed_leg = django_filters.MultipleChoiceFilter( + choices=PowerOutletFeedLegChoices, + null_value=None + ) class Meta: model = PowerOutlet @@ -1000,6 +1020,10 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati class FrontPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet): + type = django_filters.MultipleChoiceFilter( + choices=PortTypeChoices, + null_value=None + ) class Meta: model = FrontPort @@ -1007,6 +1031,10 @@ class FrontPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati class RearPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet): + type = django_filters.MultipleChoiceFilter( + choices=PortTypeChoices, + null_value=None + ) class Meta: model = RearPort @@ -1423,6 +1451,10 @@ class PowerFeedFilterSet( queryset=Rack.objects.all(), label='Rack (ID)', ) + status = django_filters.MultipleChoiceFilter( + choices=PowerFeedStatusChoices, + null_value=None + ) tag = TagFilter() class Meta: diff --git a/netbox/dcim/tests/test_filters.py b/netbox/dcim/tests/test_filters.py index a68733f86..632a10c46 100644 --- a/netbox/dcim/tests/test_filters.py +++ b/netbox/dcim/tests/test_filters.py @@ -940,9 +940,8 @@ class PowerOutletTemplateTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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): @@ -981,9 +980,8 @@ class InterfaceTemplateTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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): params = {'mgmt_only': 'true'} @@ -1035,9 +1033,8 @@ class FrontPortTemplateTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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): @@ -1076,9 +1073,8 @@ class RearPortTemplateTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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): params = {'positions': [1, 2]} @@ -1985,9 +1981,8 @@ class PowerOutletTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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): params = {'connected': True} @@ -2284,9 +2279,8 @@ class FrontPortTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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): params = {'description': ['First', 'Second']} @@ -2396,9 +2390,8 @@ class RearPortTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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): params = {'positions': [1, 2]} @@ -3045,9 +3038,8 @@ class PowerFeedTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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): params = {'type': PowerFeedTypeChoices.TYPE_PRIMARY} diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index daba2a639..5da1fcb5b 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -12,7 +12,7 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * 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): @@ -109,7 +109,7 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): 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 = { 'name': 'Site X', @@ -242,7 +242,7 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase): 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 = { 'rack': rack.pk, @@ -298,7 +298,7 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase): Rack(name='Rack 3', site=sites[0]), )) - tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'name': 'Rack X', @@ -413,7 +413,7 @@ class DeviceTypeTestCase( 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 = { '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]), ]) - tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'device_type': devicetypes[1].pk, @@ -1201,7 +1201,7 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase): ConsolePort(device=device, name='Console Port 3'), ]) - tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'device': device.pk, @@ -1259,7 +1259,7 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): ConsoleServerPort(device=device, name='Console Server Port 3'), ]) - tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'device': device.pk, @@ -1315,7 +1315,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): PowerPort(device=device, name='Power Port 3'), ]) - tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'device': device.pk, @@ -1383,7 +1383,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): 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 = { 'device': device.pk, @@ -1452,7 +1452,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): ) VLAN.objects.bulk_create(vlans) - tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'device': device.pk, @@ -1539,7 +1539,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): 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 = { 'device': device.pk, @@ -1600,7 +1600,7 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase): RearPort(device=device, name='Rear Port 3'), ]) - tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'device': device.pk, @@ -1661,7 +1661,7 @@ class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase): DeviceBay(device=device, name='Device Bay 3'), ]) - tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { '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 3') - tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { '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[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) cls.form_data = { @@ -1918,7 +1918,7 @@ class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase): 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 = { 'site': sites[1].pk, @@ -1966,7 +1966,7 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase): 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 = { 'name': 'Power Feed X', diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 53e842ff9..5da50e0db 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1172,14 +1172,20 @@ class DeviceRoleBulkImportView(generic.BulkImportView): 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 table = tables.DeviceRoleTable form = forms.DeviceRoleBulkEditForm 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 diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 8b97877a4..2360da739 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -114,6 +114,24 @@ class CustomField(BigIDModel): def __str__(self): 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): """ Delete custom field data which is no longer relevant (either because the CustomField is diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 41bc345e2..b3bc7f535 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -391,6 +391,8 @@ class ImageAttachment(BigIDModel): # Journal entries # + +@extras_features('webhooks') class JournalEntry(ChangeLoggedModel): """ A historical remark concerning an object; collectively, these form an object's journal. The journal is used to diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index 1c4f4953e..2191c4397 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -5,7 +5,7 @@ from cacheops.signals import cache_invalidated, cache_read from django.conf import settings from django.contrib.contenttypes.models import ContentType 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_prometheus.models import model_deletes, model_inserts, model_updates 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)) +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): """ 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) +post_save.connect(handle_cf_renamed, sender=CustomField) pre_delete.connect(handle_cf_deleted, sender=CustomField) diff --git a/netbox/extras/tests/test_changelog.py b/netbox/extras/tests/test_changelog.py index 91868832c..33251473f 100644 --- a/netbox/extras/tests/test_changelog.py +++ b/netbox/extras/tests/test_changelog.py @@ -7,7 +7,7 @@ from dcim.models import Site from extras.choices import * from extras.models import CustomField, ObjectChange, Tag 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 @@ -38,7 +38,7 @@ class ChangeLogViewTest(ModelViewTestCase): cf_select.content_types.set([ct]) def test_create_object(self): - tags = self.create_tags('Tag 1', 'Tag 2') + tags = create_tags('Tag 1', 'Tag 2') form_data = { 'name': 'Site 1', 'slug': 'site-1', @@ -72,7 +72,7 @@ class ChangeLogViewTest(ModelViewTestCase): def test_update_object(self): site = Site(name='Site 1', slug='site-1') 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') form_data = { @@ -116,7 +116,7 @@ class ChangeLogViewTest(ModelViewTestCase): } ) site.save() - self.create_tags('Tag 1', 'Tag 2') + create_tags('Tag 1', 'Tag 2') site.tags.set('Tag 1', 'Tag 2') request = { diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 4f7a67676..d1725ac9d 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -91,6 +91,33 @@ class CustomFieldTest(TestCase): # Delete the custom field 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): diff --git a/netbox/extras/tests/test_tags.py b/netbox/extras/tests/test_tags.py index 39aae49dc..b827b28d9 100644 --- a/netbox/extras/tests/test_tags.py +++ b/netbox/extras/tests/test_tags.py @@ -2,7 +2,7 @@ from django.urls import reverse from rest_framework import status from dcim.models import Site -from utilities.testing import APITestCase +from utilities.testing import APITestCase, create_tags 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). """ def test_create_tagged_item(self): - tags = self.create_tags("Foo", "Bar", "Baz") + tags = create_tags("Foo", "Bar", "Baz") data = { 'name': 'Test Site', 'slug': 'test-site', @@ -37,7 +37,7 @@ class TaggedItemTest(APITestCase): slug='test-site' ) site.tags.add("Foo", "Bar", "Baz") - self.create_tags("New Tag") + create_tags("New Tag") data = { 'tags': [ {"name": "Foo"}, diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 2704ac1ca..057f9383f 100644 --- a/netbox/ipam/tests/test_views.py +++ b/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.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF from tenancy.models import Tenant -from utilities.testing import ViewTestCases +from utilities.testing import ViewTestCases, create_tags class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase): @@ -27,7 +27,7 @@ class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase): VRF(name='VRF 3', rd='65000:3'), ]) - tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'name': 'VRF X', @@ -64,7 +64,7 @@ class RouteTargetTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) Tenant.objects.bulk_create(tenants) - tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + tags = create_tags('Alpha', 'Bravo', 'Charlie') route_targets = ( 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]), ]) - tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { '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]), ]) - tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { '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]), ]) - tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { '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]), ]) - tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'site': sites[1].pk, @@ -434,7 +434,7 @@ class ServiceTestCase( 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 = { 'device': device.pk, diff --git a/netbox/templates/extras/report_result.html b/netbox/templates/extras/report_result.html index f75ae18b5..e64154979 100644 --- a/netbox/templates/extras/report_result.html +++ b/netbox/templates/extras/report_result.html @@ -73,9 +73,11 @@ {{ obj }} {% elif obj %} {{ obj }} + {% else %} + — {% endif %} -