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

Merge pull request #5930 from netbox-community/1519-interface-parent

Closes #1519: Enable parent assignment for interfaces
Jeremy Stretch 5 лет назад
Родитель
Сommit
1c66733b8a

+ 6 - 0
docs/release-notes/version-2.11.md

@@ -6,6 +6,10 @@
 
 
 ### New Features
 ### New Features
 
 
+#### Parent Interface Assignments ([#1519](https://github.com/netbox-community/netbox/issues/1519))
+
+Virtual interfaces can now be assigned to a "parent" physical interface, by setting the `parent` field on the Interface model. This is helpful for associating subinterfaces with their physical counterpart. For example, you might assign virtual interfaces Gi0/0.100 and Gi0/0.200 to the physical interface Gi0/0.
+
 #### Mark as Connected Without a Cable ([#3648](https://github.com/netbox-community/netbox/issues/3648))
 #### Mark as Connected Without a Cable ([#3648](https://github.com/netbox-community/netbox/issues/3648))
 
 
 Cable termination objects (circuit terminations, power feeds, and most device components) can now be marked as "connected" without actually attaching a cable. This helps simplify the process of modeling an infrastructure boundary where you don't necessarily know or care what is connected to the far end of a cable, but still need to designate the near end termination.
 Cable termination objects (circuit terminations, power feeds, and most device components) can now be marked as "connected" without actually attaching a cable. This helps simplify the process of modeling an infrastructure boundary where you don't necessarily know or care what is connected to the far end of a cable, but still need to designate the near end termination.
@@ -58,6 +62,8 @@ The ObjectChange model (which is used to record the creation, modification, and
   * The `/dcim/rack-groups/` endpoint is now `/dcim/locations/`
   * The `/dcim/rack-groups/` endpoint is now `/dcim/locations/`
 * dcim.Device
 * dcim.Device
   * Added the `location` field
   * Added the `location` field
+* dcim.Interface
+  * Added the `parent` field
 * dcim.PowerPanel
 * dcim.PowerPanel
   * Renamed `rack_group` field to `location`
   * Renamed `rack_group` field to `location`
 * dcim.Rack
 * dcim.Rack

+ 1 - 1
netbox/circuits/models.py

@@ -295,7 +295,7 @@ class CircuitTermination(ChangeLoggingMixin, BigIDModel, PathEndpoint, CableTerm
         return super().to_objectchange(action, related_object=circuit)
         return super().to_objectchange(action, related_object=circuit)
 
 
     @property
     @property
-    def parent(self):
+    def parent_object(self):
         return self.circuit
         return self.circuit
 
 
     def get_peer_termination(self):
     def get_peer_termination(self):

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

@@ -598,6 +598,7 @@ class InterfaceSerializer(TaggedObjectSerializer, CableTerminationSerializer, Co
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     type = ChoiceField(choices=InterfaceTypeChoices)
     type = ChoiceField(choices=InterfaceTypeChoices)
+    parent = NestedInterfaceSerializer(required=False, allow_null=True)
     lag = NestedInterfaceSerializer(required=False, allow_null=True)
     lag = NestedInterfaceSerializer(required=False, allow_null=True)
     mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
     mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
     untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
     untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
@@ -613,10 +614,11 @@ class InterfaceSerializer(TaggedObjectSerializer, CableTerminationSerializer, Co
     class Meta:
     class Meta:
         model = Interface
         model = Interface
         fields = [
         fields = [
-            'id', 'url', 'device', 'name', 'label', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only',
-            'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'cable_peer',
-            'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags',
-            'custom_fields', 'created', 'last_updated', 'count_ipaddresses', '_occupied',
+            'id', 'url', 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address',
+            'mgmt_only', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable',
+            'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type',
+            'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses',
+            '_occupied',
         ]
         ]
 
 
     def validate(self, data):
     def validate(self, data):

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

@@ -522,7 +522,7 @@ class PowerOutletViewSet(PathEndpointMixin, ModelViewSet):
 
 
 class InterfaceViewSet(PathEndpointMixin, ModelViewSet):
 class InterfaceViewSet(PathEndpointMixin, ModelViewSet):
     queryset = Interface.objects.prefetch_related(
     queryset = Interface.objects.prefetch_related(
-        'device', '_path__destination', 'cable', '_cable_peer', 'ip_addresses', 'tags'
+        'device', 'parent', 'lag', '_path__destination', 'cable', '_cable_peer', 'ip_addresses', 'tags'
     )
     )
     serializer_class = serializers.InterfaceSerializer
     serializer_class = serializers.InterfaceSerializer
     filterset_class = filters.InterfaceFilterSet
     filterset_class = filters.InterfaceFilterSet

+ 5 - 0
netbox/dcim/filters.py

@@ -844,6 +844,11 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati
         method='filter_kind',
         method='filter_kind',
         label='Kind of interface',
         label='Kind of interface',
     )
     )
+    parent_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='parent',
+        queryset=Interface.objects.all(),
+        label='Parent interface (ID)',
+    )
     lag_id = django_filters.ModelMultipleChoiceFilter(
     lag_id = django_filters.ModelMultipleChoiceFilter(
         field_name='lag',
         field_name='lag',
         queryset=Interface.objects.all(),
         queryset=Interface.objects.all(),

+ 74 - 36
netbox/dcim/forms.py

@@ -2802,6 +2802,24 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
 
 
 
 
 class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm):
 class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm):
+    parent = DynamicModelChoiceField(
+        queryset=Interface.objects.all(),
+        required=False,
+        label='Parent interface',
+        display_field='display_name',
+        query_params={
+            'kind': 'physical',
+        }
+    )
+    lag = DynamicModelChoiceField(
+        queryset=Interface.objects.all(),
+        required=False,
+        label='LAG interface',
+        display_field='display_name',
+        query_params={
+            'type': 'lag',
+        }
+    )
     untagged_vlan = DynamicModelChoiceField(
     untagged_vlan = DynamicModelChoiceField(
         queryset=VLAN.objects.all(),
         queryset=VLAN.objects.all(),
         required=False,
         required=False,
@@ -2830,13 +2848,12 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm):
     class Meta:
     class Meta:
         model = Interface
         model = Interface
         fields = [
         fields = [
-            'device', 'name', 'label', 'type', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'mark_connected',
-            'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags',
+            'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mac_address', 'mtu', 'mgmt_only',
+            'mark_connected', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags',
         ]
         ]
         widgets = {
         widgets = {
             'device': forms.HiddenInput(),
             'device': forms.HiddenInput(),
             'type': StaticSelect2(),
             'type': StaticSelect2(),
-            'lag': StaticSelect2(),
             'mode': StaticSelect2(),
             'mode': StaticSelect2(),
         }
         }
         labels = {
         labels = {
@@ -2849,19 +2866,11 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm):
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
-        if self.is_bound:
-            device = Device.objects.get(pk=self.data['device'])
-        else:
-            device = self.instance.device
+        device = Device.objects.get(pk=self.data['device']) if self.is_bound else self.instance.device
 
 
-        # Limit LAG choices to interfaces belonging to this device or a peer VC member
-        device_query = Q(device=device)
-        if device.virtual_chassis:
-            device_query |= Q(device__virtual_chassis=device.virtual_chassis)
-        self.fields['lag'].queryset = Interface.objects.filter(
-            device_query,
-            type=InterfaceTypeChoices.TYPE_LAG
-        ).exclude(pk=self.instance.pk)
+        # Restrict parent/LAG interface assignment by device
+        self.fields['parent'].widget.add_query_param('device_id', device.pk)
+        self.fields['lag'].widget.add_query_param('device_id', device.pk)
 
 
         # Add current site to VLANs query params
         # Add current site to VLANs query params
         self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk)
         self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk)
@@ -2878,11 +2887,23 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm):
         required=False,
         required=False,
         initial=True
         initial=True
     )
     )
