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

Merge pull request #4528 from netbox-community/develop

Release v2.8.1
Jeremy Stretch 5 лет назад
Родитель
Сommit
a77d1e502c
38 измененных файлов с 958 добавлено и 637 удалено
  1. 1 1
      docs/configuration/optional-settings.md
  2. 4 2
      docs/installation/3-netbox.md
  3. 3 0
      docs/installation/upgrading.md
  4. BIN
      docs/media/installation/netbox_application_stack.png
  5. 1 1
      docs/models/dcim/rackgroup.md
  6. 2 0
      docs/models/tenancy/tenantgroup.md
  7. 39 1
      docs/release-notes/version-2.8.md
  8. 2 4
      netbox/dcim/api/serializers.py
  9. 1 1
      netbox/dcim/api/views.py
  10. 2 0
      netbox/dcim/choices.py
  11. 9 0
      netbox/dcim/exceptions.py
  12. 397 432
      netbox/dcim/forms.py
  13. 18 0
      netbox/dcim/migrations/0105_interface_name_collation.py
  14. 42 27
      netbox/dcim/models/__init__.py
  15. 36 10
      netbox/dcim/models/device_components.py
  16. 31 20
      netbox/dcim/signals.py
  17. 5 0
      netbox/dcim/tests/test_api.py
  18. 145 40
      netbox/dcim/tests/test_models.py
  19. 6 0
      netbox/dcim/tests/test_natural_ordering.py
  20. 1 1
      netbox/dcim/urls.py
  21. 37 9
      netbox/dcim/views.py
  22. 10 4
      netbox/ipam/api/serializers.py
  23. 13 0
      netbox/ipam/api/views.py
  24. 4 1
      netbox/ipam/tests/test_api.py
  25. 8 14
      netbox/netbox/configuration.example.py
  26. 13 8
      netbox/netbox/settings.py
  27. 45 1
      netbox/templates/dcim/cable_trace.html
  28. 1 0
      netbox/templates/dcim/device_list.html
  29. 1 7
      netbox/templates/dcim/devicetype.html
  30. 1 1
      netbox/utilities/auth_backends.py
  31. 1 1
      netbox/utilities/custom_inspectors.py
  32. 14 0
      netbox/utilities/forms.py
  33. 1 1
      netbox/utilities/ordering.py
  34. 9 0
      netbox/utilities/query_functions.py
  35. 19 16
      netbox/utilities/tests/test_ordering.py
  36. 24 17
      netbox/utilities/views.py
  37. 11 16
      netbox/virtualization/forms.py
  38. 1 1
      netbox/virtualization/views.py

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

@@ -344,7 +344,7 @@ When determining the primary IP address for a device, IPv6 is preferred over IPv
 
 Default: `False`
 
-NetBox can be configured to support remote user authentication by inferring user authentication from an HTTP header set by the HTTP reverse proxy (e.g. nginx or Apache). Set this to `True` to enable this functionality. (Local authenitcation will still take effect as a fallback.)
+NetBox can be configured to support remote user authentication by inferring user authentication from an HTTP header set by the HTTP reverse proxy (e.g. nginx or Apache). Set this to `True` to enable this functionality. (Local authentication will still take effect as a fallback.)
 
 ---
 

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

@@ -1,13 +1,15 @@
 # NetBox Installation
 
-This section of the documentation discusses installing and configuring the NetBox application. Begin by installing all system packages required by NetBox and its dependencies:
+This section of the documentation discusses installing and configuring the NetBox application itself.
 
 ## Install System Packages
 
+Begin by installing all system packages required by NetBox and its dependencies. Note that beginning with NetBox v2.8, Python 3.6 or later is required.
+
 ### Ubuntu
 
 ```no-highlight
-# apt-get install -y python3 python3-pip python3-venv python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev zlib1g-dev
+# apt-get install -y python3.6 python3-pip python3-venv python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev zlib1g-dev
 ```
 
 ### CentOS

+ 3 - 0
docs/installation/upgrading.md

@@ -4,6 +4,9 @@
 
 Prior to upgrading your NetBox instance, be sure to carefully review all [release notes](../../release-notes/) that have been published since your current version was released. Although the upgrade process typically does not involve additional work, certain releases may introduce breaking or backward-incompatible changes. These are called out in the release notes under the version in which the change went into effect.
 
+!!! note
+    Beginning with version 2.8, NetBox requires Python 3.6 or later.
+
 ## Install the Latest Code
 
 As with the initial installation, you can upgrade NetBox by either downloading the latest release package or by cloning the `master` branch of the git repository. 

BIN
docs/media/installation/netbox_application_stack.png


+ 1 - 1
docs/models/dcim/rackgroup.md

@@ -2,6 +2,6 @@
 
 Racks can be arranged into groups. As with sites, how you choose to designate rack groups will depend on the nature of your organization. For example, if each site represents a campus, each group might represent a building within a campus. If each site represents a building, each rack group might equate to a floor or room.
 
-Each rack group must be assigned to a parent site. Hierarchical recursion of rack groups is not currently supported.
+Each rack group must be assigned to a parent site, and rack groups may optionally be nested to achieve a multi-level hierarchy.
 
 The name and facility ID of each rack within a group must be unique. (Racks not assigned to the same rack group may have identical names and/or facility IDs.)

+ 2 - 0
docs/models/tenancy/tenantgroup.md

@@ -1,3 +1,5 @@
 # Tenant Groups
 
 Tenants can be organized by custom groups. For instance, you might create one group called "Customers" and one called "Acquisitions." The assignment of tenants to groups is optional.
+
+Tenant groups may be nested to achieve a multi-level hierarchy. For example, you might have a group called "Customers" containing subgroups of individual tenants grouped by product or account team.

+ 39 - 1
docs/release-notes/version-2.8.md

@@ -1,7 +1,45 @@
 # NetBox v2.8
 
