Kaynağa Gözat

Merge pull request #5694 from netbox-community/develop

Release v2.10.4
Jeremy Stretch 5 yıl önce
ebeveyn
işleme
856d2e3176

+ 1 - 2
README.md

@@ -12,8 +12,7 @@ complete list of requirements, see `requirements.txt`. The code is available [on
 
 The complete documentation for NetBox can be found at [Read the Docs](https://netbox.readthedocs.io/en/stable/).
 
-Questions? Comments? Please start a [discussion on GitHub](https://github.com/netbox-community/netbox/discussions),
-or join us in the **#netbox** Slack channel on [NetworkToCode](https://networktocode.slack.com/)!
+Questions? Comments? Start by perusing our [GitHub discussions](https://github.com/netbox-community/netbox/discussions) for the topic you have in mind.
 
 ### Build Status
 

+ 1 - 1
docs/configuration/optional-settings.md

@@ -56,7 +56,7 @@ BASE_PATH = 'netbox/'
 
 Default: 900
 
-The number of seconds to cache entries will be retained before expiring.
+The number of seconds that cache entries will be retained before expiring.
 
 ---
 

+ 2 - 2
docs/configuration/required-settings.md

@@ -5,7 +5,7 @@
 This is a list of valid fully-qualified domain names (FQDNs) and/or IP addresses that can be used to reach the NetBox service. Usually this is the same as the hostname for the NetBox server, but can also be different; for example, when using a reverse proxy serving the NetBox website under a different FQDN than the hostname of the NetBox server. To help guard against [HTTP Host header attackes](https://docs.djangoproject.com/en/3.0/topics/security/#host-headers-virtual-hosting), NetBox will not permit access to the server via any other hostnames (or IPs).
 
 !!! note
-    This parameter must always be defined as a list or tuple, even if only value is provided.
+    This parameter must always be defined as a list or tuple, even if only a single value is provided.
 
 The value of this option is also used to set `CSRF_TRUSTED_ORIGINS`, which restricts POST requests to the same set of hosts (more about this [here](https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-CSRF_TRUSTED_ORIGINS)). Keep in mind that NetBox, by default, sets `USE_X_FORWARDED_HOST` to true, which means that if you're using a reverse proxy, it's the FQDN used to reach that reverse proxy which needs to be in this list (more about this [here](https://docs.djangoproject.com/en/stable/ref/settings/#allowed-hosts)).
 
@@ -101,7 +101,7 @@ REDIS = {
 
 If you are using [Redis Sentinel](https://redis.io/topics/sentinel) for high-availability purposes, there is minimal 
 configuration necessary to convert NetBox to recognize it. It requires the removal of the `HOST` and `PORT` keys from 
-above and the addition of two new keys.
+above and the addition of three new keys.
 
 * `SENTINELS`: List of tuples or tuple of tuples with each inner tuple containing the name or IP address 
 of the Redis server and port for each sentinel instance to connect to

+ 2 - 2
docs/installation/3-netbox.md

@@ -7,12 +7,12 @@ This section of the documentation discusses installing and configuring the NetBo
 Begin by installing all system packages required by NetBox and its dependencies.
 
 !!! note
-    NetBox v2.8.0 and later require Python 3.6, 3.7, or 3.8. This documentation assumes Python 3.6.
+    NetBox v2.8.0 and later require Python 3.6, 3.7, or 3.8.
 
 ### Ubuntu
 
 ```no-highlight
-sudo apt install -y python3.6 python3-pip python3-venv python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev zlib1g-dev
+sudo apt install -y python3 python3-pip python3-venv python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev zlib1g-dev
 ```
 
 ### CentOS

+ 4 - 0
docs/installation/index.md

@@ -11,6 +11,10 @@ The following sections detail how to set up a new instance of NetBox:
 5. [HTTP server](5-http-server.md)
 6. [LDAP authentication](6-ldap.md) (optional)
 
+The video below demonstrates the installation of NetBox v2.10.3 on Ubuntu 20.04 for your reference.
+
+<iframe width="560" height="315" src="https://www.youtube.com/embed/dFANGlxXEng" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
+
 ## Requirements
 
 | Dependency | Minimum Version |

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

@@ -1,5 +1,30 @@
 # NetBox v2.10
 
+## v2.10.4 (2021-01-26)
+
+### Enhancements
+
+* [#5542](https://github.com/netbox-community/netbox/issues/5542) - Show cable trace lengths in both meters and feet
+* [#5570](https://github.com/netbox-community/netbox/issues/5570) - Add "management only" filter widget for interfaces list
+* [#5586](https://github.com/netbox-community/netbox/issues/5586) - Allow filtering virtual chassis by name and master
+* [#5612](https://github.com/netbox-community/netbox/issues/5612) - Add GG45 and TERA port types, and CAT7a and CAT8 cable types
+* [#5678](https://github.com/netbox-community/netbox/issues/5678) - Show available type choices for all device component import forms
+
+### Bug Fixes
+
+* [#5232](https://github.com/netbox-community/netbox/issues/5232) - Correct swagger definition for ip_prefixes_available-ips_create API
+* [#5574](https://github.com/netbox-community/netbox/issues/5574) - Restrict the creation of device bay templates on non-parent device types
+* [#5584](https://github.com/netbox-community/netbox/issues/5584) - Restore power utilization panel under device view
+* [#5597](https://github.com/netbox-community/netbox/issues/5597) - Fix ordering devices by primary IP address
+* [#5603](https://github.com/netbox-community/netbox/issues/5603) - Fix display of white cables in trace view
+* [#5639](https://github.com/netbox-community/netbox/issues/5639) - Fix filtering connection lists by device name
+* [#5640](https://github.com/netbox-community/netbox/issues/5640) - Fix permissions assessment when adding VM interfaces in bulk
+* [#5648](https://github.com/netbox-community/netbox/issues/5648) - Include VC member interfaces on interfaces tab count when viewing VC master
+* [#5665](https://github.com/netbox-community/netbox/issues/5665) - Validate rack group is assigned to same site when creating a rack
+* [#5683](https://github.com/netbox-community/netbox/issues/5683) - Correct rack elevation displayed when viewing a reservation
+
+---
+
 ## v2.10.3 (2021-01-05)
 
 ### Bug Fixes

+ 12 - 0
netbox/dcim/choices.py

@@ -873,6 +873,10 @@ class PortTypeChoices(ChoiceSet):
     TYPE_8P6C = '8p6c'
     TYPE_8P4C = '8p4c'
     TYPE_8P2C = '8p2c'
+    TYPE_GG45 = 'gg45'
+    TYPE_TERA4P = 'tera-4p'
+    TYPE_TERA2P = 'tera-2p'
+    TYPE_TERA1P = 'tera-1p'
     TYPE_110_PUNCH = '110-punch'
     TYPE_BNC = 'bnc'
     TYPE_MRJ21 = 'mrj21'
@@ -898,6 +902,10 @@ class PortTypeChoices(ChoiceSet):
                 (TYPE_8P6C, '8P6C'),
                 (TYPE_8P4C, '8P4C'),
                 (TYPE_8P2C, '8P2C'),
+                (TYPE_GG45, 'GG45'),
+                (TYPE_TERA4P, 'TERA 4P'),
+                (TYPE_TERA2P, 'TERA 2P'),
+                (TYPE_TERA1P, 'TERA 1P'),
                 (TYPE_110_PUNCH, '110 Punch'),
                 (TYPE_BNC, 'BNC'),
                 (TYPE_MRJ21, 'MRJ21'),
@@ -936,6 +944,8 @@ class CableTypeChoices(ChoiceSet):
     TYPE_CAT6 = 'cat6'
     TYPE_CAT6A = 'cat6a'
     TYPE_CAT7 = 'cat7'
+    TYPE_CAT7A = 'cat7a'
+    TYPE_CAT8 = 'cat8'
     TYPE_DAC_ACTIVE = 'dac-active'
     TYPE_DAC_PASSIVE = 'dac-passive'
     TYPE_MRJ21_TRUNK = 'mrj21-trunk'
@@ -960,6 +970,8 @@ class CableTypeChoices(ChoiceSet):
                 (TYPE_CAT6, 'CAT6'),
                 (TYPE_CAT6A, 'CAT6a'),
                 (TYPE_CAT7, 'CAT7'),
+                (TYPE_CAT7A, 'CAT7a'),
+                (TYPE_CAT8, 'CAT8'),
                 (TYPE_DAC_ACTIVE, 'Direct Attach Copper (Active)'),
                 (TYPE_DAC_PASSIVE, 'Direct Attach Copper (Passive)'),
                 (TYPE_MRJ21_TRUNK, 'MRJ21 Trunk'),

+ 12 - 2
netbox/dcim/filters.py

@@ -1016,6 +1016,16 @@ class VirtualChassisFilterSet(BaseFilterSet):
         method='search',
         label='Search',
     )
+    master_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=Device.objects.all(),
+        label='Master (ID)',
+    )
+    master = django_filters.ModelMultipleChoiceFilter(
+        field_name='master__name',
+        queryset=Device.objects.all(),
+        to_field_name='name',
+        label='Master (name)',
+    )
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         field_name='master__site__region',
@@ -1055,7 +1065,7 @@ class VirtualChassisFilterSet(BaseFilterSet):
 
     class Meta:
         model = VirtualChassis
-        fields = ['id', 'domain']
+        fields = ['id', 'domain', 'name']
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -1142,7 +1152,7 @@ class ConnectionFilterSet:
     def filter_device(self, queryset, name, value):
         if not value:
             return queryset
-        return queryset.filter(device_id__in=value)
+        return queryset.filter(**{f'{name}__in': value})
 
 
 class ConsoleConnectionFilterSet(ConnectionFilterSet, BaseFilterSet):

+ 26 - 0
netbox/dcim/forms.py

@@ -2352,6 +2352,11 @@ class ConsolePortCSVForm(CSVModelForm):
         queryset=Device.objects.all(),
         to_field_name='name'
     )
+    type = CSVChoiceField(
+        choices=ConsolePortTypeChoices,
+        required=False,
+        help_text='Port type'
+    )
 
     class Meta:
         model = ConsolePort
@@ -2425,6 +2430,11 @@ class ConsoleServerPortCSVForm(CSVModelForm):
         queryset=Device.objects.all(),
         to_field_name='name'
     )
+    type = CSVChoiceField(
+        choices=ConsolePortTypeChoices,
+        required=False,
+        help_text='Port type'
+    )
 
     class Meta:
         model = ConsoleServerPort
@@ -2510,6 +2520,11 @@ class PowerPortCSVForm(CSVModelForm):
         queryset=Device.objects.all(),
         to_field_name='name'
     )
+    type = CSVChoiceField(
+        choices=PowerPortTypeChoices,
+        required=False,
+        help_text='Port type'
+    )
 
     class Meta:
         model = PowerPort
@@ -2630,6 +2645,11 @@ class PowerOutletCSVForm(CSVModelForm):
         queryset=Device.objects.all(),
         to_field_name='name'
     )
+    type = CSVChoiceField(
+        choices=PowerOutletTypeChoices,
+        required=False,
+        help_text='Outlet type'
+    )
     power_port = CSVModelChoiceField(
         queryset=PowerPort.objects.all(),
         required=False,
@@ -2687,6 +2707,12 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
             choices=BOOLEAN_WITH_BLANK_CHOICES
         )
     )
+    mgmt_only = forms.NullBooleanField(
+        required=False,
+        widget=StaticSelect2(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
     mac_address = forms.CharField(
         required=False,
         label='MAC address'

+ 6 - 0
netbox/dcim/models/device_component_templates.py

@@ -363,3 +363,9 @@ class DeviceBayTemplate(ComponentTemplateModel):
             name=self.name,
             label=self.label
         )
+
+    def clean(self):
+        if self.device_type and self.device_type.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT:
+            raise ValidationError(
+                f"Subdevice role of device type ({self.device_type}) must be set to \"parent\" to allow device bays."
+            )

+ 4 - 0
netbox/dcim/models/racks.py

@@ -299,6 +299,10 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
     def clean(self):
         super().clean()
 
+        # Validate group/site assignment
+        if self.site and self.group and self.group.site != self.site:
+            raise ValidationError(f"Assigned rack group must belong to parent site ({self.site}).")
+
         # Validate outer dimensions and unit
         if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit:
             raise ValidationError("Must specify a unit when setting an outer width/depth")

+ 2 - 0
netbox/dcim/tables/devices.py

@@ -129,6 +129,7 @@ class DeviceTable(BaseTable):
     )
     primary_ip = tables.Column(
         linkify=True,
+        order_by=('primary_ip6', 'primary_ip4'),
         verbose_name='IP Address'
     )
     primary_ip4 = tables.Column(
@@ -406,6 +407,7 @@ class BaseInterfaceTable(BaseTable):
 
 
 class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable):
+    mgmt_only = BooleanColumn()
     tags = TagColumn(
         url_name='dcim:interface_list'
     )

+ 4 - 1
netbox/dcim/tests/test_api.py

@@ -740,7 +740,10 @@ class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
     def setUpTestData(cls):
         manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
         devicetype = DeviceType.objects.create(
-            manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
+            manufacturer=manufacturer,
+            model='Device Type 1',
+            slug='device-type-1',
+            subdevice_role=SubdeviceRoleChoices.ROLE_PARENT
         )
 
         device_bay_templates = (

+ 14 - 3
netbox/dcim/tests/test_filters.py

@@ -2399,9 +2399,9 @@ class VirtualChassisTestCase(TestCase):
         Device.objects.bulk_create(devices)
 
         virtual_chassis = (
-            VirtualChassis(master=devices[0], domain='Domain 1'),
-            VirtualChassis(master=devices[2], domain='Domain 2'),
-            VirtualChassis(master=devices[4], domain='Domain 3'),
+            VirtualChassis(name='VC 1', master=devices[0], domain='Domain 1'),
+            VirtualChassis(name='VC 2', master=devices[2], domain='Domain 2'),
+            VirtualChassis(name='VC 3', master=devices[4], domain='Domain 3'),
         )
         VirtualChassis.objects.bulk_create(virtual_chassis)
 
@@ -2417,6 +2417,17 @@ class VirtualChassisTestCase(TestCase):
         params = {'domain': ['Domain 1', 'Domain 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_master(self):
+        masters = Device.objects.all()
+        params = {'master_id': [masters[0].pk, masters[2].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'master': [masters[0].name, masters[2].name]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_name(self):
+        params = {'name': ['VC 1', 'VC 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_region(self):
         regions = Region.objects.all()[:2]
         params = {'region_id': [regions[0].pk, regions[1].pk]}

+ 3 - 2
netbox/dcim/tests/test_views.py

@@ -396,6 +396,7 @@ manufacturer: Generic
 model: TEST-1000
 slug: test-1000
 u_height: 2
+subdevice_role: parent
 comments: test comment
 console-ports:
   - name: Console Port 1
@@ -831,8 +832,8 @@ class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
     def setUpTestData(cls):
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
         devicetypes = (
-            DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
-            DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
+            DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', subdevice_role=SubdeviceRoleChoices.ROLE_PARENT),
+            DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', subdevice_role=SubdeviceRoleChoices.ROLE_PARENT),
         )
         DeviceType.objects.bulk_create(devicetypes)
 

+ 2 - 3
netbox/generate_secret_key.py

@@ -1,7 +1,6 @@
 #!/usr/bin/env python
 # This script will generate a random 50-character string suitable for use as a SECRET_KEY.
-import random
+import secrets
 
 charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*(-_=+)'
-secure_random = random.SystemRandom()
-print(''.join(secure_random.sample(charset, 50)))
+print(''.join(secrets.choice(charset) for _ in range(50)))

+ 1 - 1
netbox/ipam/api/views.py

@@ -178,7 +178,7 @@ class PrefixViewSet(CustomFieldModelViewSet):
 
     @swagger_auto_schema(method='get', responses={200: serializers.AvailableIPSerializer(many=True)})
     @swagger_auto_schema(method='post', responses={201: serializers.AvailableIPSerializer(many=True)},
-                         request_body=serializers.AvailableIPSerializer(many=False))
+                         request_body=serializers.AvailableIPSerializer(many=True))
     @action(detail=True, url_path='available-ips', methods=['get', 'post'], queryset=IPAddress.objects.all())
     @advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
     def available_ips(self, request, pk=None):

+ 1 - 1
netbox/netbox/settings.py

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

+ 2 - 1
netbox/templates/dcim/cable_trace.html

@@ -69,7 +69,8 @@
                                     <h5>Total segments: {{ traced_path|length }}</h5>
                                     <h5>Total length:
                                         {% if total_length %}
-                                            {{ total_length|floatformat:"-2" }} Meters
+                                            {{ total_length|floatformat:"-2" }} Meters /
+                                            {{ total_length|meters_to_feet|floatformat:"-2" }} Feet
                                         {% else %}
                                             <span class="text-muted">N/A</span>
                                         {% endif %}

+ 4 - 4
netbox/templates/dcim/device.html

@@ -204,7 +204,7 @@
                             {% plugin_left_page object %}
                         </div>
                         <div class="col-md-6">
-                            {% if power_ports and poweroutlets %}
+                            {% if object.powerports.exists and object.poweroutlets.exists %}
                                 <div class="panel panel-default">
                                     <div class="panel-heading">
                                         <strong>Power Utilization</strong>
@@ -217,10 +217,10 @@
                                             <th>Available</th>
                                             <th>Utilization</th>
                                         </tr>
-                                        {% for pp in power_ports %}
-                                            {% with utilization=pp.get_power_draw powerfeed=pp.connected_endpoint %}
+                                        {% for powerport in object.powerports.all %}
+                                            {% with utilization=powerport.get_power_draw powerfeed=powerport.connected_endpoint %}
                                                 <tr>
-                                                    <td>{{ pp }}</td>
+                                                    <td>{{ powerport }}</td>
                                                     <td>{{ utilization.outlet_count }}</td>
                                                     <td>{{ utilization.allocated }}VA</td>
                                                     {% if powerfeed.available_power %}

+ 1 - 1
netbox/templates/dcim/device/base.html

@@ -90,7 +90,7 @@
         <li role="presentation" {% if active_tab == 'device' %} class="active"{% endif %}>
             <a href="{% url 'dcim:device' pk=object.pk %}">Device</a>
         </li>
-        {% with interface_count=object.interfaces.count %}
+        {% with interface_count=object.vc_interfaces.count %}
             {% if interface_count %}
                 <li role="presentation" {% if active_tab == 'interfaces' %} class="active"{% endif %}>
                     <a href="{% url 'dcim:device_interfaces' pk=object.pk %}">Interfaces {% badge interface_count %}</a>

+ 11 - 13
netbox/templates/dcim/rackreservation.html

@@ -127,22 +127,20 @@
         {% plugin_left_page object %}
 	</div>
     <div class="col-md-6">
-        {% with rack=object.rack %}
-            <div class="row" style="margin-bottom: 20px">
-                <div class="col-md-6 col-sm-6 col-xs-12">
-                    <div class="rack_header">
-                        <h4>Front</h4>
-                    </div>
-                    {% include 'dcim/inc/rack_elevation.html' with face='front' %}
+        <div class="row" style="margin-bottom: 20px">
+            <div class="col-md-6 col-sm-6 col-xs-12">
+                <div class="rack_header">
+                    <h4>Front</h4>
                 </div>
-                <div class="col-md-6 col-sm-6 col-xs-12">
-                    <div class="rack_header">
-                        <h4>Rear</h4>
-                    </div>
-                    {% include 'dcim/inc/rack_elevation.html' with face='rear' %}
+                {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='front' %}
+            </div>
+            <div class="col-md-6 col-sm-6 col-xs-12">
+                <div class="rack_header">
+                    <h4>Rear</h4>
                 </div>
+                {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='rear' %}
             </div>
-        {% endwith %}
+        </div>
         {% plugin_right_page object %}
     </div>
 </div>

+ 1 - 1
netbox/templates/dcim/trace/cable.html

@@ -1,6 +1,6 @@
 {% load helpers %}
 
-<div class="cable" style="border-left-color: #{{ cable.color|default:'606060' }}; {% if cable.status != 'connected' %} border-left-style: dashed{% endif %}">
+<div class="cable" style="border-left-color: #{% if cable.color == 'ffffff' %}909090; border-left-style: double; border-left-width: 6px;{% else %}{{ cable.color|default:'606060' }};{% endif %} {% if cable.status != 'connected' %} border-left-style: dashed{% endif %}">
     <strong>
         <a href="{% url 'dcim:cable' pk=cable.pk %}">
             {% if cable.label %}<code>{{ cable.label }}</code>{% else %}Cable #{{ cable.pk }}{% endif %}

+ 1 - 1
netbox/templates/virtualization/virtualmachine_list.html

@@ -7,7 +7,7 @@
                 <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Components <span class="caret"></span>
             </button>
             <ul class="dropdown-menu">
-                {% if perms.dcim.add_interface %}<li><a href="{% url 'virtualization:virtualmachine_bulk_add_vminterface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Interfaces</a></li>{% endif %}
+                {% if perms.virtualization.add_vminterface %}<li><a href="{% url 'virtualization:virtualmachine_bulk_add_vminterface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Interfaces</a></li>{% endif %}
             </ul>
         </div>
     {% endif %}

+ 30 - 21
netbox/utilities/custom_inspectors.py

@@ -28,29 +28,38 @@ class NetBoxSwaggerAutoSchema(SwaggerAutoSchema):
         serializer = super().get_request_serializer()
 
         if serializer is not None and self.method in self.implicit_body_methods:
-            properties = {}
-            for child_name, child in serializer.fields.items():
-                if isinstance(child, (ChoiceField, WritableNestedSerializer)):
-                    properties[child_name] = None
-                elif isinstance(child, ManyRelatedField) and isinstance(child.child_relation, SerializedPKRelatedField):
-                    properties[child_name] = None
-
-            if properties:
-                if type(serializer) not in self.writable_serializers:
-                    writable_name = 'Writable' + type(serializer).__name__
-                    meta_class = getattr(type(serializer), 'Meta', None)
-                    if meta_class:
-                        ref_name = 'Writable' + get_serializer_ref_name(serializer)
-                        writable_meta = type('Meta', (meta_class,), {'ref_name': ref_name})
-                        properties['Meta'] = writable_meta
-
-                    self.writable_serializers[type(serializer)] = type(writable_name, (type(serializer),), properties)
-
-                writable_class = self.writable_serializers[type(serializer)]
-                serializer = writable_class()
-
+            writable_class = self.get_writable_class(serializer)
+            if writable_class is not None:
+                if hasattr(serializer, 'child'):
+                    child_serializer = self.get_writable_class(serializer.child)
+                    serializer = writable_class(child=child_serializer)
+                else:
+                    serializer = writable_class()
         return serializer
 
+    def get_writable_class(self, serializer):
+        properties = {}
+        fields = {} if hasattr(serializer, 'child') else serializer.fields
+        for child_name, child in fields.items():
+            if isinstance(child, (ChoiceField, WritableNestedSerializer)):
+                properties[child_name] = None
+            elif isinstance(child, ManyRelatedField) and isinstance(child.child_relation, SerializedPKRelatedField):
+                properties[child_name] = None
+
+        if properties:
+            if type(serializer) not in self.writable_serializers:
+                writable_name = 'Writable' + type(serializer).__name__
+                meta_class = getattr(type(serializer), 'Meta', None)
+                if meta_class:
+                    ref_name = 'Writable' + get_serializer_ref_name(serializer)
+                    writable_meta = type('Meta', (meta_class,), {'ref_name': ref_name})
+                    properties['Meta'] = writable_meta
+
+                self.writable_serializers[type(serializer)] = type(writable_name, (type(serializer),), properties)
+
+            writable_class = self.writable_serializers[type(serializer)]
+            return writable_class
+
 
 class SerializedPKRelatedFieldInspector(FieldInspector):
     def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs):

+ 8 - 0
netbox/utilities/templatetags/helpers.py

@@ -220,6 +220,14 @@ def as_range(n):
     return range(n)
 
 
+@register.filter()
+def meters_to_feet(n):
+    """
+    Convert a length from meters to feet.
+    """
+    return float(n) * 3.28084
+
+
 #
 # Tags
 #

+ 3 - 0
netbox/virtualization/views.py

@@ -396,3 +396,6 @@ class VirtualMachineBulkAddInterfaceView(generic.BulkComponentCreateView):
     model_form = forms.VMInterfaceForm
     filterset = filters.VirtualMachineFilterSet
     table = tables.VirtualMachineTable
+
+    def get_required_permission(self):
+        return f'virtualization.add_vminterface'