-    lag = forms.ModelChoiceField(
+    parent = DynamicModelChoiceField(
         queryset=Interface.objects.all(),
         queryset=Interface.objects.all(),
         required=False,
         required=False,
-        label='Parent LAG',
-        widget=StaticSelect2(),
+        display_field='display_name',
+        query_params={
+            'device_id': '$device',
+            'kind': 'physical',
+        }
+    )
+    lag = DynamicModelChoiceField(
+        queryset=Interface.objects.all(),
+        required=False,
+        display_field='display_name',
+        query_params={
+            'device_id': '$device',
+            'type': 'lag',
+        }
     )
     )
     mtu = forms.IntegerField(
     mtu = forms.IntegerField(
         required=False,
         required=False,
@@ -2923,23 +2944,17 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm):
         }
         }
     )
     )
     field_order = (
     field_order = (
-        'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'description',
-        'mgmt_only', 'mark_connected', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags'
+        'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address',
+        'description', 'mgmt_only', 'mark_connected', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags'
     )
     )
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
-        # Limit LAG choices to interfaces belonging to this device or a peer VC member
+        # Add current site to VLANs query params
         device = Device.objects.get(
         device = Device.objects.get(
             pk=self.initial.get('device') or self.data.get('device')
             pk=self.initial.get('device') or self.data.get('device')
         )
         )
-        device_query = Q(device=device)
-        if device.virtual_chassis:
-            device_query |= Q(device__virtual_chassis=device.virtual_chassis)
-        self.fields['lag'].queryset = Interface.objects.filter(device_query, type=InterfaceTypeChoices.TYPE_LAG)
-
-        # Add current site to VLANs query params
         self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk)
         self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk)
         self.fields['tagged_vlans'].widget.add_query_param('site_id', device.site.pk)
         self.fields['tagged_vlans'].widget.add_query_param('site_id', device.site.pk)
 
 
