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

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
 
+#### 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))
 
 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/`
 * dcim.Device
   * Added the `location` field
+* dcim.Interface
+  * Added the `parent` field
 * dcim.PowerPanel
   * Renamed `rack_group` field to `location`
 * 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)
 
     @property
-    def parent(self):
+    def parent_object(self):
         return self.circuit
 
     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')
     device = NestedDeviceSerializer()
     type = ChoiceField(choices=InterfaceTypeChoices)
+    parent = NestedInterfaceSerializer(required=False, allow_null=True)
     lag = NestedInterfaceSerializer(required=False, allow_null=True)
     mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
     untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
@@ -613,10 +614,11 @@ class InterfaceSerializer(TaggedObjectSerializer, CableTerminationSerializer, Co
     class Meta:
         model = Interface
         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):

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

@@ -522,7 +522,7 @@ class PowerOutletViewSet(PathEndpointMixin, ModelViewSet):
 
 class InterfaceViewSet(PathEndpointMixin, ModelViewSet):
     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
     filterset_class = filters.InterfaceFilterSet

+ 5 - 0
netbox/dcim/filters.py

@@ -844,6 +844,11 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati
         method='filter_kind',
         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(
         field_name='lag',
         queryset=Interface.objects.all(),

+ 74 - 36
netbox/dcim/forms.py

@@ -2802,6 +2802,24 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
 
 
 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(
         queryset=VLAN.objects.all(),
         required=False,
@@ -2830,13 +2848,12 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm):
     class Meta:
         model = Interface
         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 = {
             'device': forms.HiddenInput(),
             'type': StaticSelect2(),
-            'lag': StaticSelect2(),
             'mode': StaticSelect2(),
         }
         labels = {
@@ -2849,19 +2866,11 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm):
     def __init__(self, *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
         self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk)
@@ -2878,11 +2887,23 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm):
         required=False,
         initial=True
     )
-    lag = forms.ModelChoiceField(
+    parent = DynamicModelChoiceField(
         queryset=Interface.objects.all(),
         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(
         required=False,
@@ -2923,23 +2944,17 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm):
         }
     )
     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):
         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(
             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['tagged_vlans'].widget.add_query_param('site_id', device.site.pk)
 
@@ -2956,7 +2971,7 @@ class InterfaceBulkCreateForm(
 
 class InterfaceBulkEditForm(
     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,
     AddRemoveTagsForm,
@@ -2976,6 +2991,22 @@ class InterfaceBulkEditForm(
         required=False,
         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(
         required=False,
         widget=BulkEditNullBooleanSelect,
@@ -3006,25 +3037,24 @@ class InterfaceBulkEditForm(
 
     class Meta:
         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):
         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
-            )
+
+            # 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
             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)
+
         else:
-            # See 4523
+            # See #4523
             if 'pk' in self.initial:
                 site = None
                 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['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'].widget.attrs['disabled'] = True
 
@@ -3064,6 +3096,12 @@ class InterfaceCSVForm(CustomFieldModelCSVForm):
         queryset=Device.objects.all(),
         to_field_name='name'
     )
+    parent = CSVModelChoiceField(
+        queryset=Interface.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Parent interface'
+    )
     lag = CSVModelChoiceField(
         queryset=Interface.objects.all(),
         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.constants import *
-from extras.models import ObjectChange
 from extras.utils import extras_features
 from netbox.models import BigIDModel, ChangeLoggingMixin
 from utilities.fields import NaturalOrderingField
 from utilities.querysets import RestrictedQuerySet
 from utilities.ordering import naturalize_interface
-from utilities.utils import serialize_object
 from .device_components import (
     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.constants import *
 from dcim.fields import MACAddressField
-from extras.models import ObjectChange, TaggedItem
+from extras.models import TaggedItem
 from extras.utils import extras_features
 from netbox.models import PrimaryModel
 from utilities.fields import NaturalOrderingField
@@ -19,7 +19,6 @@ from utilities.mptt import TreeManager
 from utilities.ordering import naturalize_interface
 from utilities.querysets import RestrictedQuerySet
 from utilities.query_functions import CollateAsChar
-from utilities.utils import serialize_object
 
 
 __all__ = (
@@ -85,8 +84,8 @@ class ComponentModel(PrimaryModel):
         return super().to_objectchange(action, related_object=device)
 
     @property
-    def parent(self):
-        return getattr(self, 'device', None)
+    def parent_object(self):
+        return self.device
 
 
 class CableTermination(models.Model):
@@ -153,6 +152,10 @@ class CableTermination(models.Model):
     def _occupied(self):
         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):
     """
@@ -208,7 +211,7 @@ class PathEndpoint(models.Model):
 #
 
 @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.
     """
@@ -252,7 +255,7 @@ class ConsolePort(CableTermination, PathEndpoint, ComponentModel):
 #
 
 @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.
     """
@@ -296,7 +299,7 @@ class ConsoleServerPort(CableTermination, PathEndpoint, ComponentModel):
 #
 
 @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.
     """
@@ -408,7 +411,7 @@ class PowerPort(CableTermination, PathEndpoint, ComponentModel):
 #
 
 @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.
     """
@@ -509,7 +512,7 @@ class BaseInterface(models.Model):
 
 
 @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.
     """
@@ -520,6 +523,14 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
         max_length=100,
         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(
         to='self',
         on_delete=models.SET_NULL,
@@ -560,8 +571,8 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
     tags = TaggableManager(through=TaggedItem)
 
     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:
@@ -576,6 +587,7 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
             self.device.identifier if self.device else None,
             self.name,
             self.label,
+            self.parent.name if self.parent else None,
             self.lag.name if self.lag else None,
             self.get_type_display(),
             self.enabled,
@@ -599,6 +611,27 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
                         "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
         if self.lag and self.lag.device != self.device:
             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."})
 
         # 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({
                 '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)
             })
 
-    @property
-    def parent(self):
-        return self.device
-
     @property
     def is_connectable(self):
         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')
-class FrontPort(CableTermination, ComponentModel):
+class FrontPort(ComponentModel, CableTermination):
     """
     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')
-class RearPort(CableTermination, ComponentModel):
+class RearPort(ComponentModel, CableTermination):
     """
     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)
 
     @property
-    def parent(self):
+    def parent_object(self):
         return self.power_panel
 
     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.constants import *
 from dcim.fields import ASNField
-from extras.models import ObjectChange, TaggedItem
+from extras.models import TaggedItem
 from extras.utils import extras_features
 from netbox.models import NestedGroupModel, PrimaryModel
 from utilities.fields import NaturalOrderingField
 from utilities.querysets import RestrictedQuerySet
-from utilities.mptt import TreeManager
-from utilities.utils import serialize_object
 
 __all__ = (
     '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>',
         attrs={'td': {'class': 'text-nowrap'}}
     )
+    parent = tables.Column(
+        linkify=True,
+        verbose_name='Parent'
+    )
     lag = tables.Column(
         linkify=True,
         verbose_name='LAG'
@@ -449,13 +453,13 @@ class DeviceInterfaceTable(InterfaceTable):
     class Meta(DeviceComponentTable.Meta):
         model = Interface
         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 = (
-            '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 = {
             '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 = """
 {% 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>
     <a href="{{ value.get_absolute_url }}">{{ value }}</a>
 {% else %}
@@ -64,7 +64,7 @@ POWERFEED_CABLE = """
 """
 
 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>
 <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']}
         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):
         regions = Region.objects.all()[:2]
         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}
 
         # 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:
             initial_data['termination_b_region'] = termination_a_site.region
         if 'termination_b_site' not in initial_data:
             initial_data['termination_b_site'] = termination_a_site
         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)
 

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

@@ -9,7 +9,7 @@ from django.urls import reverse
 from taggit.managers import TaggableManager
 
 from dcim.models import Device
-from extras.models import ObjectChange, TaggedItem
+from extras.models import TaggedItem
 from extras.utils import extras_features
 from netbox.models import OrganizationalModel, PrimaryModel
 from ipam.choices import *
@@ -19,7 +19,6 @@ from ipam.managers import IPAddressManager
 from ipam.querysets import PrefixQuerySet
 from ipam.validators import DNSValidator
 from utilities.querysets import RestrictedQuerySet
-from utilities.utils import serialize_object
 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 %}>
                                 <td>
                                     <a href="?cablepath_id={{ cablepath.pk }}">
-                                        {{ cablepath.origin.parent }} / {{ cablepath.origin }}
+                                        {{ cablepath.origin.parent_object }} / {{ cablepath.origin }}
                                     </a>
                                 </td>
                                 <td>
                                     {% if cablepath.destination %}
-                                        {{ cablepath.destination }} ({{ cablepath.destination.parent }})
+                                        {{ cablepath.destination }} ({{ cablepath.destination.parent_object }})
                                     {% else %}
                                         <span class="text-muted">Incomplete</span>
                                     {% endif %}

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

@@ -1,12 +1,12 @@
 <td>
-    {% if termination.parent.provider %}
+    {% if termination.parent_object.provider %}
         <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>
     {% else %}
-        <a href="{{ termination.parent.get_absolute_url }}">{{ termination.parent }}</a>
+        <a href="{{ termination.parent_object.get_absolute_url }}">{{ termination.parent_object }}</a>
     {% endif %}
 </td>
 <td>

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

@@ -1,6 +1,6 @@
 {% if path.destination_id %}
     {% 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>
     {% endwith %}
 {% else %}

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

@@ -38,10 +38,20 @@
                             {% endif %}
                         </td>
                     </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>
                         <td>LAG</td>
                         <td>
-                            {% if object.lag%}
+                            {% if object.lag %}
                                 <a href="{{ object.lag.get_absolute_url }}">{{ object.lag }}</a>
                             {% else %}
                                 <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.type %}
             {% render_field form.enabled %}
+            {% render_field form.parent %}
             {% render_field form.lag %}
             {% render_field form.mac_address %}
             {% 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 taggit.managers import TaggableManager
 
-from extras.models import ObjectChange, TaggedItem
+from extras.models import TaggedItem
 from extras.utils import extras_features
 from netbox.models import NestedGroupModel, PrimaryModel
 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 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.utils import extras_features
 from netbox.models import BigIDModel, ChangeLoggingMixin, OrganizationalModel, PrimaryModel