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

Merge branch 'develop' into feature

jeremystretch 4 лет назад
Родитель
Сommit
ef78a7d41b

+ 12 - 1
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)
 
 ---
 

+ 21 - 0
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'),
             ),
         ),

+ 32 - 0
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:

+ 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)
 
     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}

+ 18 - 0
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

+ 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.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)
 
 

+ 27 - 0
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):
 

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

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

+ 28 - 6
netbox/virtualization/forms.py

@@ -789,14 +789,36 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
 
     def __init__(self, *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):