@@ -2956,7 +2971,7 @@ class InterfaceBulkCreateForm(
 
 
 class InterfaceBulkEditForm(
 class InterfaceBulkEditForm(
     form_from_model(Interface, [
     form_from_model(Interface, [
-        'label', 'type', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'mode'
+        'label', 'type', 'parent', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'mode',
     ]),
     ]),
     BootstrapMixin,
     BootstrapMixin,
     AddRemoveTagsForm,
     AddRemoveTagsForm,
@@ -2976,6 +2991,22 @@ class InterfaceBulkEditForm(
         required=False,
         required=False,
         widget=BulkEditNullBooleanSelect
         widget=BulkEditNullBooleanSelect
     )
     )
+    parent = DynamicModelChoiceField(
+        queryset=Interface.objects.all(),
+        required=False,
+        display_field='display_name',
+        query_params={
+            'kind': 'physical',
+        }
+    )
+    lag = DynamicModelChoiceField(
+        queryset=Interface.objects.all(),
+        required=False,
+        display_field='display_name',
+        query_params={
+            'type': 'lag',
+        }
+    )
     mgmt_only = forms.NullBooleanField(
     mgmt_only = forms.NullBooleanField(
         required=False,
         required=False,
         widget=BulkEditNullBooleanSelect,
         widget=BulkEditNullBooleanSelect,
@@ -3006,25 +3037,24 @@ class InterfaceBulkEditForm(
 
 
     class Meta:
     class Meta:
         nullable_fields = [
         nullable_fields = [
-            'label', 'lag', 'mac_address', 'mtu', 'description', 'mode', 'untagged_vlan', 'tagged_vlans'
+            'label', 'parent', 'lag', 'mac_address', 'mtu', 'description', 'mode', 'untagged_vlan', 'tagged_vlans'
         ]
         ]
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super().__init__(*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:
         if 'device' in self.initial:
             device = Device.objects.filter(pk=self.initial['device']).first()
             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
-            )
+
+            # Restrict parent/LAG interface assignment by device
+            self.fields['parent'].widget.add_query_param('device_id', device.pk)
+            self.fields['lag'].widget.add_query_param('device_id', device.pk)
 
 
             # Add current site to VLANs query params
             # Add current site to VLANs query params
             self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk)
             self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk)
             self.fields['tagged_vlans'].widget.add_query_param('site_id', device.site.pk)
             self.fields['tagged_vlans'].widget.add_query_param('site_id', device.site.pk)
+
         else:
         else:
-            # See 4523
+            # See #4523
             if 'pk' in self.initial:
             if 'pk' in self.initial:
                 site = None
                 site = None
                 interfaces = Interface.objects.filter(pk__in=self.initial['pk']).prefetch_related('device__site')
                 interfaces = Interface.objects.filter(pk__in=self.initial['pk']).prefetch_related('device__site')
@@ -3042,6 +3072,8 @@ class InterfaceBulkEditForm(
                     self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk)
                     self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk)
                     self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk)
                     self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk)
 
 
+            self.fields['parent'].choices = ()
+            self.fields['parent'].widget.attrs['disabled'] = True
             self.fields['lag'].choices = ()
             self.fields['lag'].choices = ()
             self.fields['lag'].widget.attrs['disabled'] = True
             self.fields['lag'].widget.attrs['disabled'] = True
 
 
@@ -3064,6 +3096,12 @@ class InterfaceCSVForm(CustomFieldModelCSVForm):
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         to_field_name='name'
         to_field_name='name'
     )
     )
+    parent = CSVModelChoiceField(
+        queryset=Interface.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Parent interface'
+    )
     lag = CSVModelChoiceField(
     lag = CSVModelChoiceField(
         queryset=Interface.objects.all(),
         queryset=Interface.objects.all(),
         required=False,
         required=False,

+ 17 - 0
netbox/dcim/migrations/0129_interface_parent.py

@@ -0,0 +1,17 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0128_device_location_populate'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='interface',
+            name='parent',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='child_interfaces', to='dcim.interface'),
+        ),
+    ]

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

@@ -4,13 +4,11 @@ from django.db import models
 
 
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
-from extras.models import ObjectChange
 from extras.utils import extras_features
 from extras.utils import extras_features
 from netbox.models import BigIDModel, ChangeLoggingMixin
 from netbox.models import BigIDModel, ChangeLoggingMixin
 from utilities.fields import NaturalOrderingField
 from utilities.fields import NaturalOrderingField
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
 from utilities.ordering import naturalize_interface
 from utilities.ordering import naturalize_interface
