Przeglądaj źródła

Merge pull request #6172 from netbox-community/develop

Release v2.10.10
Jeremy Stretch 4 lat temu
rodzic
commit
6c1c695616

+ 0 - 5
.github/ISSUE_TEMPLATE/bug_report.yaml

@@ -56,8 +56,3 @@ body:
       placeholder: "A TypeError exception was raised"
     validations:
       required: true
-  - type: markdown
-    attributes:
-      value: |
-        ### Additional information
-        You can use the space below to provide any additional information or to attach files.

+ 0 - 5
.github/ISSUE_TEMPLATE/documentation_change.yaml

@@ -33,8 +33,3 @@ body:
       description: "Describe the proposed changes and why they are necessary"
     validations:
       required: true
-  - type: markdown
-    attributes:
-      value: |
-        ### Additional information
-        You can use the space below to provide any additional information or to attach files.

+ 0 - 5
.github/ISSUE_TEMPLATE/feature_request.yaml

@@ -51,8 +51,3 @@ body:
       description: "List any new dependencies on external libraries or services that this
         new feature would introduce. For example, does the proposal require the installation
         of a new Python package? (Not all new features introduce new dependencies.)"
-  - type: markdown
-    attributes:
-      value: |
-        ### Additional information
-        You can use the space below to provide any additional information or to attach files.

+ 0 - 5
.github/ISSUE_TEMPLATE/housekeeping.yaml

@@ -20,8 +20,3 @@ body:
       description: "Please provide justification for the proposed change(s)."
     validations:
       required: true
-  - type: markdown
-    attributes:
-      value: |
-        ### Additional information
-        You can use the space below to provide any additional information or to attach files.

+ 0 - 30
.github/stale.yml

@@ -1,30 +0,0 @@
-# Configuration for Stale (https://github.com/apps/stale)
-
-# Number of days of inactivity before an issue becomes stale
-daysUntilStale: 45
-
-# Number of days of inactivity before a stale issue is closed
-daysUntilClose: 15
-
-# Issues with these labels will never be considered stale
-exemptLabels:
-  - "status: accepted"
-  - "status: blocked"
-  - "status: needs milestone"
-
-# Label to use when marking an issue as stale
-staleLabel: "pending closure"
-
-# Comment to post when marking an issue as stale. Set to `false` to disable
-markComment: >
-  This issue has been automatically marked as stale because it has not had
-  recent activity. It will be closed if no further activity occurs. NetBox
-  is governed by a small group of core maintainers which means not all opened
-  issues may receive direct feedback. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md).
-
-# Comment to post when closing a stale issue. Set to `false` to disable
-closeComment: >
-  This issue has been automatically closed due to lack of activity. In an
-  effort to reduce noise, please do not comment any further. Note that the
-  core maintainers may elect to reopen this issue at a later date if deemed
-  necessary.

+ 34 - 0
.github/workflows/stale.yml

@@ -0,0 +1,34 @@
+# close-stale-issues (https://github.com/marketplace/actions/close-stale-issues)
+name: 'Close stale issues/PRs'
+on:
+  schedule:
+    - cron: '0 4 * * *'
+
+jobs:
+  stale:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/stale@v3
+        with:
+          close-issue-message: >
+            This issue has been automatically closed due to lack of activity. In an
+            effort to reduce noise, please do not comment any further. Note that the
+            core maintainers may elect to reopen this issue at a later date if deemed
+            necessary.
+          close-pr-message: >
+            This PR has been automatically closed due to lack of activity.
+          days-before-stale: 45
+          days-before-close: 15
+          exempt-issue-labels: 'status: accepted,status: blocked,status: needs milestone'
+          remove-stale-when-updated: false
+          stale-issue-label: 'pending closure'
+          stale-issue-message: >
+            This issue has been automatically marked as stale because it has not had
+            recent activity. It will be closed if no further activity occurs. NetBox
+            is governed by a small group of core maintainers which means not all opened
+            issues may receive direct feedback. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md).
+          stale-pr-label: 'pending closure'
+          stale-pr-message: >
+            This PR has been automatically marked as stale because it has not had
+            recent activity. It will be closed automatically if no further action is
+            taken.