+## v2.8.1 (2020-04-23)
+
+### Notes
+
+In accordance with the fix in [#4459](https://github.com/netbox-community/netbox/issues/4459), users that are experiencing invalid nested data with
+regions, rack groups, or tenant groups can perform a one-time operation using the NetBox shell to rebuild the correct nested relationships after upgrading:
+
+```text
+$ python netbox/manage.py nbshell
+### NetBox interactive shell (localhost)
+### Python 3.6.4 | Django 3.0.5 | NetBox 2.8.1
+### lsmodels() will show available models. Use help(<model>) for more info.
+>>> Region.objects.rebuild()
+>>> RackGroup.objects.rebuild()
+>>> TenantGroup.objects.rebuild()
+```
+
+### Enhancements
+
+* [#4464](https://github.com/netbox-community/netbox/issues/4464) - Add 21-inch rack width (ETSI)
+
+### Bug Fixes
+
+* [#2994](https://github.com/netbox-community/netbox/issues/2994) - Prevent modifying termination points of existing cable to ensure end-to-end path integrity
+* [#3356](https://github.com/netbox-community/netbox/issues/3356) - Correct Swagger schema specification for the available prefixes/IPs API endpoints
+* [#4139](https://github.com/netbox-community/netbox/issues/4139) - Enable assigning all relevant attributes during bulk device/VM component creation
+* [#4336](https://github.com/netbox-community/netbox/issues/4336) - Ensure interfaces without a subinterface ID are ordered before subinterface zero
+* [#4361](https://github.com/netbox-community/netbox/issues/4361) - Fix Type of `connection_state` in Swagger schema
+* [#4388](https://github.com/netbox-community/netbox/issues/4388) - Fix detection of connected endpoints when connecting rear ports
+* [#4459](https://github.com/netbox-community/netbox/issues/4459) - Fix caching issue resulting in erroneous nested data for regions, rack groups, and tenant groups
+* [#4489](https://github.com/netbox-community/netbox/issues/4489) - Fix display of parent/child role on device type view
+* [#4496](https://github.com/netbox-community/netbox/issues/4496) - Fix exception when validating certain models via REST API
+* [#4510](https://github.com/netbox-community/netbox/issues/4510) - Enforce address family for device primary IPv4/v6 addresses
+
+---
+
 ## v2.8.0 (2020-04-13)
 
+**NOTE:** Beginning with release 2.8.0, NetBox requires Python 3.6 or later.
+
 ### New Features (Beta)
 
 This releases introduces two new features in beta status. While they are expected to be functional, their precise implementation is subject to change during the v2.8 release cycle. It is recommended to wait until NetBox v2.9 to deploy them in production.
@@ -35,7 +73,7 @@ For NetBox plugins to be recognized, they must be installed and added by name to
 * [#1754](https://github.com/netbox-community/netbox/issues/1754) - Added support for nested rack groups
 * [#3939](https://github.com/netbox-community/netbox/issues/3939) - Added support for nested tenant groups
 * [#4078](https://github.com/netbox-community/netbox/issues/4078) - Standardized description fields across all models
-* [#4195](https://github.com/netbox-community/netbox/issues/4195) - Enabled application logging (see [logging configuration](../configuration/optional-settings.md#logging))
+* [#4195](https://github.com/netbox-community/netbox/issues/4195) - Enabled application logging (see [logging configuration](https://netbox.readthedocs.io/en/stable/configuration/optional-settings/#logging))
 
 ### Bug Fixes
 

+ 2 - 4
netbox/dcim/api/serializers.py

@@ -143,8 +143,7 @@ class RackSerializer(TaggitSerializer, CustomFieldModelSerializer):
         # Validate uniqueness of (group, facility_id) since we omitted the automatically-created validator from Meta.
         if data.get('facility_id', None):
             validator = UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('group', 'facility_id'))
-            validator.set_context(self)
-            validator(data)
+            validator(data, self)
 
         # Enforce model validation
         super().validate(data)
@@ -395,8 +394,7 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer):
         # Validate uniqueness of (rack, position, face) since we omitted the automatically-created validator from Meta.
         if data.get('rack') and data.get('position') and data.get('face'):
             validator = UniqueTogetherValidator(queryset=Device.objects.all(), fields=('rack', 'position', 'face'))
-            validator.set_context(self)
-            validator(data)
+            validator(data, self)
 
         # Enforce model validation
         super().validate(data)

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

@@ -48,7 +48,7 @@ class CableTraceMixin(object):
         # Initialize the path array
         path = []
 
-        for near_end, cable, far_end in obj.trace():
+        for near_end, cable, far_end in obj.trace()[0]:
 
             # Serialize each object
             serializer_a = get_serializer_for_model(near_end, prefix='Nested')

+ 2 - 0
netbox/dcim/choices.py

@@ -57,11 +57,13 @@ class RackWidthChoices(ChoiceSet):
 
     WIDTH_10IN = 10
     WIDTH_19IN = 19
+    WIDTH_21IN = 21
     WIDTH_23IN = 23
 
     CHOICES = (
         (WIDTH_10IN, '10 inches'),
         (WIDTH_19IN, '19 inches'),
+        (WIDTH_21IN, '21 inches'),
         (WIDTH_23IN, '23 inches'),
     )
 

+ 9 - 0
netbox/dcim/exceptions.py

@@ -3,3 +3,12 @@ class LoopDetected(Exception):
     A loop has been detected while tracing a cable path.
     """
     pass
+
+
+class CableTraceSplit(Exception):
+    """
+    A cable trace cannot be completed because a RearPort maps to multiple FrontPorts and
+    we don't know which one to follow.
+    """
+    def __init__(self, termination, *args, **kwargs):
+        self.termination = termination

+ 397 - 432
netbox/dcim/forms.py

@@ -23,8 +23,9 @@ from tenancy.models import Tenant, TenantGroup
 from utilities.forms import (
     APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
     BulkEditNullBooleanSelect, ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, DynamicModelChoiceField,
-    DynamicModelMultipleChoiceField, ExpandableNameField, FlexibleModelChoiceField, JSONField, SelectWithPK,
-    SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
+    DynamicModelMultipleChoiceField, ExpandableNameField, FlexibleModelChoiceField, form_from_model, JSONField,
+    SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
+    BOOLEAN_WITH_BLANK_CHOICES,
 )
 from virtualization.models import Cluster, ClusterGroup, VirtualMachine
 from .choices import *
@@ -2298,30 +2299,10 @@ class DeviceBulkAddComponentForm(BootstrapMixin, forms.Form):
         label='Name'
     )
 
-
-class DeviceBulkAddInterfaceForm(DeviceBulkAddComponentForm):
-    type = forms.ChoiceField(
-        choices=InterfaceTypeChoices,
-        widget=StaticSelect2()
-    )
-    enabled = forms.BooleanField(
-        required=False,
-        initial=True
-    )
-    mtu = forms.IntegerField(
-        required=False,
-        min_value=INTERFACE_MTU_MIN,
-        max_value=INTERFACE_MTU_MAX,
-        label='MTU'
-    )
-    mgmt_only = forms.BooleanField(
-        required=False,
-        label='Management only'
-    )
-    description = forms.CharField(
-        max_length=100,
-        required=False
-    )
+    def clean_tags(self):
+        # Because we're feeding TagField data (on the bulk edit form) to another TagField (on the model form), we
+        # must first convert the list of tags to a string.
+        return ','.join(self.cleaned_data.get('tags'))
 
 
 #
@@ -2375,20 +2356,23 @@ class ConsolePortCreateForm(BootstrapMixin, forms.Form):
     )
 
 
-class ConsolePortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
+class ConsolePortBulkCreateForm(
+    form_from_model(ConsolePort, ['type', 'description', 'tags']),
+    DeviceBulkAddComponentForm
+):
+    pass
+
+
+class ConsolePortBulkEditForm(
+    form_from_model(ConsolePort, ['type', 'description']),
+    BootstrapMixin,
+    AddRemoveTagsForm,
+    BulkEditForm
+):
     pk = forms.ModelMultipleChoiceField(
         queryset=ConsolePort.objects.all(),
         widget=forms.MultipleHiddenInput()
     )
-    type = forms.ChoiceField(
-        choices=add_blank_choice(ConsolePortTypeChoices),
-        required=False,
-        widget=StaticSelect2()
-    )
-    description = forms.CharField(
-        max_length=100,
-        required=False
-    )
 
     class Meta:
         nullable_fields = (
@@ -2462,20 +2446,23 @@ class ConsoleServerPortCreateForm(BootstrapMixin, forms.Form):
     )
 
 
-class ConsoleServerPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
+class ConsoleServerPortBulkCreateForm(
+    form_from_model(ConsoleServerPort, ['type', 'description', 'tags']),
+    DeviceBulkAddComponentForm
+):
+    pass
+
+
+class ConsoleServerPortBulkEditForm(
+    form_from_model(ConsoleServerPort, ['type', 'description']),
+    BootstrapMixin,
+    AddRemoveTagsForm,
+    BulkEditForm
+):
     pk = forms.ModelMultipleChoiceField(
         queryset=ConsoleServerPort.objects.all(),
         widget=forms.MultipleHiddenInput()
     )
-    type = forms.ChoiceField(
-        choices=add_blank_choice(ConsolePortTypeChoices),
-        required=False,
-        widget=StaticSelect2()
-    )
-    description = forms.CharField(
-        max_length=100,
-        required=False
-    )
 
     class Meta:
         nullable_fields = [
@@ -2573,30 +2560,23 @@ class PowerPortCreateForm(BootstrapMixin, forms.Form):
     )
 
 
-class PowerPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
+class PowerPortBulkCreateForm(
+    form_from_model(PowerPort, ['type', 'maximum_draw', 'allocated_draw', 'description', 'tags']),
+    DeviceBulkAddComponentForm
+):
+    pass
+
+
+class PowerPortBulkEditForm(
+    form_from_model(PowerPort, ['type', 'maximum_draw', 'allocated_draw', 'description']),
+    BootstrapMixin,
+    AddRemoveTagsForm,
+    BulkEditForm
+):
     pk = forms.ModelMultipleChoiceField(
         queryset=PowerPort.objects.all(),
         widget=forms.MultipleHiddenInput()
     )
-    type = forms.ChoiceField(
-        choices=add_blank_choice(PowerPortTypeChoices),
-        required=False,
-        widget=StaticSelect2()
-    )
-    maximum_draw = forms.IntegerField(
-        min_value=1,
-        required=False,
-        help_text="Maximum draw in watts"
-    )
-    allocated_draw = forms.IntegerField(
-        min_value=1,
-        required=False,
-        help_text="Allocated draw in watts"
-    )
-    description = forms.CharField(
-        max_length=100,
-        required=False
-    )
 
     class Meta:
         nullable_fields = (
@@ -2700,6 +2680,61 @@ class PowerOutletCreateForm(BootstrapMixin, forms.Form):
         self.fields['power_port'].queryset = PowerPort.objects.filter(device=device)
 
 
+class PowerOutletBulkCreateForm(
+    form_from_model(PowerOutlet, ['type', 'feed_leg', 'description', 'tags']),
+    DeviceBulkAddComponentForm
+):
+    pass
+
+
+class PowerOutletBulkEditForm(
+    form_from_model(PowerOutlet, ['type', 'feed_leg', 'power_port', 'description']),
+    BootstrapMixin,
+    AddRemoveTagsForm,
+    BulkEditForm
+):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=PowerOutlet.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    device = forms.ModelChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        disabled=True,
+        widget=forms.HiddenInput()
+    )
+
+    class Meta:
+        nullable_fields = [
+            'type', 'feed_leg', 'power_port', 'description',
+        ]
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Limit power_port queryset to PowerPorts which belong to the parent Device
+        if 'device' in self.initial:
+            device = Device.objects.filter(pk=self.initial['device']).first()
+            self.fields['power_port'].queryset = PowerPort.objects.filter(device=device)
+        else:
+            self.fields['power_port'].choices = ()
+            self.fields['power_port'].widget.attrs['disabled'] = True
+
+
+class PowerOutletBulkRenameForm(BulkRenameForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=PowerOutlet.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+
+
+class PowerOutletBulkDisconnectForm(ConfirmationForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=PowerOutlet.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+
+
 class PowerOutletCSVForm(forms.ModelForm):
     device = FlexibleModelChoiceField(
         queryset=Device.objects.all(),
@@ -2750,65 +2785,6 @@ class PowerOutletCSVForm(forms.ModelForm):
             self.fields['power_port'].queryset = PowerPort.objects.none()
 
 
-class PowerOutletBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=PowerOutlet.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    device = forms.ModelChoiceField(
-        queryset=Device.objects.all(),
-        required=False,
-        disabled=True,
-        widget=forms.HiddenInput()
-    )
-    type = forms.ChoiceField(
-        choices=add_blank_choice(PowerOutletTypeChoices),
-        required=False
-    )
-    feed_leg = forms.ChoiceField(
-        choices=add_blank_choice(PowerOutletFeedLegChoices),
-        required=False,
-    )
-    power_port = forms.ModelChoiceField(
-        queryset=PowerPort.objects.all(),
-        required=False
-    )
-    description = forms.CharField(
-        max_length=100,
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = [
-            'type', 'feed_leg', 'power_port', 'description',
-        ]
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Limit power_port queryset to PowerPorts which belong to the parent Device
-        if 'device' in self.initial:
-            device = Device.objects.filter(pk=self.initial['device']).first()
-            self.fields['power_port'].queryset = PowerPort.objects.filter(device=device)
-        else:
-            self.fields['power_port'].choices = ()
-            self.fields['power_port'].widget.attrs['disabled'] = True
-
-
-class PowerOutletBulkRenameForm(BulkRenameForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=PowerOutlet.objects.all(),
-        widget=forms.MultipleHiddenInput
-    )
-
-
-class PowerOutletBulkDisconnectForm(ConfirmationForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=PowerOutlet.objects.all(),
-        widget=forms.MultipleHiddenInput
-    )
-
-
 #
 # Interfaces
 #
@@ -2985,6 +2961,102 @@ class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form):
         self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', device.site.pk)
 
 
+class InterfaceBulkCreateForm(
+    form_from_model(Interface, ['type', 'enabled', 'mtu', 'mgmt_only', 'description', 'tags']),
+    DeviceBulkAddComponentForm
+):
+    pass
+
+
+class InterfaceBulkEditForm(
+    form_from_model(Interface, ['type', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description', 'mode']),
+    BootstrapMixin,
+    AddRemoveTagsForm,
+    BulkEditForm
+):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=Interface.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    device = forms.ModelChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        disabled=True,
+        widget=forms.HiddenInput()
+    )
+    untagged_vlan = DynamicModelChoiceField(
+        queryset=VLAN.objects.all(),
+        required=False,
+        widget=APISelect(
+            display_field='display_name',
+            full=True,
+            additional_query_params={
+                'site_id': 'null',
+            },
+        )
+    )
+    tagged_vlans = DynamicModelMultipleChoiceField(
+        queryset=VLAN.objects.all(),
+        required=False,
+        widget=APISelectMultiple(
+            display_field='display_name',
+            full=True,
+            additional_query_params={
+                'site_id': 'null',
+            },
+        )
+    )
+
+    class Meta:
+        nullable_fields = [
+            'lag', 'mac_address', 'mtu', 'description', 'mode', 'untagged_vlan', 'tagged_vlans'
+        ]
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Limit LAG choices to interfaces which belong to the parent device (or VC master)
+        if 'device' in self.initial:
+            device = Device.objects.filter(pk=self.initial['device']).first()
+            self.fields['lag'].queryset = Interface.objects.filter(
+                device__in=[device, device.get_vc_master()],
+                type=InterfaceTypeChoices.TYPE_LAG
+            )
+
+            # Add current site to VLANs query params
+            self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', device.site.pk)
+            self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', device.site.pk)
+        else:
+            self.fields['lag'].choices = ()
+            self.fields['lag'].widget.attrs['disabled'] = True
+
+    def clean(self):
+
+        # Untagged interfaces cannot be assigned tagged VLANs
+        if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and self.cleaned_data['tagged_vlans']:
+            raise forms.ValidationError({
+                'mode': "An access interface cannot have tagged VLANs assigned."
+            })
+
+        # Remove all tagged VLAN assignments from "tagged all" interfaces
+        elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED_ALL:
+            self.cleaned_data['tagged_vlans'] = []
+
+
+class InterfaceBulkRenameForm(BulkRenameForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=Interface.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+
+
+class InterfaceBulkDisconnectForm(ConfirmationForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=Interface.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+
+
 class InterfaceCSVForm(forms.ModelForm):
     device = FlexibleModelChoiceField(
         queryset=Device.objects.all(),
@@ -3052,129 +3124,6 @@ class InterfaceCSVForm(forms.ModelForm):
             return self.cleaned_data['enabled']
 
 
-class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=Interface.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    device = forms.ModelChoiceField(
-        queryset=Device.objects.all(),
-        required=False,
-        disabled=True,
-        widget=forms.HiddenInput()
-    )
-    type = forms.ChoiceField(
-        choices=add_blank_choice(InterfaceTypeChoices),
-        required=False,
-        widget=StaticSelect2()
-    )
-    enabled = forms.NullBooleanField(
-        required=False,
-        widget=BulkEditNullBooleanSelect()
-    )
-    lag = forms.ModelChoiceField(
-        queryset=Interface.objects.all(),
-        required=False,
-        label='Parent LAG',
-        widget=StaticSelect2()
-    )
-    mac_address = forms.CharField(
-        required=False,
-        label='MAC Address'
-    )
-    mtu = forms.IntegerField(
-        required=False,
-        min_value=INTERFACE_MTU_MIN,
-        max_value=INTERFACE_MTU_MAX,
-        label='MTU'
-    )
-    mgmt_only = forms.NullBooleanField(
-        required=False,
-        widget=BulkEditNullBooleanSelect(),
-        label='Management only'
-    )
-    description = forms.CharField(
-        max_length=100,
-        required=False
-    )
-    mode = forms.ChoiceField(
-        choices=add_blank_choice(InterfaceModeChoices),
-        required=False,
-        widget=StaticSelect2()
-    )
-    untagged_vlan = DynamicModelChoiceField(
-        queryset=VLAN.objects.all(),
-        required=False,
-        widget=APISelect(
-            display_field='display_name',
-            full=True,
-            additional_query_params={
-                'site_id': 'null',
-            },
-        )
-    )
-    tagged_vlans = DynamicModelMultipleChoiceField(
-        queryset=VLAN.objects.all(),
-        required=False,
-        widget=APISelectMultiple(
-            display_field='display_name',
-            full=True,
-            additional_query_params={
-                'site_id': 'null',
-            },
-        )
-    )
-
-    class Meta:
-        nullable_fields = [
-            'lag', 'mac_address', 'mtu', 'description', 'mode', 'untagged_vlan', 'tagged_vlans'
-        ]
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Limit LAG choices to interfaces which belong to the parent device (or VC master)
-        if 'device' in self.initial:
-            device = Device.objects.filter(pk=self.initial['device']).first()
-            self.fields['lag'].queryset = Interface.objects.filter(
-                device__in=[device, device.get_vc_master()],
-                type=InterfaceTypeChoices.TYPE_LAG
-            )
-
-            # Add current site to VLANs query params
-            self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', device.site.pk)
-            self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', device.site.pk)
-        else:
-            self.fields['lag'].choices = ()
-            self.fields['lag'].widget.attrs['disabled'] = True
-
-    def clean(self):
-
-        # Untagged interfaces cannot be assigned tagged VLANs
-        if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and self.cleaned_data['tagged_vlans']:
-            raise forms.ValidationError({
-                'mode': "An access interface cannot have tagged VLANs assigned."
-            })
-
-        # Remove all tagged VLAN assignments from "tagged all" interfaces
-        elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED_ALL:
-            self.cleaned_data['tagged_vlans'] = []
-
-
-class InterfaceBulkRenameForm(BulkRenameForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=Interface.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-
-
-class InterfaceBulkDisconnectForm(ConfirmationForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=Interface.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-
-
 #
 # Front pass-through ports
 #
@@ -3283,6 +3232,44 @@ class FrontPortCreateForm(BootstrapMixin, forms.Form):
         }
 
 
+# class FrontPortBulkCreateForm(
+#     form_from_model(FrontPort, ['type', 'description', 'tags']),
+#     DeviceBulkAddComponentForm
+# ):
+#     pass
+
+
+class FrontPortBulkEditForm(
+    form_from_model(FrontPort, ['type', 'description']),
+    BootstrapMixin,
+    AddRemoveTagsForm,
+    BulkEditForm
+):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=FrontPort.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+
+    class Meta:
+        nullable_fields = [
+            'description',
+        ]
+
+
+class FrontPortBulkRenameForm(BulkRenameForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=FrontPort.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+
+
+class FrontPortBulkDisconnectForm(ConfirmationForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=FrontPort.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+
+
 class FrontPortCSVForm(forms.ModelForm):
     device = FlexibleModelChoiceField(
         queryset=Device.objects.all(),
@@ -3331,41 +3318,6 @@ class FrontPortCSVForm(forms.ModelForm):
             self.fields['rear_port'].queryset = RearPort.objects.none()
 
 
-class FrontPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=FrontPort.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    type = forms.ChoiceField(
-        choices=add_blank_choice(PortTypeChoices),
-        required=False,
-        widget=StaticSelect2()
-    )
-    description = forms.CharField(
-        max_length=100,
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = [
-            'description',
-        ]
-
-
-class FrontPortBulkRenameForm(BulkRenameForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=FrontPort.objects.all(),
-        widget=forms.MultipleHiddenInput
-    )
-
-
-class FrontPortBulkDisconnectForm(ConfirmationForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=FrontPort.objects.all(),
-        widget=forms.MultipleHiddenInput
-    )
-
-
 #
 # Rear pass-through ports
 #
@@ -3418,6 +3370,44 @@ class RearPortCreateForm(BootstrapMixin, forms.Form):
     )
 
 
+class RearPortBulkCreateForm(
+    form_from_model(RearPort, ['type', 'positions', 'description', 'tags']),
+    DeviceBulkAddComponentForm
+):
+    pass
+
+
+class RearPortBulkEditForm(
+    form_from_model(RearPort, ['type', 'description']),
+    BootstrapMixin,
+    AddRemoveTagsForm,
+    BulkEditForm
+):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=RearPort.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+
+    class Meta:
+        nullable_fields = [
+            'description',
+        ]
+
+
+class RearPortBulkRenameForm(BulkRenameForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=RearPort.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+
+
+class RearPortBulkDisconnectForm(ConfirmationForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=RearPort.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+
+
 class RearPortCSVForm(forms.ModelForm):
     device = FlexibleModelChoiceField(
         queryset=Device.objects.all(),
@@ -3436,40 +3426,145 @@ class RearPortCSVForm(forms.ModelForm):
         fields = RearPort.csv_headers
 
 
-class RearPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=RearPort.objects.all(),
-        widget=forms.MultipleHiddenInput()
+#
+# Device bays
+#
+
+class DeviceBayFilterForm(DeviceComponentFilterForm):
+    model = DeviceBay
+    tag = TagFilterField(model)
+
+
+class DeviceBayForm(BootstrapMixin, forms.ModelForm):
+    tags = TagField(
+        required=False
     )
-    type = forms.ChoiceField(
-        choices=add_blank_choice(PortTypeChoices),
-        required=False,
-        widget=StaticSelect2()
+
+    class Meta:
+        model = DeviceBay
+        fields = [
+            'device', 'name', 'description', 'tags',
+        ]
+        widgets = {
+            'device': forms.HiddenInput(),
+        }
+
+
+class DeviceBayCreateForm(BootstrapMixin, forms.Form):
+    device = DynamicModelChoiceField(
+        queryset=Device.objects.prefetch_related('device_type__manufacturer')
     )
-    description = forms.CharField(
-        max_length=100,
+    name_pattern = ExpandableNameField(
+        label='Name'
+    )
+    tags = TagField(
         required=False
     )
 
+
+class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
+    installed_device = forms.ModelChoiceField(
+        queryset=Device.objects.all(),
+        label='Child Device',
+        help_text="Child devices must first be created and assigned to the site/rack of the parent device.",
+        widget=StaticSelect2(),
+    )
+
+    def __init__(self, device_bay, *args, **kwargs):
+
+        super().__init__(*args, **kwargs)
+
+        self.fields['installed_device'].queryset = Device.objects.filter(
+            site=device_bay.device.site,
+            rack=device_bay.device.rack,
+            parent_bay__isnull=True,
+            device_type__u_height=0,
+            device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD
+        ).exclude(pk=device_bay.device.pk)
+
+
+class DeviceBayBulkCreateForm(
+    form_from_model(DeviceBay, ['description', 'tags']),
+    DeviceBulkAddComponentForm
+):
+    tags = TagField(
+        required=False
+    )
+
+
+class DeviceBayBulkEditForm(
+    form_from_model(DeviceBay, ['description']),
+    BootstrapMixin,
+    AddRemoveTagsForm,
+    BulkEditForm
+):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=DeviceBay.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+
     class Meta:
-        nullable_fields = [
+        nullable_fields = (
             'description',
-        ]
+        )
 
 
-class RearPortBulkRenameForm(BulkRenameForm):
+class DeviceBayBulkRenameForm(BulkRenameForm):
     pk = forms.ModelMultipleChoiceField(
-        queryset=RearPort.objects.all(),
-        widget=forms.MultipleHiddenInput
+        queryset=DeviceBay.objects.all(),
+        widget=forms.MultipleHiddenInput()
     )
 
 
-class RearPortBulkDisconnectForm(ConfirmationForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=RearPort.objects.all(),
-        widget=forms.MultipleHiddenInput
+class DeviceBayCSVForm(forms.ModelForm):
+    device = FlexibleModelChoiceField(
+        queryset=Device.objects.all(),
+        to_field_name='name',
+        help_text='Name or ID of device',
+        error_messages={
+            'invalid_choice': 'Device not found.',
+        }
+    )
+    installed_device = FlexibleModelChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Name or ID of device',
+        error_messages={
+            'invalid_choice': 'Child device not found.',
+        }
     )
 
+    class Meta:
+        model = DeviceBay
+        fields = DeviceBay.csv_headers
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Limit installed device choices to devices of the correct type and location
+        if self.is_bound:
+            try:
+                device = self.fields['device'].to_python(self.data['device'])
+            except forms.ValidationError:
+                device = None
+        else:
+            try:
+                device = self.instance.device
+            except Device.DoesNotExist:
+                device = None
+
+        if device:
+            self.fields['installed_device'].queryset = Device.objects.filter(
+                site=device.site,
+                rack=device.rack,
+                parent_bay__isnull=True,
+                device_type__u_height=0,
+                device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD
+            ).exclude(pk=device.pk)
+        else:
+            self.fields['installed_device'].queryset = Interface.objects.none()
+
 
 #
 # Cables
@@ -3954,136 +4049,6 @@ class CableFilterForm(BootstrapMixin, forms.Form):
     )
 
 
-#
-# Device bays
-#
-
-class DeviceBayFilterForm(DeviceComponentFilterForm):
-    model = DeviceBay
-    tag = TagFilterField(model)
-
-
-class DeviceBayForm(BootstrapMixin, forms.ModelForm):
-    tags = TagField(
-        required=False
-    )
-
-    class Meta:
-        model = DeviceBay
-        fields = [
-            'device', 'name', 'description', 'tags',
-        ]
-        widgets = {
-            'device': forms.HiddenInput(),
-        }
-
-
-class DeviceBayCreateForm(BootstrapMixin, forms.Form):
-    device = DynamicModelChoiceField(
-        queryset=Device.objects.prefetch_related('device_type__manufacturer')
-    )
-    name_pattern = ExpandableNameField(
-        label='Name'
-    )
-    tags = TagField(
-        required=False
-    )
-
-
-class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
-    installed_device = forms.ModelChoiceField(
-        queryset=Device.objects.all(),
-        label='Child Device',
-        help_text="Child devices must first be created and assigned to the site/rack of the parent device.",
-        widget=StaticSelect2(),
-    )
-
-    def __init__(self, device_bay, *args, **kwargs):
-
-        super().__init__(*args, **kwargs)
-
-        self.fields['installed_device'].queryset = Device.objects.filter(
-            site=device_bay.device.site,
-            rack=device_bay.device.rack,
-            parent_bay__isnull=True,
-            device_type__u_height=0,
-            device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD
-        ).exclude(pk=device_bay.device.pk)
-
-
-class DeviceBayBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=DeviceBay.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    description = forms.CharField(
-        max_length=100,
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = (
-            'description',
-        )
-
-
-class DeviceBayCSVForm(forms.ModelForm):
-    device = FlexibleModelChoiceField(
-        queryset=Device.objects.all(),
-        to_field_name='name',
-        help_text='Name or ID of device',
-        error_messages={
-            'invalid_choice': 'Device not found.',
-        }
-    )
-    installed_device = FlexibleModelChoiceField(
-        queryset=Device.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text='Name or ID of device',
-        error_messages={
-            'invalid_choice': 'Child device not found.',
-        }
-    )
-
-    class Meta:
-        model = DeviceBay
-        fields = DeviceBay.csv_headers
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Limit installed device choices to devices of the correct type and location
-        if self.is_bound:
-            try:
-                device = self.fields['device'].to_python(self.data['device'])
-            except forms.ValidationError:
-                device = None
-        else:
-            try:
-                device = self.instance.device
-            except Device.DoesNotExist:
-                device = None
-
-        if device:
-            self.fields['installed_device'].queryset = Device.objects.filter(
-                site=device.site,
-                rack=device.rack,
-                parent_bay__isnull=True,
-                device_type__u_height=0,
-                device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD
-            ).exclude(pk=device.pk)
-        else:
-            self.fields['installed_device'].queryset = Interface.objects.none()
-
-
-class DeviceBayBulkRenameForm(BulkRenameForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=DeviceBay.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-
-
 #
 # Connections
 #

+ 18 - 0
netbox/dcim/migrations/0105_interface_name_collation.py

@@ -0,0 +1,18 @@
+# Generated by Django 3.0.5 on 2020-04-21 20:13
+
+from django.db import migrations
+import utilities.query_functions
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0104_correct_infiniband_types'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='interface',
+            options={'ordering': ('device', utilities.query_functions.CollateAsChar('_name'))},
+        ),
+    ]

+ 42 - 27
netbox/dcim/models/__init__.py

@@ -1514,24 +1514,30 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
         # Validate primary IP addresses
         vc_interfaces = self.vc_interfaces.all()
         if self.primary_ip4:
+            if self.primary_ip4.family != 4:
+                raise ValidationError({
+                    'primary_ip4': f"{self.primary_ip4} is not an IPv4 address."
+                })
             if self.primary_ip4.interface in vc_interfaces:
                 pass
             elif self.primary_ip4.nat_inside is not None and self.primary_ip4.nat_inside.interface in vc_interfaces:
                 pass
             else:
                 raise ValidationError({
-                    'primary_ip4': "The specified IP address ({}) is not assigned to this device.".format(
-                        self.primary_ip4),
+                    'primary_ip4': f"The specified IP address ({self.primary_ip4}) is not assigned to this device."
                 })
         if self.primary_ip6:
+            if self.primary_ip6.family != 6:
+                raise ValidationError({
+                    'primary_ip6': f"{self.primary_ip6} is not an IPv6 address."
+                })
             if self.primary_ip6.interface in vc_interfaces:
                 pass
             elif self.primary_ip6.nat_inside is not None and self.primary_ip6.nat_inside.interface in vc_interfaces:
                 pass
             else:
                 raise ValidationError({
-                    'primary_ip6': "The specified IP address ({}) is not assigned to this device.".format(
-                        self.primary_ip6),
+                    'primary_ip6': f"The specified IP address ({self.primary_ip6}) is not assigned to this device."
                 })
 
         # Validate manufacturer/platform
@@ -2070,6 +2076,20 @@ class Cable(ChangeLoggedModel):
         # A copy of the PK to be used by __str__ in case the object is deleted
         self._pk = self.pk
 
+    @classmethod
+    def from_db(cls, db, field_names, values):
+        """
+        Cache the original A and B terminations of existing Cable instances for later reference inside clean().
+        """
+        instance = super().from_db(db, field_names, values)
+
+        instance._orig_termination_a_type = instance.termination_a_type
+        instance._orig_termination_a_id = instance.termination_a_id
+        instance._orig_termination_b_type = instance.termination_b_type
+        instance._orig_termination_b_id = instance.termination_b_id
+
+        return instance
+
     def __str__(self):
         return self.label or '#{}'.format(self._pk)
 
@@ -2098,6 +2118,24 @@ class Cable(ChangeLoggedModel):
                 'termination_b': 'Invalid ID for type {}'.format(self.termination_b_type)
             })
 
+        # If editing an existing Cable instance, check that neither termination has been modified.
+        if self.pk:
+            err_msg = 'Cable termination points may not be modified. Delete and recreate the cable instead.'
+            if (
+                self.termination_a_type != self._orig_termination_a_type or
+                self.termination_a_id != self._orig_termination_a_id
+            ):
+                raise ValidationError({
+                    'termination_a': err_msg
+                })
+            if (
+                self.termination_b_type != self._orig_termination_b_type or
+                self.termination_b_id != self._orig_termination_b_id
+            ):
+                raise ValidationError({
+                    'termination_b': err_msg
+                })
+
         type_a = self.termination_a_type.model
         type_b = self.termination_b_type.model
 
@@ -2205,26 +2243,3 @@ class Cable(ChangeLoggedModel):
         if self.termination_a is None:
             return
         return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name]
-
-    def get_path_endpoints(self):
-        """
-        Traverse both ends of a cable path and return its connected endpoints. Note that one or both endpoints may be
-        None.
-        """
-        a_path = self.termination_b.trace()
-        b_path = self.termination_a.trace()
-
-        # Determine overall path status (connected or planned)
-        if self.status == CableStatusChoices.STATUS_CONNECTED:
-            path_status = True
-            for segment in a_path[1:] + b_path[1:]:
-                if segment[1] is None or segment[1].status != CableStatusChoices.STATUS_CONNECTED:
-                    path_status = False
-                    break
-        else:
-            path_status = False
-
-        a_endpoint = a_path[-1][2]
-        b_endpoint = b_path[-1][2]
-
-        return a_endpoint, b_endpoint, path_status

+ 36 - 10
netbox/dcim/models/device_components.py

@@ -10,11 +10,13 @@ from taggit.managers import TaggableManager
 
 from dcim.choices import *
 from dcim.constants import *
+from dcim.exceptions import CableTraceSplit
 from dcim.fields import MACAddressField
 from extras.models import ObjectChange, TaggedItem
 from extras.utils import extras_features
 from utilities.fields import NaturalOrderingField
 from utilities.ordering import naturalize_interface
+from utilities.query_functions import CollateAsChar
 from utilities.utils import serialize_object
 from virtualization.choices import VMInterfaceTypeChoices
 
@@ -91,7 +93,13 @@ class CableTermination(models.Model):
 
     def trace(self):
         """
-        Return a list representing a complete cable path, with each individual segment represented as a three-tuple:
+        Return two items: the traceable portion of a cable path, and the termination points where it splits (if any).
+        This occurs when the trace is initiated from a midpoint along a path which traverses a RearPort. In cases where
+        the originating endpoint is unknown, it is not possible to know which corresponding FrontPort to follow.
+
+        The path is a list representing a complete cable path, with each individual segment represented as a
+        three-tuple:
+
             [
                 (termination A, cable, termination B),
                 (termination C, cable, termination D),
@@ -117,10 +125,7 @@ class CableTermination(models.Model):
 
                 # Can't map to a FrontPort without a position
                 if not position_stack:
-                    # TODO: This behavior is broken. We need a mechanism by which to return all FrontPorts mapped
-                    # to a given RearPort so that we can update end-to-end paths when a cable is created/deleted.
-                    # For now, we're maintaining the current behavior of tracing only to the first FrontPort.
-                    position_stack.append(1)
+                    raise CableTraceSplit(termination)
 
                 position = position_stack.pop()
 
@@ -159,12 +164,12 @@ class CableTermination(models.Model):
             if not endpoint.cable:
                 path.append((endpoint, None, None))
                 logger.debug("No cable connected")
-                return path
+                return path, None
 
             # Check for loops
             if endpoint.cable in [segment[1] for segment in path]:
                 logger.debug("Loop detected!")
-                return path
+                return path, None
 
             # Record the current segment in the path
             far_end = endpoint.get_cable_peer()
@@ -174,9 +179,13 @@ class CableTermination(models.Model):
             ))
 
             # Get the peer port of the far end termination
-            endpoint = get_peer_port(far_end)
+            try:
+                endpoint = get_peer_port(far_end)
+            except CableTraceSplit as e:
+                return path, e.termination.frontports.all()
+
             if endpoint is None:
-                return path
+                return path, None
 
     def get_cable_peer(self):
         if self.cable is None:
@@ -186,6 +195,23 @@ class CableTermination(models.Model):
         if self._cabled_as_b.exists():
             return self.cable.termination_a
 
+    def get_path_endpoints(self):
+        """
+        Return all endpoints of paths which traverse this object.
+        """
+        endpoints = []
+
+        # Get the far end of the last path segment
+        path, split_ends = self.trace()
+        endpoint = path[-1][2]
+        if split_ends is not None:
+            for termination in split_ends:
+                endpoints.extend(termination.get_path_endpoints())
+        elif endpoint is not None:
+            endpoints.append(endpoint)
+
+        return endpoints
+
 
 #
 # Console ports
@@ -651,7 +677,7 @@ class Interface(CableTermination, ComponentModel):
 
     class Meta:
         # TODO: ordering and unique_together should include virtual_machine
-        ordering = ('device', '_name')
+        ordering = ('device', CollateAsChar('_name'))
         unique_together = ('device', 'name')
 
     def __str__(self):

+ 31 - 20
netbox/dcim/signals.py

@@ -3,6 +3,7 @@ import logging
 from django.db.models.signals import post_save, pre_delete
 from django.dispatch import receiver
 
+from .choices import CableStatusChoices
 from .models import Cable, Device, VirtualChassis
 
 
@@ -48,16 +49,28 @@ def update_connected_endpoints(instance, **kwargs):
         instance.termination_b.cable = instance
         instance.termination_b.save()
 
-    # Check if this Cable has formed a complete path. If so, update both endpoints.
-    endpoint_a, endpoint_b, path_status = instance.get_path_endpoints()
-    if getattr(endpoint_a, 'is_path_endpoint', False) and getattr(endpoint_b, 'is_path_endpoint', False):
-        logger.debug("Updating path endpoints: {} <---> {}".format(endpoint_a, endpoint_b))
-        endpoint_a.connected_endpoint = endpoint_b
-        endpoint_a.connection_status = path_status
-        endpoint_a.save()
-        endpoint_b.connected_endpoint = endpoint_a
-        endpoint_b.connection_status = path_status
-        endpoint_b.save()
+    # Update any endpoints for this Cable.
+    endpoints = instance.termination_a.get_path_endpoints() + instance.termination_b.get_path_endpoints()
+    for endpoint in endpoints:
+        path, split_ends = endpoint.trace()
+        # Determine overall path status (connected or planned)
+        path_status = True
+        for segment in path:
+            if segment[1] is None or segment[1].status != CableStatusChoices.STATUS_CONNECTED:
+                path_status = False
+                break
+
+        endpoint_a = path[0][0]
+        endpoint_b = path[-1][2]
+
+        if getattr(endpoint_a, 'is_path_endpoint', False) and getattr(endpoint_b, 'is_path_endpoint', False):
+            logger.debug("Updating path endpoints: {} <---> {}".format(endpoint_a, endpoint_b))
+            endpoint_a.connected_endpoint = endpoint_b
+            endpoint_a.connection_status = path_status
+            endpoint_a.save()
+            endpoint_b.connected_endpoint = endpoint_a
+            endpoint_b.connection_status = path_status
+            endpoint_b.save()
 
 
 @receiver(pre_delete, sender=Cable)
@@ -67,7 +80,7 @@ def nullify_connected_endpoints(instance, **kwargs):
     """
     logger = logging.getLogger('netbox.dcim.cable')
 
-    endpoint_a, endpoint_b, _ = instance.get_path_endpoints()
+    endpoints = instance.termination_a.get_path_endpoints() + instance.termination_b.get_path_endpoints()
 
     # Disassociate the Cable from its termination points
     if instance.termination_a is not None:
@@ -79,12 +92,10 @@ def nullify_connected_endpoints(instance, **kwargs):
         instance.termination_b.cable = None
         instance.termination_b.save()
 
-    # If this Cable was part of a complete path, tear it down
-    if hasattr(endpoint_a, 'connected_endpoint') and hasattr(endpoint_b, 'connected_endpoint'):
-        logger.debug("Tearing down path ({} <---> {})".format(endpoint_a, endpoint_b))
-        endpoint_a.connected_endpoint = None
-        endpoint_a.connection_status = None
-        endpoint_a.save()
-        endpoint_b.connected_endpoint = None
-        endpoint_b.connection_status = None
-        endpoint_b.save()
+    # If this Cable was part of any complete end-to-end paths, tear them down.
+    for endpoint in endpoints:
+        logger.debug(f"Removing path information for {endpoint}")
+        if hasattr(endpoint, 'connected_endpoint'):
+            endpoint.connected_endpoint = None
+            endpoint.connection_status = None
+            endpoint.save()

+ 5 - 0
netbox/dcim/tests/test_api.py

@@ -582,6 +582,7 @@ class RackTest(APITestCase):
 
         data = {
             'name': 'Test Rack 4',
+            'facility_id': '1234',
             'site': self.site1.pk,
             'group': self.rackgroup1.pk,
             'role': self.rackrole1.pk,
@@ -1815,6 +1816,7 @@ class DeviceTest(APITestCase):
 
         self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
         self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2')
+        self.rack1 = Rack.objects.create(name='Test Rack 1', site=self.site1, u_height=48)
         manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
         self.devicetype1 = DeviceType.objects.create(
             manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
@@ -1920,6 +1922,9 @@ class DeviceTest(APITestCase):
             'device_role': self.devicerole1.pk,
             'name': 'Test Device 4',
             'site': self.site1.pk,
+            'rack': self.rack1.pk,
+            'face': DeviceFaceChoices.FACE_FRONT,
+            'position': 1,
             'cluster': self.cluster1.pk,
         }
 

+ 145 - 40
netbox/dcim/tests/test_models.py

@@ -549,12 +549,21 @@ class CablePathTestCase(TestCase):
         self.assertIsNone(endpoint_a.connection_status)
         self.assertIsNone(endpoint_b.connection_status)
 
-    def test_connection_via_patch(self):
-        """
-                     1               2               3
-        [Device 1] ----- [Panel 1] ----- [Panel 2] ----- [Device 2]
-             Iface1     FP1     RP1     RP1     FP1     Iface1
-
+    def test_connections_via_patch(self):
+        """
+        Test two connections via patched rear ports:
+            Device 1 <---> Device 2
+            Device 3 <---> Device 4
+
+                        1                           2
+        [Device 1] -----------+               +----------- [Device 2]
+              Iface1          |               |          Iface1
+                          FP1 |       3       | FP1
+                          [Panel 1] ----- [Panel 2]
+                          FP2 |   RP1   RP1   | FP2
+              Iface1          |               |          Iface1
+        [Device 3] -----------+               +----------- [Device 4]
+                        4                           5
         """
         # Create cables
         cable1 = Cable(
@@ -563,45 +572,78 @@ class CablePathTestCase(TestCase):
         )
         cable1.save()
         cable2 = Cable(
-            termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'),
-            termination_b=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1')
+            termination_a=Interface.objects.get(device__name='Device 2', name='Interface 1'),
+            termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1')
         )
         cable2.save()
+
         cable3 = Cable(
-            termination_a=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1'),
-            termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
+            termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'),
+            termination_b=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1')
         )
         cable3.save()
 
+        cable4 = Cable(
+            termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1'),
+            termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2')
+        )
+        cable4.save()
+        cable5 = Cable(
+            termination_a=Interface.objects.get(device__name='Device 4', name='Interface 1'),
+            termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 2')
+        )
+        cable5.save()
+
         # Retrieve endpoints
         endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1')
         endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1')
+        endpoint_c = Interface.objects.get(device__name='Device 3', name='Interface 1')
+        endpoint_d = Interface.objects.get(device__name='Device 4', name='Interface 1')
 
         # Validate connections
         self.assertEqual(endpoint_a.connected_endpoint, endpoint_b)
         self.assertEqual(endpoint_b.connected_endpoint, endpoint_a)
+        self.assertEqual(endpoint_c.connected_endpoint, endpoint_d)
+        self.assertEqual(endpoint_d.connected_endpoint, endpoint_c)
         self.assertTrue(endpoint_a.connection_status)
         self.assertTrue(endpoint_b.connection_status)
+        self.assertTrue(endpoint_c.connection_status)
+        self.assertTrue(endpoint_d.connection_status)
 
-        # Delete cable 2
-        cable2.delete()
+        # Delete cable 3
+        cable3.delete()
 
         # Refresh endpoints
         endpoint_a.refresh_from_db()
         endpoint_b.refresh_from_db()
+        endpoint_c.refresh_from_db()
+        endpoint_d.refresh_from_db()
 
         # Check that connections have been nullified
         self.assertIsNone(endpoint_a.connected_endpoint)
         self.assertIsNone(endpoint_b.connected_endpoint)
+        self.assertIsNone(endpoint_c.connected_endpoint)
+        self.assertIsNone(endpoint_d.connected_endpoint)
         self.assertIsNone(endpoint_a.connection_status)
         self.assertIsNone(endpoint_b.connection_status)
+        self.assertIsNone(endpoint_c.connection_status)
+        self.assertIsNone(endpoint_d.connection_status)
 
-    def test_connection_via_multiple_patches(self):
+    def test_connections_via_multiple_patches(self):
         """
-                     1               2               3               4               5
-        [Device 1] ----- [Panel 1] ----- [Panel 2] ----- [Panel 3] ----- [Panel 4] ----- [Device 2]
-             Iface1     FP1     RP1     RP1     FP1     FP1     RP1     RP1     FP1     Iface1
+        Test two connections via patched rear ports:
+            Device 1 <---> Device 2
+            Device 3 <---> Device 4
 
+                        1                             2                             3
+        [Device 1] -----------+               +---------------+               +----------- [Device 2]
+              Iface1          |               |               |               |          Iface1
+                          FP1 |       4       | FP1       FP1 |       5       | FP1
+                          [Panel 1] ----- [Panel 2]       [Panel 3] ----- [Panel 4]
+                          FP2 |   RP1   RP1   | FP2       FP2 |   RP1   RP1   | FP2
+              Iface1          |               |               |               |          Iface1
+        [Device 3] -----------+               +---------------+               +----------- [Device 4]
+                        6                             7                             8
         """
         # Create cables
         cable1 = Cable(
@@ -610,55 +652,94 @@ class CablePathTestCase(TestCase):
         )
         cable1.save()
         cable2 = Cable(
-            termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'),
-            termination_b=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1')
+            termination_a=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1'),
+            termination_b=FrontPort.objects.get(device__name='Panel 3', name='Front Port 1')
         )
         cable2.save()
         cable3 = Cable(
-            termination_a=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1'),
-            termination_b=FrontPort.objects.get(device__name='Panel 3', name='Front Port 1')
+            termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 1'),
+            termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
         )
         cable3.save()
+
         cable4 = Cable(
-            termination_a=RearPort.objects.get(device__name='Panel 3', name='Rear Port 1'),
-            termination_b=RearPort.objects.get(device__name='Panel 4', name='Rear Port 1')
+            termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'),
+            termination_b=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1')
         )
         cable4.save()
         cable5 = Cable(
-            termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 1'),
-            termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
+            termination_a=RearPort.objects.get(device__name='Panel 3', name='Rear Port 1'),
+            termination_b=RearPort.objects.get(device__name='Panel 4', name='Rear Port 1')
         )
         cable5.save()
 
+        cable6 = Cable(
+            termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1'),
+            termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2')
+        )
+        cable6.save()
+        cable7 = Cable(
+            termination_a=FrontPort.objects.get(device__name='Panel 2', name='Front Port 2'),
+            termination_b=FrontPort.objects.get(device__name='Panel 3', name='Front Port 2')
+        )
+        cable7.save()
+        cable8 = Cable(
+            termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 2'),
+            termination_b=Interface.objects.get(device__name='Device 4', name='Interface 1')
+        )
+        cable8.save()
+
         # Retrieve endpoints
         endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1')
         endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1')
+        endpoint_c = Interface.objects.get(device__name='Device 3', name='Interface 1')
+        endpoint_d = Interface.objects.get(device__name='Device 4', name='Interface 1')
 
         # Validate connections
         self.assertEqual(endpoint_a.connected_endpoint, endpoint_b)
         self.assertEqual(endpoint_b.connected_endpoint, endpoint_a)
+        self.assertEqual(endpoint_c.connected_endpoint, endpoint_d)
+        self.assertEqual(endpoint_d.connected_endpoint, endpoint_c)
         self.assertTrue(endpoint_a.connection_status)
         self.assertTrue(endpoint_b.connection_status)
+        self.assertTrue(endpoint_c.connection_status)
+        self.assertTrue(endpoint_d.connection_status)
 
-        # Delete cable 3
-        cable3.delete()
+        # Delete cables 4 and 5
+        cable4.delete()
+        cable5.delete()
 
         # Refresh endpoints
         endpoint_a.refresh_from_db()
         endpoint_b.refresh_from_db()
+        endpoint_c.refresh_from_db()
+        endpoint_d.refresh_from_db()
 
         # Check that connections have been nullified
         self.assertIsNone(endpoint_a.connected_endpoint)
         self.assertIsNone(endpoint_b.connected_endpoint)
+        self.assertIsNone(endpoint_c.connected_endpoint)
+        self.assertIsNone(endpoint_d.connected_endpoint)
         self.assertIsNone(endpoint_a.connection_status)
         self.assertIsNone(endpoint_b.connection_status)
+        self.assertIsNone(endpoint_c.connection_status)
+        self.assertIsNone(endpoint_d.connection_status)
 
-    def test_connection_via_stacked_rear_ports(self):
+    def test_connections_via_nested_rear_ports(self):
         """
-                     1               2               3               4               5
-        [Device 1] ----- [Panel 1] ----- [Panel 2] ----- [Panel 3] ----- [Panel 4] ----- [Device 2]
-             Iface1     FP1     RP1     FP1     RP1     RP1     FP1     RP1     FP1     Iface1
+        Test two connections via nested rear ports:
+            Device 1 <---> Device 2
+            Device 3 <---> Device 4
 
+                        1                                                           2
+        [Device 1] -----------+                                               +----------- [Device 2]
+              Iface1          |                                               |          Iface1
+                          FP1 |       3               4               5       | FP1
+                          [Panel 1] ----- [Panel 2] ----- [Panel 3] ----- [Panel 4]
+                          FP2 |   RP1   FP1       RP1   RP1       FP1   RP1   | FP2
+              Iface1          |                                               |          Iface1
+        [Device 3] -----------+                                               +----------- [Device 4]
+                        6                                                           7
         """
         # Create cables
         cable1 = Cable(
@@ -667,48 +748,72 @@ class CablePathTestCase(TestCase):
         )
         cable1.save()
         cable2 = Cable(
-            termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'),
-            termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1')
+            termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 1'),
+            termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
         )
         cable2.save()
+
         cable3 = Cable(
-            termination_a=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1'),
-            termination_b=RearPort.objects.get(device__name='Panel 3', name='Rear Port 1')
+            termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'),
+            termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1')
         )
         cable3.save()
         cable4 = Cable(
-            termination_a=FrontPort.objects.get(device__name='Panel 3', name='Front Port 1'),
-            termination_b=RearPort.objects.get(device__name='Panel 4', name='Rear Port 1')
+            termination_a=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1'),
+            termination_b=RearPort.objects.get(device__name='Panel 3', name='Rear Port 1')
         )
         cable4.save()
         cable5 = Cable(
-            termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 1'),
-            termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
+            termination_a=FrontPort.objects.get(device__name='Panel 3', name='Front Port 1'),
+            termination_b=RearPort.objects.get(device__name='Panel 4', name='Rear Port 1')
         )
         cable5.save()
 
+        cable6 = Cable(
+            termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1'),
+            termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2')
+        )
+        cable6.save()
+        cable7 = Cable(
+            termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 2'),
+            termination_b=Interface.objects.get(device__name='Device 4', name='Interface 1')
+        )
+        cable7.save()
+
         # Retrieve endpoints
         endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1')
         endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1')
+        endpoint_c = Interface.objects.get(device__name='Device 3', name='Interface 1')
+        endpoint_d = Interface.objects.get(device__name='Device 4', name='Interface 1')
 
         # Validate connections
         self.assertEqual(endpoint_a.connected_endpoint, endpoint_b)
         self.assertEqual(endpoint_b.connected_endpoint, endpoint_a)
+        self.assertEqual(endpoint_c.connected_endpoint, endpoint_d)
+        self.assertEqual(endpoint_d.connected_endpoint, endpoint_c)
         self.assertTrue(endpoint_a.connection_status)
         self.assertTrue(endpoint_b.connection_status)
+        self.assertTrue(endpoint_c.connection_status)
+        self.assertTrue(endpoint_d.connection_status)
 
-        # Delete cable 3
-        cable3.delete()
+        # Delete cable 4
+        cable4.delete()
 
         # Refresh endpoints
         endpoint_a.refresh_from_db()
         endpoint_b.refresh_from_db()
+        endpoint_c.refresh_from_db()
+        endpoint_d.refresh_from_db()
 
         # Check that connections have been nullified
         self.assertIsNone(endpoint_a.connected_endpoint)
         self.assertIsNone(endpoint_b.connected_endpoint)
+        self.assertIsNone(endpoint_c.connected_endpoint)
+        self.assertIsNone(endpoint_d.connected_endpoint)
         self.assertIsNone(endpoint_a.connection_status)
         self.assertIsNone(endpoint_b.connection_status)
+        self.assertIsNone(endpoint_c.connection_status)
+        self.assertIsNone(endpoint_d.connection_status)
 
     def test_connection_via_circuit(self):
         """

+ 6 - 0
netbox/dcim/tests/test_natural_ordering.py

@@ -23,28 +23,34 @@ class NaturalOrderingTestCase(TestCase):
 
         INTERFACES = [
             '0',
+            '0.0',
             '0.1',
             '0.2',
             '0.10',
             '0.100',
             '0:1',
+            '0:1.0',
             '0:1.1',
             '0:1.2',
             '0:1.10',
             '0:2',
+            '0:2.0',
             '0:2.1',
             '0:2.2',
             '0:2.10',
             '1',
+            '1.0',
             '1.1',
             '1.2',
             '1.10',
             '1.100',
             '1:1',
+            '1:1.0',
             '1:1.1',
             '1:1.2',
             '1:1.10',
             '1:2',
+            '1:2.0',
             '1:2.1',
             '1:2.2',
             '1:2.10',

+ 1 - 1
netbox/dcim/urls.py

@@ -278,7 +278,7 @@ urlpatterns = [
     path('rear-ports/<int:pk>/edit/', views.RearPortEditView.as_view(), name='rearport_edit'),
     path('rear-ports/<int:pk>/delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'),
     path('rear-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}),
-    # path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'),
+    path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'),
 
     # Device bays
     path('device-bays/', views.DeviceBayListView.as_view(), name='devicebay_list'),

+ 37 - 9
netbox/dcim/views.py

@@ -32,6 +32,7 @@ from virtualization.models import VirtualMachine
 from . import filters, forms, tables
 from .choices import DeviceFaceChoices
 from .constants import NONCONNECTABLE_IFACE_TYPES
+from .exceptions import CableTraceSplit
 from .models import (
     Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
@@ -1929,7 +1930,7 @@ class DeviceBulkAddConsolePortView(PermissionRequiredMixin, BulkComponentCreateV
     permission_required = 'dcim.add_consoleport'
     parent_model = Device
     parent_field = 'device'
-    form = forms.DeviceBulkAddComponentForm
+    form = forms.ConsolePortBulkCreateForm
     model = ConsolePort
     model_form = forms.ConsolePortForm
     filterset = filters.DeviceFilterSet
@@ -1941,7 +1942,7 @@ class DeviceBulkAddConsoleServerPortView(PermissionRequiredMixin, BulkComponentC
     permission_required = 'dcim.add_consoleserverport'
     parent_model = Device
     parent_field = 'device'
-    form = forms.DeviceBulkAddComponentForm
+    form = forms.ConsoleServerPortBulkCreateForm
     model = ConsoleServerPort
     model_form = forms.ConsoleServerPortForm
     filterset = filters.DeviceFilterSet
@@ -1953,7 +1954,7 @@ class DeviceBulkAddPowerPortView(PermissionRequiredMixin, BulkComponentCreateVie
     permission_required = 'dcim.add_powerport'
     parent_model = Device
     parent_field = 'device'
-    form = forms.DeviceBulkAddComponentForm
+    form = forms.PowerPortBulkCreateForm
     model = PowerPort
     model_form = forms.PowerPortForm
     filterset = filters.DeviceFilterSet
@@ -1965,7 +1966,7 @@ class DeviceBulkAddPowerOutletView(PermissionRequiredMixin, BulkComponentCreateV
     permission_required = 'dcim.add_poweroutlet'
     parent_model = Device
     parent_field = 'device'
-    form = forms.DeviceBulkAddComponentForm
+    form = forms.PowerOutletBulkCreateForm
     model = PowerOutlet
     model_form = forms.PowerOutletForm
     filterset = filters.DeviceFilterSet
@@ -1977,7 +1978,7 @@ class DeviceBulkAddInterfaceView(PermissionRequiredMixin, BulkComponentCreateVie
     permission_required = 'dcim.add_interface'
     parent_model = Device
     parent_field = 'device'
-    form = forms.DeviceBulkAddInterfaceForm
+    form = forms.InterfaceBulkCreateForm
     model = Interface
     model_form = forms.InterfaceForm
     filterset = filters.DeviceFilterSet
@@ -1985,11 +1986,35 @@ class DeviceBulkAddInterfaceView(PermissionRequiredMixin, BulkComponentCreateVie
     default_return_url = 'dcim:device_list'
 
 
+# class DeviceBulkAddFrontPortView(PermissionRequiredMixin, BulkComponentCreateView):
+#     permission_required = 'dcim.add_frontport'
+#     parent_model = Device
+#     parent_field = 'device'
+#     form = forms.FrontPortBulkCreateForm
+#     model = FrontPort
+#     model_form = forms.FrontPortForm
+#     filterset = filters.DeviceFilterSet
+#     table = tables.DeviceTable
+#     default_return_url = 'dcim:device_list'
+
+
+class DeviceBulkAddRearPortView(PermissionRequiredMixin, BulkComponentCreateView):
+    permission_required = 'dcim.add_rearport'
+    parent_model = Device
+    parent_field = 'device'
+    form = forms.RearPortBulkCreateForm
+    model = RearPort
+    model_form = forms.RearPortForm
+    filterset = filters.DeviceFilterSet
+    table = tables.DeviceTable
+    default_return_url = 'dcim:device_list'
+
+
 class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, BulkComponentCreateView):
     permission_required = 'dcim.add_devicebay'
     parent_model = Device
     parent_field = 'device'
-    form = forms.DeviceBulkAddComponentForm
+    form = forms.DeviceBayBulkCreateForm
     model = DeviceBay
     model_form = forms.DeviceBayForm
     filterset = filters.DeviceFilterSet
@@ -2033,12 +2058,15 @@ class CableTraceView(PermissionRequiredMixin, View):
     def get(self, request, model, pk):
 
         obj = get_object_or_404(model, pk=pk)
-        trace = obj.trace()
-        total_length = sum([entry[1]._abs_length for entry in trace if entry[1] and entry[1]._abs_length])
+        path, split_ends = obj.trace()
+        total_length = sum(
+            [entry[1]._abs_length for entry in path if entry[1] and entry[1]._abs_length]
+        )
 
         return render(request, 'dcim/cable_trace.html', {
             'obj': obj,
-            'trace': trace,
+            'trace': path,
+            'split_ends': split_ends,
             'total_length': total_length,
         })
 

+ 10 - 4
netbox/ipam/api/serializers.py

@@ -90,8 +90,7 @@ class VLANGroupSerializer(ValidatedModelSerializer):
         if data.get('site', None):
             for field in ['name', 'slug']:
                 validator = UniqueTogetherValidator(queryset=VLANGroup.objects.all(), fields=('site', field))
-                validator.set_context(self)
-                validator(data)
+                validator(data, self)
 
         # Enforce model validation
         super().validate(data)
@@ -122,8 +121,7 @@ class VLANSerializer(TaggitSerializer, CustomFieldModelSerializer):
         if data.get('group', None):
             for field in ['vid', 'name']:
                 validator = UniqueTogetherValidator(queryset=VLAN.objects.all(), fields=('group', field))
-                validator.set_context(self)
-                validator(data)
+                validator(data, self)
 
         # Enforce model validation
         super().validate(data)
@@ -185,6 +183,10 @@ class AvailablePrefixSerializer(serializers.Serializer):
     """
     Representation of a prefix which does not exist in the database.
     """
+    family = serializers.IntegerField(read_only=True)
+    prefix = serializers.CharField(read_only=True)
+    vrf = NestedVRFSerializer(read_only=True)
+
     def to_representation(self, instance):
         if self.context.get('vrf'):
             vrf = NestedVRFSerializer(self.context['vrf'], context={'request': self.context['request']}).data
@@ -248,6 +250,10 @@ class AvailableIPSerializer(serializers.Serializer):
     """
     Representation of an IP address which does not exist in the database.
     """
+    family = serializers.IntegerField(read_only=True)
+    address = serializers.CharField(read_only=True)
+    vrf = NestedVRFSerializer(read_only=True)
+
     def to_representation(self, instance):
         if self.context.get('vrf'):
             vrf = NestedVRFSerializer(self.context['vrf'], context={'request': self.context['request']}).data

+ 13 - 0
netbox/ipam/api/views.py

@@ -2,6 +2,7 @@ from django.conf import settings
 from django.db.models import Count
 from django.shortcuts import get_object_or_404
 from django_pglocks import advisory_lock
+from drf_yasg.utils import swagger_auto_schema
 from rest_framework import status
 from rest_framework.decorators import action
 from rest_framework.exceptions import PermissionDenied
@@ -73,6 +74,12 @@ class PrefixViewSet(CustomFieldModelViewSet):
     serializer_class = serializers.PrefixSerializer
     filterset_class = filters.PrefixFilterSet
 
+    @swagger_auto_schema(
+        methods=['get', 'post'],
+        responses={
+            200: serializers.AvailablePrefixSerializer(many=True),
+        }
+    )
     @action(detail=True, url_path='available-prefixes', methods=['get', 'post'])
     @advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes'])
     def available_prefixes(self, request, pk=None):
@@ -151,6 +158,12 @@ class PrefixViewSet(CustomFieldModelViewSet):
 
             return Response(serializer.data)
 
+    @swagger_auto_schema(
+        methods=['get', 'post'],
+        responses={
+            200: serializers.AvailableIPSerializer(many=True),
+        }
+    )
     @action(detail=True, url_path='available-ips', methods=['get', 'post'])
     @advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
     def available_ips(self, request, pk=None):

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

@@ -785,6 +785,7 @@ class VLANGroupTest(APITestCase):
 
         super().setUp()
 
+        self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
         self.vlangroup1 = VLANGroup.objects.create(name='Test VLAN Group 1', slug='test-vlan-group-1')
         self.vlangroup2 = VLANGroup.objects.create(name='Test VLAN Group 2', slug='test-vlan-group-2')
         self.vlangroup3 = VLANGroup.objects.create(name='Test VLAN Group 3', slug='test-vlan-group-3')
@@ -818,6 +819,7 @@ class VLANGroupTest(APITestCase):
         data = {
             'name': 'Test VLAN Group 4',
             'slug': 'test-vlan-group-4',
+            'site': self.site1.pk,
         }
 
         url = reverse('ipam-api:vlangroup-list')
@@ -886,10 +888,10 @@ class VLANTest(APITestCase):
 
         super().setUp()
 
+        self.group1 = VLANGroup.objects.create(name='Test VLAN Group 1', slug='test-vlan-group-1')
         self.vlan1 = VLAN.objects.create(vid=1, name='Test VLAN 1')
         self.vlan2 = VLAN.objects.create(vid=2, name='Test VLAN 2')
         self.vlan3 = VLAN.objects.create(vid=3, name='Test VLAN 3')
-
         self.prefix1 = Prefix.objects.create(prefix=IPNetwork('192.168.1.0/24'))
 
     def test_get_vlan(self):
@@ -921,6 +923,7 @@ class VLANTest(APITestCase):
         data = {
             'vid': 4,
             'name': 'Test VLAN 4',
+            'group': self.group1.pk,
         }
 
         url = reverse('ipam-api:vlan-list')

+ 8 - 14
netbox/netbox/configuration.example.py

@@ -178,8 +178,14 @@ PAGINATE_COUNT = 50
 # Enable installed plugins. Add the name of each plugin to the list.
 PLUGINS = []
 
-# Configure enabled plugins. This should be a dictionary of dictionaries, mapping each plugin by name to its configuration parameters.
-PLUGINS_CONFIG = {}
+# Plugins configuration settings. These settings are used by various plugins that the user may have installed.
+# Each key in the dictionary is the name of an installed plugin and its value is a dictionary of settings.
+# PLUGINS_CONFIG = {
+#     'my_plugin': {
+#         'foo': 'bar',
+#         'buzz': 'bazz'
+#     }
+# }
 
 # When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to
 # prefer IPv4 instead.
@@ -209,18 +215,6 @@ RELEASE_CHECK_URL = None
 # this setting is derived from the installed location.
 # SCRIPTS_ROOT = '/opt/netbox/netbox/scripts'
 
-# Enable plugin support in netbox. This setting must be enabled for any installed plugins to function.
-PLUGINS_ENABLED = False
-
-# Plugins configuration settings. These settings are used by various plugins that the user may have installed.
-# Each key in the dictionary is the name of an installed plugin and its value is a dictionary of settings.
-# PLUGINS_CONFIG = {
-#     'my_plugin': {
-#         'foo': 'bar',
-#         'buzz': 'bazz'
-#     }
-# }
-
 # By default, NetBox will store session data in the database. Alternatively, a file path can be specified here to use
 # local file storage instead. (This can be useful for enabling authentication on a standby instance with read-only
 # database access.) Note that the user as which NetBox runs must have read and write permissions to this path.

+ 13 - 8
netbox/netbox/settings.py

@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
 # Environment setup
 #
 
-VERSION = '2.8.0'
+VERSION = '2.8.1'
 
 # Hostname
 HOSTNAME = platform.node()
@@ -479,11 +479,14 @@ CACHEOPS = {
     'auth.*': {'ops': ('fetch', 'get')},
     'auth.permission': {'ops': 'all'},
     'circuits.*': {'ops': 'all'},
+    'dcim.region': None,  # MPTT models are exempt due to raw sql
+    'dcim.rackgroup': None,  # MPTT models are exempt due to raw sql
     'dcim.*': {'ops': 'all'},
     'ipam.*': {'ops': 'all'},
     'extras.*': {'ops': 'all'},
     'secrets.*': {'ops': 'all'},
     'users.*': {'ops': 'all'},
+    'tenancy.tenantgroup': None,  # MPTT models are exempt due to raw sql
     'tenancy.*': {'ops': 'all'},
     'virtualization.*': {'ops': 'all'},
 }
@@ -644,18 +647,18 @@ for plugin_name in PLUGINS:
         plugin = importlib.import_module(plugin_name)
     except ImportError:
         raise ImproperlyConfigured(
-            f"Unable to import plugin {plugin_name}: Module not found. Check that the plugin module has been "
-            f"installed within the correct Python environment."
+            "Unable to import plugin {}: Module not found. Check that the plugin module has been installed within the "
+            "correct Python environment.".format(plugin_name)
         )
 
     # Determine plugin config and add to INSTALLED_APPS.
     try:
         plugin_config = plugin.config
-        INSTALLED_APPS.append(f"{plugin_config.__module__}.{plugin_config.__name__}")
+        INSTALLED_APPS.append("{}.{}".format(plugin_config.__module__, plugin_config.__name__))
     except AttributeError:
         raise ImproperlyConfigured(
-            f"Plugin {plugin_name} does not provide a 'config' variable. This should be defined in the plugin's "
-            f"__init__.py file and point to the PluginConfig subclass."
+            "Plugin {} does not provide a 'config' variable. This should be defined in the plugin's __init__.py file "
+            "and point to the PluginConfig subclass.".format(plugin_name)
         )
 
     # Validate user-provided configuration settings and assign defaults
@@ -670,7 +673,9 @@ for plugin_name in PLUGINS:
 
     # Apply cacheops config
     if type(plugin_config.caching_config) is not dict:
-        raise ImproperlyConfigured(f"Plugin {plugin_name} caching_config must be a dictionary.")
+        raise ImproperlyConfigured(
+            "Plugin {} caching_config must be a dictionary.".format(plugin_name)
+        )
     CACHEOPS.update({
-        f"{plugin_name}.{key}": value for key, value in plugin_config.caching_config.items()
+        "{}.{}".format(plugin_name, key): value for key, value in plugin_config.caching_config.items()
     })

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

@@ -48,6 +48,50 @@
                 {% endif %}
             </div>
         </div>
-        {% if not forloop.last %}<hr />{% endif %}
+        <hr />
     {% endfor %}
+    <div class="row">
+        {% if split_ends %}
+            <div class="col-md-7 col-md-offset-3">
+                <div class="panel panel-warning">
+                    <div class="panel-heading">
+                        <strong><i class="fa fa-warning"></i> Trace Split</strong>
+                    </div>
+                    <div class="panel-body">
+                        There are multiple possible paths from this point. Select a port to continue.
+                    </div>
+                </div>
+                <div class="panel panel-default">
+                    <table class="panel-body table">
+                        <thead>
+                            <tr class="table-headings">
+                                <th>Port</th>
+                                <th>Connected</th>
+                                <th>Type</th>
+                                <th>Description</th>
+                            </tr>
+                        </thead>
+                        {% for termination in split_ends %}
+                            <tr>
+                                <td><a href="{% url 'dcim:frontport_trace' pk=termination.pk %}">{{ termination }}</a></td>
+                                <td>
+                                    {% if termination.cable %}
+                                        <i class="fa fa-check text-success" title="Yes"></i>
+                                    {% else %}
+                                        <i class="fa fa-times text-danger" title="No"></i>
+                                    {% endif %}
+                                </td>
+                                <td>{{ termination.get_type_display }}</td>
+                                <td>{{ termination.description|placeholder }}</td>
+                            </tr>
+                        {% endfor %}
+                    </table>
+                </div>
+            </div>
+        {% else %}
+            <div class="col-md-11 col-md-offset-1">
+                <h3 class="text-success text-center">Trace completed!</h3>
+            </div>
+        {% endif %}
+    </div>
 {% endblock %}

+ 1 - 0
netbox/templates/dcim/device_list.html

@@ -12,6 +12,7 @@
                 {% if perms.dcim.add_powerport %}<li><a href="{% url 'dcim:device_bulk_add_powerport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Power Ports</a></li>{% endif %}
                 {% if perms.dcim.add_poweroutlet %}<li><a href="{% url 'dcim:device_bulk_add_poweroutlet' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Power Outlets</a></li>{% endif %}
                 {% if perms.dcim.add_interface %}<li><a href="{% url 'dcim:device_bulk_add_interface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Interfaces</a></li>{% endif %}
+                {% if perms.dcim.add_rearport %}<li><a href="{% url 'dcim:device_bulk_add_rearport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Rear Ports</a></li>{% endif %}
                 {% if perms.dcim.add_devicebay %}<li><a href="{% url 'dcim:device_bulk_add_devicebay' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Device Bays</a></li>{% endif %}
             </ul>
         </div>

+ 1 - 7
netbox/templates/dcim/devicetype.html

@@ -102,13 +102,7 @@
                 <tr>
                     <td>Parent/Child</td>
                     <td>
-                        {% if devicetype.subdevice_role == True %}
-                            <label class="label label-primary">Parent</label>
-                        {% elif devicetype.subdevice_role == False %}
-                            <label class="label label-info">Child</label>
-                        {% else %}
-                            <span class="text-muted">&mdash;</span>
-                        {% endif %}
+                        {{ devicetype.get_subdevice_role_display|placeholder }}
                     </td>
                 </tr>
                 <tr>

+ 1 - 1
netbox/utilities/auth_backends.py

@@ -48,7 +48,7 @@ class RemoteUserBackend(ViewExemptModelBackend, RemoteUserBackend_):
             try:
                 group_list.append(Group.objects.get(name=name))
             except Group.DoesNotExist:
-                logging.error("Could not assign group {name} to remotely-authenticated user {user}: Group not found")
+                logging.error(f"Could not assign group {name} to remotely-authenticated user {user}: Group not found")
         if group_list:
             user.groups.add(*group_list)
             logger.debug(f"Assigned groups to remotely-authenticated user {user}: {group_list}")

+ 1 - 1
netbox/utilities/custom_inspectors.py

@@ -92,7 +92,7 @@ class CustomChoiceFieldInspector(FieldInspector):
                 value_schema = openapi.Schema(type=schema_type, enum=choice_value)
                 value_schema['x-nullable'] = True
 
-            if isinstance(choice_value[0], int):
+            if all(type(x) == int for x in [c for c in choice_value if c is not None]):
                 # Change value_schema for IPAddressFamilyChoices, RackWidthChoices
                 value_schema = openapi.Schema(type=openapi.TYPE_INTEGER, enum=choice_value)
 

+ 14 - 0
netbox/utilities/forms.py

@@ -10,6 +10,7 @@ from django.conf import settings
 from django.contrib.postgres.forms.jsonb import JSONField as _JSONField, InvalidJSONInput
 from django.db.models import Count
 from django.forms import BoundField
+from django.forms.models import fields_for_model
 from django.urls import reverse
 
 from .choices import unpack_grouped_choices
@@ -123,6 +124,19 @@ def add_blank_choice(choices):
     return ((None, '---------'),) + tuple(choices)
 
 
+def form_from_model(model, fields):
+    """
+    Return a Form class with the specified fields derived from a model. This is useful when we need a form to be used
+    for creating objects, but want to avoid the model's validation (e.g. for bulk create/edit functions). All fields
+    are marked as not required.
+    """
+    form_fields = fields_for_model(model, fields=fields)
+    for field in form_fields.values():
+        field.required = False
+
+    return type('FormFromModel', (forms.Form,), form_fields)
+
+
 #
 # Widgets
 #

+ 1 - 1
netbox/utilities/ordering.py

@@ -75,7 +75,7 @@ def naturalize_interface(value, max_length):
         if part is not None:
             output += part.rjust(6, '0')
         else:
-            output += '000000'
+            output += '......'
 
     # Finally, naturalize any remaining text and append it
     if match.group('remainder') is not None and len(output) < max_length:

+ 9 - 0
netbox/utilities/query_functions.py

@@ -0,0 +1,9 @@
+from django.db.models import F, Func
+
+
+class CollateAsChar(Func):
+    """
+    Disregard localization by collating a field as a plain character string. Helpful for ensuring predictable ordering.
+    """
+    function = 'C'
+    template = '(%(expressions)s) COLLATE "%(function)s"'

+ 19 - 16
netbox/utilities/tests/test_ordering.py

@@ -30,29 +30,32 @@ class NaturalizationTestCase(TestCase):
 
         # Original, naturalized
         data = (
+
             # IOS/JunOS-style
-            ('Gi', '9999999999999999Gi000000000000000000'),
-            ('Gi1', '9999999999999999Gi000001000000000000'),
-            ('Gi1.0', '9999999999999999Gi000001000000000000'),
-            ('Gi1.1', '9999999999999999Gi000001000000000001'),
-            ('Gi1:0', '9999999999999999Gi000001000000000000'),
+            ('Gi', '9999999999999999Gi..................'),
+            ('Gi1', '9999999999999999Gi000001............'),
+            ('Gi1.0', '9999999999999999Gi000001......000000'),
+            ('Gi1.1', '9999999999999999Gi000001......000001'),
+            ('Gi1:0', '9999999999999999Gi000001000000......'),
             ('Gi1:0.0', '9999999999999999Gi000001000000000000'),
             ('Gi1:0.1', '9999999999999999Gi000001000000000001'),
-            ('Gi1:1', '9999999999999999Gi000001000001000000'),
+            ('Gi1:1', '9999999999999999Gi000001000001......'),
             ('Gi1:1.0', '9999999999999999Gi000001000001000000'),
             ('Gi1:1.1', '9999999999999999Gi000001000001000001'),
-            ('Gi1/2', '0001999999999999Gi000002000000000000'),
-            ('Gi1/2/3', '0001000299999999Gi000003000000000000'),
-            ('Gi1/2/3/4', '0001000200039999Gi000004000000000000'),
-            ('Gi1/2/3/4/5', '0001000200030004Gi000005000000000000'),
-            ('Gi1/2/3/4/5:6', '0001000200030004Gi000005000006000000'),
+            ('Gi1/2', '0001999999999999Gi000002............'),
+            ('Gi1/2/3', '0001000299999999Gi000003............'),
+            ('Gi1/2/3/4', '0001000200039999Gi000004............'),
+            ('Gi1/2/3/4/5', '0001000200030004Gi000005............'),
+            ('Gi1/2/3/4/5:6', '0001000200030004Gi000005000006......'),
             ('Gi1/2/3/4/5:6.7', '0001000200030004Gi000005000006000007'),
+
             # Generic
-            ('Interface 1', '9999999999999999Interface 000001000000000000'),
-            ('Interface 1 (other)', '9999999999999999Interface 000001000000000000 (other)'),
-            ('Interface 99', '9999999999999999Interface 000099000000000000'),
-            ('PCIe1-p1', '9999999999999999PCIe000001000000000000-p00000001'),
-            ('PCIe1-p99', '9999999999999999PCIe000001000000000000-p00000099'),
+            ('Interface 1', '9999999999999999Interface 000001............'),
+            ('Interface 1 (other)', '9999999999999999Interface 000001............ (other)'),
+            ('Interface 99', '9999999999999999Interface 000099............'),
+            ('PCIe1-p1', '9999999999999999PCIe000001............-p00000001'),
+            ('PCIe1-p99', '9999999999999999PCIe000001............-p00000099'),
+
         )
 
         for origin, naturalized in data:

+ 24 - 17
netbox/utilities/views.py

@@ -972,25 +972,32 @@ class BulkComponentCreateView(GetReturnURLMixin, View):
                 new_components = []
                 data = deepcopy(form.cleaned_data)
 
-                for obj in data['pk']:
-
-                    names = data['name_pattern']
-                    for name in names:
-                        component_data = {
-                            self.parent_field: obj.pk,
-                            'name': name,
-                        }
-                        component_data.update(data)
-                        component_form = self.model_form(component_data)
-                        if component_form.is_valid():
-                            new_components.append(component_form.save(commit=False))
-                        else:
-                            for field, errors in component_form.errors.as_data().items():
-                                for e in errors:
-                                    form.add_error(field, '{} {}: {}'.format(obj, name, ', '.join(e)))
+                try:
+                    with transaction.atomic():
+
+                        for obj in data['pk']:
+
+                            names = data['name_pattern']
+                            for name in names:
+                                component_data = {
+                                    self.parent_field: obj.pk,
+                                    'name': name,
+                                }
+                                component_data.update(data)
+                                component_form = self.model_form(component_data)
+                                if component_form.is_valid():
+                                    instance = component_form.save()
+                                    logger.debug(f"Created {instance} on {instance.parent}")
+                                    new_components.append(instance)
+                                else:
+                                    for field, errors in component_form.errors.as_data().items():
+                                        for e in errors:
+                                            form.add_error(field, '{} {}: {}'.format(obj, name, ', '.join(e)))
+
+                except IntegrityError:
+                    pass
 
                 if not form.errors:
-                    self.model.objects.bulk_create(new_components)
                     msg = "Added {} {} to {} {}.".format(
                         len(new_components),
                         model_name,

+ 11 - 16
netbox/virtualization/forms.py

@@ -15,7 +15,8 @@ from tenancy.models import Tenant
 from utilities.forms import (
     add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
     CommentField, ConfirmationForm, CSVChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
-    ExpandableNameField, JSONField, SlugField, SmallTextarea, StaticSelect2, StaticSelect2Multiple, TagFilterField,
+    ExpandableNameField, form_from_model, JSONField, SlugField, SmallTextarea, StaticSelect2, StaticSelect2Multiple,
+    TagFilterField,
 )
 from .choices import *
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@@ -827,24 +828,18 @@ class VirtualMachineBulkAddComponentForm(BootstrapMixin, forms.Form):
         label='Name'
     )
 
+    def clean_tags(self):
+        # Because we're feeding TagField data (on the bulk edit form) to another TagField (on the model form), we
+        # must first convert the list of tags to a string.
+        return ','.join(self.cleaned_data.get('tags'))
 
-class VirtualMachineBulkAddInterfaceForm(VirtualMachineBulkAddComponentForm):
+
+class InterfaceBulkCreateForm(
+    form_from_model(Interface, ['enabled', 'mtu', 'description', 'tags']),
+    VirtualMachineBulkAddComponentForm
+):
     type = forms.ChoiceField(
         choices=VMInterfaceTypeChoices,
         initial=VMInterfaceTypeChoices.TYPE_VIRTUAL,
         widget=forms.HiddenInput()
     )
-    enabled = forms.BooleanField(
-        required=False,
-        initial=True
-    )
-    mtu = forms.IntegerField(
-        required=False,
-        min_value=INTERFACE_MTU_MIN,
-        max_value=INTERFACE_MTU_MAX,
-        label='MTU'
-    )
-    description = forms.CharField(
-        max_length=100,
-        required=False
-    )

+ 1 - 1
netbox/virtualization/views.py

@@ -366,7 +366,7 @@ class VirtualMachineBulkAddInterfaceView(PermissionRequiredMixin, BulkComponentC
     permission_required = 'dcim.add_interface'
     parent_model = VirtualMachine
     parent_field = 'virtual_machine'
-    form = forms.VirtualMachineBulkAddInterfaceForm
+    form = forms.InterfaceBulkCreateForm
     model = Interface
     model_form = forms.InterfaceForm
     filterset = filters.VirtualMachineFilterSet