-from utilities.utils import serialize_object
 from .device_components import (
 from .device_components import (
     ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, PowerOutlet, PowerPort, RearPort,
     ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, PowerOutlet, PowerPort, RearPort,
 )
 )

+ 47 - 18
netbox/dcim/models/device_components.py

@@ -11,7 +11,7 @@ from taggit.managers import TaggableManager
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.fields import MACAddressField
 from dcim.fields import MACAddressField
-from extras.models import ObjectChange, TaggedItem
+from extras.models import TaggedItem
 from extras.utils import extras_features
 from extras.utils import extras_features
 from netbox.models import PrimaryModel
 from netbox.models import PrimaryModel
 from utilities.fields import NaturalOrderingField
 from utilities.fields import NaturalOrderingField
@@ -19,7 +19,6 @@ from utilities.mptt import TreeManager
 from utilities.ordering import naturalize_interface
 from utilities.ordering import naturalize_interface
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
 from utilities.query_functions import CollateAsChar
 from utilities.query_functions import CollateAsChar
-from utilities.utils import serialize_object
 
 
 
 
 __all__ = (
 __all__ = (
@@ -85,8 +84,8 @@ class ComponentModel(PrimaryModel):
         return super().to_objectchange(action, related_object=device)
         return super().to_objectchange(action, related_object=device)
 
 
     @property
     @property
-    def parent(self):
-        return getattr(self, 'device', None)
+    def parent_object(self):
+        return self.device
 
 
 
 
 class CableTermination(models.Model):
 class CableTermination(models.Model):
@@ -153,6 +152,10 @@ class CableTermination(models.Model):
     def _occupied(self):
     def _occupied(self):
         return bool(self.mark_connected or self.cable_id)
         return bool(self.mark_connected or self.cable_id)
 
 
+    @property
+    def parent_object(self):
+        raise NotImplementedError("CableTermination models must implement parent_object()")
+
 
 
 class PathEndpoint(models.Model):
 class PathEndpoint(models.Model):
     """
     """
@@ -208,7 +211,7 @@ class PathEndpoint(models.Model):
 #
 #
 
 
 @extras_features('custom_fields', 'export_templates', 'webhooks')
 @extras_features('custom_fields', 'export_templates', 'webhooks')
-class ConsolePort(CableTermination, PathEndpoint, ComponentModel):
+class ConsolePort(ComponentModel, CableTermination, PathEndpoint):
     """
     """
     A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
     A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
     """
     """
@@ -252,7 +255,7 @@ class ConsolePort(CableTermination, PathEndpoint, ComponentModel):
 #
 #
 
 
 @extras_features('custom_fields', 'export_templates', 'webhooks')
 @extras_features('custom_fields', 'export_templates', 'webhooks')
-class ConsoleServerPort(CableTermination, PathEndpoint, ComponentModel):
+class ConsoleServerPort(ComponentModel, CableTermination, PathEndpoint):
     """
     """
     A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
     A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
     """
     """
@@ -296,7 +299,7 @@ class ConsoleServerPort(CableTermination, PathEndpoint, ComponentModel):
 #
 #
 
 
 @extras_features('custom_fields', 'export_templates', 'webhooks')
 @extras_features('custom_fields', 'export_templates', 'webhooks')
-class PowerPort(CableTermination, PathEndpoint, ComponentModel):
+class PowerPort(ComponentModel, CableTermination, PathEndpoint):
     """
     """
     A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
     A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
     """
     """
@@ -408,7 +411,7 @@ class PowerPort(CableTermination, PathEndpoint, ComponentModel):
 #
 #
 
 
 @extras_features('custom_fields', 'export_templates', 'webhooks')
 @extras_features('custom_fields', 'export_templates', 'webhooks')
-class PowerOutlet(CableTermination, PathEndpoint, ComponentModel):
+class PowerOutlet(ComponentModel, CableTermination, PathEndpoint):
     """
     """
     A physical power outlet (output) within a Device which provides power to a PowerPort.
     A physical power outlet (output) within a Device which provides power to a PowerPort.
     """
     """
@@ -509,7 +512,7 @@ class BaseInterface(models.Model):
 
 
 
 
 @extras_features('custom_fields', 'export_templates', 'webhooks')
 @extras_features('custom_fields', 'export_templates', 'webhooks')
-class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
+class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
     """
     """
     A network interface within a Device. A physical Interface can connect to exactly one other Interface.
     A network interface within a Device. A physical Interface can connect to exactly one other Interface.
     """
     """
@@ -520,6 +523,14 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
         max_length=100,
         max_length=100,
         blank=True
         blank=True
     )
     )