+ 22 - 0
docs/release-notes/version-2.10.md

@@ -1,5 +1,27 @@
 # NetBox v2.10
 
+## 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)
+
+---
+
 ## v2.10.9 (2021-04-12)
 
 ### Enhancements

+ 1 - 1
docs/rest-api/overview.md

@@ -387,7 +387,7 @@ curl -s -X GET http://netbox/api/ipam/ip-addresses/5618/ | jq '.'
 
 ### Creating a New Object
 
-To create a new object, make a `POST` request to the model's _list_ endpoint with JSON data pertaining to the object being created. Note that a REST API token is required for all write operations; see the [authentication documentation](../authentication/index.md) for more information. Also be sure to set the `Content-Type` HTTP header to `application/json`.
+To create a new object, make a `POST` request to the model's _list_ endpoint with JSON data pertaining to the object being created. Note that a REST API token is required for all write operations; see the [authentication documentation](authentication.md) for more information. Also be sure to set the `Content-Type` HTTP header to `application/json`.
 
 ```no-highlight
 curl -s -X POST \

+ 21 - 0
netbox/dcim/choices.py

@@ -314,6 +314,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', (
@@ -414,6 +418,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'),
+        )),
     )
 
 
@@ -507,8 +517,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', (
@@ -602,8 +615,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'),
         )),
     )
 
@@ -645,6 +662,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'
@@ -749,6 +767,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)'),
@@ -881,6 +900,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'
@@ -910,6 +930,7 @@ class PortTypeChoices(ChoiceSet):
                 (TYPE_TERA1P, 'TERA 1P'),
                 (TYPE_110_PUNCH, '110 Punch'),
                 (TYPE_BNC, 'BNC'),
+                (TYPE_F, 'F Connector'),
                 (TYPE_MRJ21, 'MRJ21'),
             ),
         ),

+ 34 - 3
netbox/dcim/filters.py

@@ -1,6 +1,5 @@
 import django_filters
 from django.contrib.auth.models import User
-from django.db.models import Count
 
 from extras.filters import CustomFieldModelFilterSet, LocalConfigContextFilterSet, CreatedUpdatedFilterSet
 from tenancy.filters import TenancyFilterSet
@@ -447,6 +446,10 @@ class PowerPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
 
 
 class PowerOutletTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
+    feed_leg = django_filters.MultipleChoiceFilter(
+        choices=PowerOutletFeedLegChoices,
+        null_value=None
+    )
 
     class Meta:
         model = PowerOutletTemplate
@@ -454,6 +457,10 @@ class PowerOutletTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
 
 
 class InterfaceTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
+    type = django_filters.MultipleChoiceFilter(
+        choices=InterfaceTypeChoices,
+        null_value=None
+    )
 
     class Meta:
         model = InterfaceTemplate
@@ -461,6 +468,10 @@ class InterfaceTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
 
 
 class FrontPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
+    type = django_filters.MultipleChoiceFilter(
+        choices=PortTypeChoices,
+        null_value=None
+    )
 
     class Meta:
         model = FrontPortTemplate
@@ -468,6 +479,10 @@ class FrontPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
 
 
 class RearPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
+    type = django_filters.MultipleChoiceFilter(
+        choices=PortTypeChoices,
+        null_value=None
+    )
 
     class Meta:
         model = RearPortTemplate
@@ -818,6 +833,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
@@ -918,6 +937,10 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati
 
 
 class FrontPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet):
+    type = django_filters.MultipleChoiceFilter(
+        choices=PortTypeChoices,
+        null_value=None
+    )
 
     class Meta:
         model = FrontPort
@@ -925,6 +948,10 @@ class FrontPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati
 
 
 class RearPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet):
+    type = django_filters.MultipleChoiceFilter(
+        choices=PortTypeChoices,
+        null_value=None
+    )
 
     class Meta:
         model = RearPort
@@ -1011,7 +1038,7 @@ class InventoryItemFilterSet(BaseFilterSet, DeviceComponentFilterSet):
         return queryset.filter(qs_filter)
 
 
-class VirtualChassisFilterSet(BaseFilterSet):
+class VirtualChassisFilterSet(BaseFilterSet, CustomFieldModelFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',
@@ -1078,7 +1105,7 @@ class VirtualChassisFilterSet(BaseFilterSet):
         return queryset.filter(qs_filter).distinct()
 
 
-class CableFilterSet(BaseFilterSet):
+class CableFilterSet(BaseFilterSet, CustomFieldModelFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',
@@ -1302,6 +1329,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

@@ -851,9 +851,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):
@@ -892,9 +891,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'}
@@ -946,9 +944,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):
@@ -987,9 +984,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]}
@@ -1824,9 +1820,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}
@@ -2063,9 +2058,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']}
@@ -2159,9 +2153,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]}
@@ -2732,9 +2725,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

@@ -162,6 +162,24 @@ class CustomField(models.Model):
     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

+ 12 - 2
netbox/extras/signals.py

@@ -4,7 +4,8 @@ from datetime import timedelta
 from cacheops.signals import cache_invalidated, cache_read
 from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
-from django.db.models.signals import m2m_changed, pre_delete
+from django.db import DEFAULT_DB_ALIAS
+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
@@ -52,7 +53,7 @@ def _handle_changed_object(request, sender, instance, **kwargs):
     # Housekeeping: 0.1% chance of clearing out expired ObjectChanges
     if settings.CHANGELOG_RETENTION and random.randint(1, 1000) == 1:
         cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION)
-        ObjectChange.objects.filter(time__lt=cutoff).delete()
+        ObjectChange.objects.filter(time__lt=cutoff)._raw_delete(using=DEFAULT_DB_ALIAS)
 
 
 def _handle_deleted_object(request, sender, instance, **kwargs):
@@ -85,6 +86,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.
@@ -93,6 +102,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 - 0
netbox/ipam/apps.py

@@ -4,3 +4,6 @@ from django.apps import AppConfig
 class IPAMConfig(AppConfig):
     name = "ipam"
     verbose_name = "IPAM"
+
+    def ready(self):
+        import ipam.signals

+ 21 - 0
netbox/ipam/signals.py

@@ -0,0 +1,21 @@
+from django.db.models.signals import pre_delete
+from django.dispatch import receiver
+
+from dcim.models import Device
+from virtualization.models import VirtualMachine
+from .models import IPAddress
+
+
+@receiver(pre_delete, sender=IPAddress)
+def clear_primary_ip(instance, **kwargs):
+    """
+    When an IPAddress is deleted, trigger save() on any Devices/VirtualMachines for which it
+    was a primary IP.
+    """
+    field_name = f'primary_ip{instance.family}'
+    device = Device.objects.filter(**{field_name: instance}).first()
+    if device:
+        device.save()
+    virtualmachine = VirtualMachine.objects.filter(**{field_name: instance}).first()
+    if virtualmachine:
+        virtualmachine.save()

+ 1 - 1
netbox/netbox/settings.py

@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
 # Environment setup
 #
 
-VERSION = '2.10.9'
+VERSION = '2.10.10'
 
 # Hostname
 HOSTNAME = platform.node()

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

+ 21 - 1
netbox/virtualization/forms.py

@@ -756,6 +756,26 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
                 # Add current site to VLANs query params
                 self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk)
                 self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk)
+        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):
@@ -765,7 +785,7 @@ class VMInterfaceBulkRenameForm(BulkRenameForm):
     )
 
 
-class VMInterfaceFilterForm(forms.Form):
+class VMInterfaceFilterForm(BootstrapMixin, forms.Form):
     model = VMInterface
     cluster_id = DynamicModelMultipleChoiceField(
         queryset=Cluster.objects.all(),