Преглед изворни кода

Merge pull request #6172 from netbox-community/develop

Release v2.10.10
Jeremy Stretch пре 4 година
родитељ
комит
6c1c695616

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

@@ -56,8 +56,3 @@ body:
       placeholder: "A TypeError exception was raised"
       placeholder: "A TypeError exception was raised"
     validations:
     validations:
       required: true
       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"
       description: "Describe the proposed changes and why they are necessary"
     validations:
     validations:
       required: true
       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
       description: "List any new dependencies on external libraries or services that this
         new feature would introduce. For example, does the proposal require the installation
         new feature would introduce. For example, does the proposal require the installation
         of a new Python package? (Not all new features introduce new dependencies.)"
         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)."
       description: "Please provide justification for the proposed change(s)."
     validations:
     validations:
       required: true
       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
 # 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)
 ## v2.10.9 (2021-04-12)
 
 
 ### Enhancements
 ### 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
 ### 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
 ```no-highlight
 curl -s -X POST \
 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_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', (
@@ -414,6 +418,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'),
+        )),
     )
     )
 
 
 
 
@@ -507,8 +517,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', (
@@ -602,8 +615,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'),
         )),
         )),
     )
     )
 
 
@@ -645,6 +662,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'
@@ -749,6 +767,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)'),
@@ -881,6 +900,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'
@@ -910,6 +930,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'),
             ),
             ),
         ),
         ),

+ 34 - 3
netbox/dcim/filters.py

@@ -1,6 +1,5 @@
 import django_filters
 import django_filters
 from django.contrib.auth.models import User
 from django.contrib.auth.models import User
-from django.db.models import Count
 
 
 from extras.filters import CustomFieldModelFilterSet, LocalConfigContextFilterSet, CreatedUpdatedFilterSet
 from extras.filters import CustomFieldModelFilterSet, LocalConfigContextFilterSet, CreatedUpdatedFilterSet
 from tenancy.filters import TenancyFilterSet
 from tenancy.filters import TenancyFilterSet
@@ -447,6 +446,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
@@ -454,6 +457,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
@@ -461,6 +468,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
@@ -468,6 +479,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
@@ -818,6 +833,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
@@ -918,6 +937,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
@@ -925,6 +948,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
@@ -1011,7 +1038,7 @@ class InventoryItemFilterSet(BaseFilterSet, DeviceComponentFilterSet):
         return queryset.filter(qs_filter)
         return queryset.filter(qs_filter)
 
 
 
 
-class VirtualChassisFilterSet(BaseFilterSet):
+class VirtualChassisFilterSet(BaseFilterSet, CustomFieldModelFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
@@ -1078,7 +1105,7 @@ class VirtualChassisFilterSet(BaseFilterSet):
         return queryset.filter(qs_filter).distinct()
         return queryset.filter(qs_filter).distinct()
 
 
 
 
-class CableFilterSet(BaseFilterSet):
+class CableFilterSet(BaseFilterSet, CustomFieldModelFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
@@ -1302,6 +1329,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

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

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

+ 12 - 2
netbox/extras/signals.py

@@ -4,7 +4,8 @@ from datetime import timedelta
 from cacheops.signals import cache_invalidated, cache_read
 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.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.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
@@ -52,7 +53,7 @@ def _handle_changed_object(request, sender, instance, **kwargs):
     # Housekeeping: 0.1% chance of clearing out expired ObjectChanges
     # Housekeeping: 0.1% chance of clearing out expired ObjectChanges
     if settings.CHANGELOG_RETENTION and random.randint(1, 1000) == 1:
     if settings.CHANGELOG_RETENTION and random.randint(1, 1000) == 1:
         cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION)
         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):
 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))
         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.
@@ -93,6 +102,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)
 
 
 
 

+ 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 - 0
netbox/ipam/apps.py

@@ -4,3 +4,6 @@ from django.apps import AppConfig
 class IPAMConfig(AppConfig):
 class IPAMConfig(AppConfig):
     name = "ipam"
     name = "ipam"
     verbose_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
 # Environment setup
 #
 #
 
 
-VERSION = '2.10.9'
+VERSION = '2.10.10'
 
 
 # Hostname
 # Hostname
 HOSTNAME = platform.node()
 HOSTNAME = platform.node()

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

@@ -66,9 +66,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 %}

+ 21 - 1
netbox/virtualization/forms.py

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