+    parent = models.ForeignKey(
+        to='self',
+        on_delete=models.SET_NULL,
+        related_name='child_interfaces',
+        null=True,
+        blank=True,
+        verbose_name='Parent interface'
+    )
     lag = models.ForeignKey(
     lag = models.ForeignKey(
         to='self',
         to='self',
         on_delete=models.SET_NULL,
         on_delete=models.SET_NULL,
@@ -560,8 +571,8 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
     tags = TaggableManager(through=TaggedItem)
     tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = [
     csv_headers = [
-        'device', 'name', 'label', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', 'mtu', 'mgmt_only',
-        'description', 'mode',
+        'device', 'name', 'label', 'parent', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', 'mtu',
+        'mgmt_only', 'description', 'mode',
     ]
     ]
 
 
     class Meta:
     class Meta:
@@ -576,6 +587,7 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
             self.device.identifier if self.device else None,
             self.device.identifier if self.device else None,
             self.name,
             self.name,
             self.label,
             self.label,
+            self.parent.name if self.parent else None,
             self.lag.name if self.lag else None,
             self.lag.name if self.lag else None,
             self.get_type_display(),
             self.get_type_display(),
             self.enabled,
             self.enabled,
@@ -599,6 +611,27 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
                         "Disconnect the interface or choose a suitable type."
                         "Disconnect the interface or choose a suitable type."
             })
             })
 
 
+        # An interface's parent must belong to the same device or virtual chassis
+        if self.parent and self.parent.device != self.device:
+            if self.device.virtual_chassis is None:
+                raise ValidationError({
+                    'parent': f"The selected parent interface ({self.parent}) belongs to a different device "
+                              f"({self.parent.device})."
+                })
+            elif self.parent.device.virtual_chassis != self.parent.virtual_chassis:
+                raise ValidationError({
+                    'parent': f"The selected parent interface ({self.parent}) belongs to {self.parent.device}, which "
+                              f"is not part of virtual chassis {self.device.virtual_chassis}."
+                })
+
+        # A physical interface cannot have a parent interface
+        if self.type != InterfaceTypeChoices.TYPE_VIRTUAL and self.parent is not None:
+            raise ValidationError({'parent': "Only virtual interfaces may be assigned to a parent interface."})
+
+        # A virtual interface cannot be a parent interface
+        if self.parent is not None and self.parent.type == InterfaceTypeChoices.TYPE_VIRTUAL:
+            raise ValidationError({'parent': "Virtual interfaces may not be parents of other interfaces."})
+
         # An interface's LAG must belong to the same device or virtual chassis
         # An interface's LAG must belong to the same device or virtual chassis
         if self.lag and self.lag.device != self.device:
         if self.lag and self.lag.device != self.device:
             if self.device.virtual_chassis is None:
             if self.device.virtual_chassis is None:
@@ -620,16 +653,12 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
             raise ValidationError({'lag': "A LAG interface cannot be its own parent."})
             raise ValidationError({'lag': "A LAG interface cannot be its own parent."})
 
 
         # Validate untagged VLAN
         # Validate untagged VLAN
-        if self.untagged_vlan and self.untagged_vlan.site not in [self.parent.site, None]:
+        if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]:
             raise ValidationError({
             raise ValidationError({
                 'untagged_vlan': "The untagged VLAN ({}) must belong to the same site as the interface's parent "
                 'untagged_vlan': "The untagged VLAN ({}) must belong to the same site as the interface's parent "
                                  "device, or it must be global".format(self.untagged_vlan)
                                  "device, or it must be global".format(self.untagged_vlan)
             })
             })
 
 
-    @property
-    def parent(self):
-        return self.device
-
     @property
     @property
     def is_connectable(self):
     def is_connectable(self):
         return self.type not in NONCONNECTABLE_IFACE_TYPES
         return self.type not in NONCONNECTABLE_IFACE_TYPES
@@ -656,7 +685,7 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
 #
 #
 
 
 @extras_features('custom_fields', 'export_templates', 'webhooks')
 @extras_features('custom_fields', 'export_templates', 'webhooks')
-class FrontPort(CableTermination, ComponentModel):
+class FrontPort(ComponentModel, CableTermination):
     """
     """
     A pass-through port on the front of a Device.
     A pass-through port on the front of a Device.
     """
     """
@@ -722,7 +751,7 @@ class FrontPort(CableTermination, ComponentModel):
 
 
 
 
 @extras_features('custom_fields', 'export_templates', 'webhooks')
 @extras_features('custom_fields', 'export_templates', 'webhooks')
-class RearPort(CableTermination, ComponentModel):
+class RearPort(ComponentModel, CableTermination):
     """
     """
     A pass-through port on the rear of a Device.
     A pass-through port on the rear of a Device.
     """
     """

+ 1 - 1
netbox/dcim/models/power.py

