فهرست منبع

Merge pull request #5694 from netbox-community/develop

Release v2.10.4
Jeremy Stretch 5 سال پیش
والد
کامیت
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/).
 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
 ### Build Status
 
 

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

@@ -56,7 +56,7 @@ BASE_PATH = 'netbox/'
 
 
 Default: 900
 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).
 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
 !!! 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)).
 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 
 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 
 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 
 * `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
 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.
 Begin by installing all system packages required by NetBox and its dependencies.
 
 
 !!! note
 !!! 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
 ### Ubuntu
 
 
 ```no-highlight
 ```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
 ### 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)
 5. [HTTP server](5-http-server.md)
 6. [LDAP authentication](6-ldap.md) (optional)
 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
 ## Requirements
 
 
 | Dependency | Minimum Version |
 | Dependency | Minimum Version |

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

@@ -1,5 +1,30 @@
 # NetBox v2.10
 # 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)
 ## v2.10.3 (2021-01-05)
 
 
 ### Bug Fixes
 ### Bug Fixes

+ 12 - 0
netbox/dcim/choices.py

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

+ 12 - 2
netbox/dcim/filters.py

@@ -1016,6 +1016,16 @@ class VirtualChassisFilterSet(BaseFilterSet):
         method='search',
         method='search',
         label='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(
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         field_name='master__site__region',
         field_name='master__site__region',
@@ -1055,7 +1065,7 @@ class VirtualChassisFilterSet(BaseFilterSet):
 
 
     class Meta:
     class Meta:
         model = VirtualChassis
         model = VirtualChassis
-        fields = ['id', 'domain']
+        fields = ['id', 'domain', 'name']
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -1142,7 +1152,7 @@ class ConnectionFilterSet:
     def filter_device(self, queryset, name, value):
     def filter_device(self, queryset, name, value):
         if not value:
         if not value:
             return queryset
             return queryset
-        return queryset.filter(device_id__in=value)
+        return queryset.filter(**{f'{name}__in': value})
 
 
 
 
 class ConsoleConnectionFilterSet(ConnectionFilterSet, BaseFilterSet):
 class ConsoleConnectionFilterSet(ConnectionFilterSet, BaseFilterSet):

+ 26 - 0
netbox/dcim/forms.py

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

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

@@ -363,3 +363,9 @@ class DeviceBayTemplate(ComponentTemplateModel):
             name=self.name,
             name=self.name,
             label=self.label
             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):
     def clean(self):
         super().clean()
         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
         # Validate outer dimensions and unit
         if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_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")
             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(
     primary_ip = tables.Column(
         linkify=True,
         linkify=True,
+        order_by=('primary_ip6', 'primary_ip4'),
         verbose_name='IP Address'
         verbose_name='IP Address'
     )
     )
     primary_ip4 = tables.Column(
     primary_ip4 = tables.Column(
@@ -406,6 +407,7 @@ class BaseInterfaceTable(BaseTable):
 
 
 
 
 class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable):
 class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable):
+    mgmt_only = BooleanColumn()
     tags = TagColumn(
     tags = TagColumn(
         url_name='dcim:interface_list'
         url_name='dcim:interface_list'
     )
     )

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

@@ -740,7 +740,10 @@ class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
     def setUpTestData(cls):
     def setUpTestData(cls):
         manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
         manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
         devicetype = DeviceType.objects.create(
         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 = (
         device_bay_templates = (

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

@@ -2399,9 +2399,9 @@ class VirtualChassisTestCase(TestCase):
         Device.objects.bulk_create(devices)
         Device.objects.bulk_create(devices)
 
 
         virtual_chassis = (
         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)
         VirtualChassis.objects.bulk_create(virtual_chassis)
 
 
@@ -2417,6 +2417,17 @@ class VirtualChassisTestCase(TestCase):
         params = {'domain': ['Domain 1', 'Domain 2']}
         params = {'domain': ['Domain 1', 'Domain 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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):
     def test_region(self):
         regions = Region.objects.all()[:2]
         regions = Region.objects.all()[:2]
         params = {'region_id': [regions[0].pk, regions[1].pk]}
         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
 model: TEST-1000
 slug: test-1000
 slug: test-1000
 u_height: 2
 u_height: 2
+subdevice_role: parent
 comments: test comment
 comments: test comment
 console-ports:
 console-ports:
   - name: Console Port 1
   - name: Console Port 1
@@ -831,8 +832,8 @@ class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
     def setUpTestData(cls):
     def setUpTestData(cls):
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
         devicetypes = (
         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)
         DeviceType.objects.bulk_create(devicetypes)
 
 

+ 2 - 3
netbox/generate_secret_key.py

@@ -1,7 +1,6 @@
 #!/usr/bin/env python
 #!/usr/bin/env python
 # This script will generate a random 50-character string suitable for use as a SECRET_KEY.
 # This script will generate a random 50-character string suitable for use as a SECRET_KEY.
-import random
+import secrets
 
 
 charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*(-_=+)'
 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='get', responses={200: serializers.AvailableIPSerializer(many=True)})
     @swagger_auto_schema(method='post', responses={201: 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())
     @action(detail=True, url_path='available-ips', methods=['get', 'post'], queryset=IPAddress.objects.all())
     @advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
     @advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
     def available_ips(self, request, pk=None):
     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
 # Environment setup
 #
 #
 
 
-VERSION = '2.10.3'
+VERSION = '2.10.4'
 
 
 # Hostname
 # Hostname
 HOSTNAME = platform.node()
 HOSTNAME = platform.node()

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

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

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

@@ -204,7 +204,7 @@
                             {% plugin_left_page object %}
                             {% plugin_left_page object %}
                         </div>
                         </div>
                         <div class="col-md-6">
                         <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 panel-default">
                                     <div class="panel-heading">
                                     <div class="panel-heading">
                                         <strong>Power Utilization</strong>
                                         <strong>Power Utilization</strong>
@@ -217,10 +217,10 @@
                                             <th>Available</th>
                                             <th>Available</th>
                                             <th>Utilization</th>
                                             <th>Utilization</th>
                                         </tr>
                                         </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>
                                                 <tr>
-                                                    <td>{{ pp }}</td>
+                                                    <td>{{ powerport }}</td>
                                                     <td>{{ utilization.outlet_count }}</td>
                                                     <td>{{ utilization.outlet_count }}</td>
                                                     <td>{{ utilization.allocated }}VA</td>
                                                     <td>{{ utilization.allocated }}VA</td>
                                                     {% if powerfeed.available_power %}
                                                     {% 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 %}>
         <li role="presentation" {% if active_tab == 'device' %} class="active"{% endif %}>
             <a href="{% url 'dcim:device' pk=object.pk %}">Device</a>
             <a href="{% url 'dcim:device' pk=object.pk %}">Device</a>
         </li>
         </li>
-        {% with interface_count=object.interfaces.count %}
+        {% with interface_count=object.vc_interfaces.count %}
             {% if interface_count %}
             {% if interface_count %}
                 <li role="presentation" {% if active_tab == 'interfaces' %} class="active"{% endif %}>
                 <li role="presentation" {% if active_tab == 'interfaces' %} class="active"{% endif %}>
                     <a href="{% url 'dcim:device_interfaces' pk=object.pk %}">Interfaces {% badge interface_count %}</a>
                     <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 %}
         {% plugin_left_page object %}
 	</div>
 	</div>
     <div class="col-md-6">
     <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>
-                <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>
                 </div>
+                {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='rear' %}
             </div>
             </div>
-        {% endwith %}
+        </div>
         {% plugin_right_page object %}
         {% plugin_right_page object %}
     </div>
     </div>
 </div>
 </div>

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

@@ -1,6 +1,6 @@
 {% load helpers %}
 {% 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>
     <strong>
         <a href="{% url 'dcim:cable' pk=cable.pk %}">
         <a href="{% url 'dcim:cable' pk=cable.pk %}">
             {% if cable.label %}<code>{{ cable.label }}</code>{% else %}Cable #{{ cable.pk }}{% endif %}
             {% 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>
                 <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Components <span class="caret"></span>
             </button>
             </button>
             <ul class="dropdown-menu">
             <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>
             </ul>
         </div>
         </div>
     {% endif %}
     {% endif %}

+ 30 - 21
netbox/utilities/custom_inspectors.py

@@ -28,29 +28,38 @@ class NetBoxSwaggerAutoSchema(SwaggerAutoSchema):
         serializer = super().get_request_serializer()
         serializer = super().get_request_serializer()
 
 
         if serializer is not None and self.method in self.implicit_body_methods:
         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
         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):
 class SerializedPKRelatedFieldInspector(FieldInspector):
     def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs):
     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)
     return range(n)
 
 
 
 
+@register.filter()
+def meters_to_feet(n):
+    """
+    Convert a length from meters to feet.
+    """
+    return float(n) * 3.28084
+
+
 #
 #
 # Tags
 # Tags
 #
 #

+ 3 - 0
netbox/virtualization/views.py

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