@@ -201,7 +201,7 @@ class PowerFeed(PrimaryModel, PathEndpoint, CableTermination):
         super().save(*args, **kwargs)
         super().save(*args, **kwargs)
 
 
     @property
     @property
-    def parent(self):
+    def parent_object(self):
         return self.power_panel
         return self.power_panel
 
 
     def get_type_class(self):
     def get_type_class(self):

+ 1 - 3
netbox/dcim/models/sites.py

@@ -8,13 +8,11 @@ from timezone_field import TimeZoneField
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.fields import ASNField
 from dcim.fields import ASNField
-from extras.models import ObjectChange, TaggedItem
+from extras.models import TaggedItem
 from extras.utils import extras_features
 from extras.utils import extras_features
 from netbox.models import NestedGroupModel, PrimaryModel
 from netbox.models import NestedGroupModel, PrimaryModel
 from utilities.fields import NaturalOrderingField
 from utilities.fields import NaturalOrderingField
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
-from utilities.mptt import TreeManager
-from utilities.utils import serialize_object
 
 
 __all__ = (
 __all__ = (
     'Region',
     'Region',

+ 9 - 5
netbox/dcim/tables/devices.py

@@ -436,6 +436,10 @@ class DeviceInterfaceTable(InterfaceTable):
                       '{% endif %}"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>',
                       '{% endif %}"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>',
         attrs={'td': {'class': 'text-nowrap'}}
         attrs={'td': {'class': 'text-nowrap'}}
     )
     )
+    parent = tables.Column(
+        linkify=True,
+        verbose_name='Parent'
+    )
     lag = tables.Column(
     lag = tables.Column(
         linkify=True,
         linkify=True,
         verbose_name='LAG'
         verbose_name='LAG'
@@ -449,13 +453,13 @@ class DeviceInterfaceTable(InterfaceTable):
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = Interface
         model = Interface
         fields = (
         fields = (
-            'pk', 'name', 'label', 'enabled', 'type', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'description',
-            'mark_connected', 'cable', 'cable_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan',
-            'tagged_vlans', 'actions',
+            'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address',
+            'description', 'mark_connected', 'cable', 'cable_peer', 'connection', 'tags', 'ip_addresses',
+            'untagged_vlan', 'tagged_vlans', 'actions',
         )
         )
         default_columns = (
         default_columns = (
-            'pk', 'name', 'label', 'enabled', 'type', 'lag', 'mtu', 'mode', 'description', 'ip_addresses', 'cable',
-            'connection', 'actions',
+            'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mtu', 'mode', 'description', 'ip_addresses',
+            'cable', 'connection', 'actions',
         )
         )
         row_attrs = {
         row_attrs = {
             'class': lambda record: record.cable.get_status_class() if record.cable else '',
             'class': lambda record: record.cable.get_status_class() if record.cable else '',

+ 2 - 2
netbox/dcim/tables/template_code.py

@@ -1,6 +1,6 @@
 CABLETERMINATION = """
 CABLETERMINATION = """
 {% if value %}
 {% if value %}
-    <a href="{{ value.parent.get_absolute_url }}">{{ value.parent }}</a>
+    <a href="{{ value.parent_object.get_absolute_url }}">{{ value.parent_object }}</a>
     <i class="mdi mdi-chevron-right"></i>
     <i class="mdi mdi-chevron-right"></i>
     <a href="{{ value.get_absolute_url }}">{{ value }}</a>
     <a href="{{ value.get_absolute_url }}">{{ value }}</a>
 {% else %}
 {% else %}
@@ -64,7 +64,7 @@ POWERFEED_CABLE = """
 """
 """
 
 
 POWERFEED_CABLETERMINATION = """
 POWERFEED_CABLETERMINATION = """
-<a href="{{ value.parent.get_absolute_url }}">{{ value.parent }}</a>
+<a href="{{ value.parent_object.get_absolute_url }}">{{ value.parent_object }}</a>
 <i class="mdi mdi-chevron-right"></i>
 <i class="mdi mdi-chevron-right"></i>
 <a href="{{ value.get_absolute_url }}">{{ value }}</a>
 <a href="{{ value.get_absolute_url }}">{{ value }}</a>
 """
 """

+ 28 - 0
netbox/dcim/tests/test_filters.py

@@ -1931,6 +1931,34 @@ class InterfaceTestCase(TestCase):
         params = {'description': ['First', 'Second']}
         params = {'description': ['First', 'Second']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_parent(self):
+        # Create child interfaces
+        parent_interface = Interface.objects.first()
+        child_interfaces = (
+            Interface(device=parent_interface.device, name='Child 1', parent=parent_interface, type=InterfaceTypeChoices.TYPE_VIRTUAL),
+            Interface(device=parent_interface.device, name='Child 2', parent=parent_interface, type=InterfaceTypeChoices.TYPE_VIRTUAL),
+            Interface(device=parent_interface.device, name='Child 3', parent=parent_interface, type=InterfaceTypeChoices.TYPE_VIRTUAL),
+        )
+        Interface.objects.bulk_create(child_interfaces)
+
+        params = {'parent_id': [parent_interface.pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
+    def test_lag(self):
+        # Create LAG members
+        device = Device.objects.first()
+        lag_interface = Interface(device=device, name='LAG', type=InterfaceTypeChoices.TYPE_LAG)
+        lag_interface.save()
+        lag_members = (
+            Interface(device=device, name='Member 1', lag=lag_interface, type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=device, name='Member 2', lag=lag_interface, type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=device, name='Member 3', lag=lag_interface, type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+        )
+        Interface.objects.bulk_create(lag_members)
+
+        params = {'lag_id': [lag_interface.pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
     def test_region(self):
     def test_region(self):
         regions = Region.objects.all()[:2]
         regions = Region.objects.all()[:2]
         params = {'region_id': [regions[0].pk, regions[1].pk]}
         params = {'region_id': [regions[0].pk, regions[1].pk]}

+ 2 - 2
netbox/dcim/views.py

@@ -2178,13 +2178,13 @@ class CableCreateView(generic.ObjectEditView):
         initial_data = {k: request.GET[k] for k in request.GET}
         initial_data = {k: request.GET[k] for k in request.GET}
 
 
         # Set initial site and rack based on side A termination (if not already set)
         # Set initial site and rack based on side A termination (if not already set)
-        termination_a_site = getattr(obj.termination_a.parent, 'site', None)
+        termination_a_site = getattr(obj.termination_a.parent_object, 'site', None)
         if termination_a_site and 'termination_b_region' not in initial_data:
         if termination_a_site and 'termination_b_region' not in initial_data:
             initial_data['termination_b_region'] = termination_a_site.region
             initial_data['termination_b_region'] = termination_a_site.region
         if 'termination_b_site' not in initial_data:
         if 'termination_b_site' not in initial_data:
             initial_data['termination_b_site'] = termination_a_site
             initial_data['termination_b_site'] = termination_a_site
         if 'termination_b_rack' not in initial_data:
         if 'termination_b_rack' not in initial_data:
-            initial_data['termination_b_rack'] = getattr(obj.termination_a.parent, 'rack', None)
+            initial_data['termination_b_rack'] = getattr(obj.termination_a.parent_object, 'rack', None)
 
 
         form = self.model_form(instance=obj, initial=initial_data)
         form = self.model_form(instance=obj, initial=initial_data)
 
 

+ 1 - 2
netbox/ipam/models/ip.py

@@ -9,7 +9,7 @@ from django.urls import reverse
 from taggit.managers import TaggableManager
 from taggit.managers import TaggableManager
 
 
 from dcim.models import Device
 from dcim.models import Device
-from extras.models import ObjectChange, TaggedItem
+from extras.models import TaggedItem
 from extras.utils import extras_features
 from extras.utils import extras_features
 from netbox.models import OrganizationalModel, PrimaryModel
 from netbox.models import OrganizationalModel, PrimaryModel
 from ipam.choices import *
 from ipam.choices import *
@@ -19,7 +19,6 @@ from ipam.managers import IPAddressManager
 from ipam.querysets import PrefixQuerySet
 from ipam.querysets import PrefixQuerySet
 from ipam.validators import DNSValidator
 from ipam.validators import DNSValidator
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
-from utilities.utils import serialize_object
 from virtualization.models import VirtualMachine
 from virtualization.models import VirtualMachine
 
 
 
 

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

@@ -102,12 +102,12 @@
                             <tr{% if cablepath.pk == path.pk %} class="info"{% endif %}>
                             <tr{% if cablepath.pk == path.pk %} class="info"{% endif %}>
                                 <td>
                                 <td>
                                     <a href="?cablepath_id={{ cablepath.pk }}">
                                     <a href="?cablepath_id={{ cablepath.pk }}">
-                                        {{ cablepath.origin.parent }} / {{ cablepath.origin }}
+                                        {{ cablepath.origin.parent_object }} / {{ cablepath.origin }}
                                     </a>
                                     </a>
                                 </td>
                                 </td>
                                 <td>
                                 <td>
                                     {% if cablepath.destination %}
                                     {% if cablepath.destination %}
-                                        {{ cablepath.destination }} ({{ cablepath.destination.parent }})
+                                        {{ cablepath.destination }} ({{ cablepath.destination.parent_object }})
                                     {% else %}
                                     {% else %}
                                         <span class="text-muted">Incomplete</span>
                                         <span class="text-muted">Incomplete</span>
                                     {% endif %}
                                     {% endif %}

+ 5 - 5
netbox/templates/dcim/inc/cabletermination.html

@@ -1,12 +1,12 @@
 <td>
 <td>
-    {% if termination.parent.provider %}
+    {% if termination.parent_object.provider %}
         <i class="mdi mdi-lightning-bolt" title="Circuit"></i>
         <i class="mdi mdi-lightning-bolt" title="Circuit"></i>
-        <a href="{{ termination.parent.get_absolute_url }}">
-            {{ termination.parent.provider }}
-            {{ termination.parent }}
+        <a href="{{ termination.parent_object.get_absolute_url }}">
+            {{ termination.parent_object.provider }}
+            {{ termination.parent_object }}
         </a>
         </a>
     {% else %}
     {% else %}
-        <a href="{{ termination.parent.get_absolute_url }}">{{ termination.parent }}</a>
+        <a href="{{ termination.parent_object.get_absolute_url }}">{{ termination.parent_object }}</a>
     {% endif %}
     {% endif %}
 </td>
 </td>
 <td>
 <td>

+ 1 - 1
netbox/templates/dcim/inc/endpoint_connection.html

@@ -1,6 +1,6 @@
 {% if path.destination_id %}
 {% if path.destination_id %}
     {% with endpoint=path.destination %}
     {% with endpoint=path.destination %}
-        <td><a href="{{ endpoint.parent.get_absolute_url }}">{{ endpoint.parent }}</a></td>
+        <td><a href="{{ endpoint.parent_object.get_absolute_url }}">{{ endpoint.parent_object }}</a></td>
         <td><a href="{{ endpoint.get_absolute_url }}">{{ endpoint }}</a></td>
         <td><a href="{{ endpoint.get_absolute_url }}">{{ endpoint }}</a></td>
     {% endwith %}
     {% endwith %}
 {% else %}
 {% else %}

+ 11 - 1
netbox/templates/dcim/interface.html

@@ -38,10 +38,20 @@
                             {% endif %}
                             {% endif %}
                         </td>
                         </td>
                     </tr>
                     </tr>
+                    <tr>
+                        <td>Parent</td>
+                        <td>
+                            {% if object.parent %}
+                                <a href="{{ object.parent.get_absolute_url }}">{{ object.parent }}</a>
+                            {% else %}
+                                <span class="text-muted">None</span>
+                            {% endif %}
+                        </td>
+                    </tr>
                     <tr>
                     <tr>
                         <td>LAG</td>
                         <td>LAG</td>
                         <td>
                         <td>
-                            {% if object.lag%}
+                            {% if object.lag %}
                                 <a href="{{ object.lag.get_absolute_url }}">{{ object.lag }}</a>
                                 <a href="{{ object.lag.get_absolute_url }}">{{ object.lag }}</a>
                             {% else %}
                             {% else %}
                                 <span class="text-muted">None</span>
                                 <span class="text-muted">None</span>

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

@@ -19,6 +19,7 @@
             {% render_field form.label %}
             {% render_field form.label %}
             {% render_field form.type %}
             {% render_field form.type %}
             {% render_field form.enabled %}
             {% render_field form.enabled %}
+            {% render_field form.parent %}
             {% render_field form.lag %}
             {% render_field form.lag %}
             {% render_field form.mac_address %}
             {% render_field form.mac_address %}
             {% render_field form.mtu %}
             {% render_field form.mtu %}

+ 1 - 1
netbox/tenancy/models.py

@@ -3,7 +3,7 @@ from django.urls import reverse
 from mptt.models import MPTTModel, TreeForeignKey
 from mptt.models import MPTTModel, TreeForeignKey
 from taggit.managers import TaggableManager
 from taggit.managers import TaggableManager
 
 
-from extras.models import ObjectChange, TaggedItem
+from extras.models import TaggedItem
 from extras.utils import extras_features
 from extras.utils import extras_features
 from netbox.models import NestedGroupModel, PrimaryModel
 from netbox.models import NestedGroupModel, PrimaryModel
 from utilities.mptt import TreeManager
 from utilities.mptt import TreeManager

+ 1 - 1
netbox/virtualization/models.py

@@ -6,7 +6,7 @@ from django.urls import reverse
 from taggit.managers import TaggableManager
 from taggit.managers import TaggableManager
 
 
 from dcim.models import BaseInterface, Device
 from dcim.models import BaseInterface, Device
-from extras.models import ConfigContextModel, ObjectChange, TaggedItem
+from extras.models import ConfigContextModel, TaggedItem
 from extras.querysets import ConfigContextModelQuerySet
 from extras.querysets import ConfigContextModelQuerySet
 from extras.utils import extras_features
 from extras.utils import extras_features
 from netbox.models import BigIDModel, ChangeLoggingMixin, OrganizationalModel, PrimaryModel
 from netbox.models import BigIDModel, ChangeLoggingMixin, OrganizationalModel, PrimaryModel