From 7a5cf8041279dab266122f82244441a6482bead6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 5 Mar 2021 12:48:53 -0500 Subject: [PATCH 1/6] Clean up extraneous imports --- netbox/dcim/models/device_component_templates.py | 2 -- netbox/dcim/models/device_components.py | 3 +-- netbox/dcim/models/sites.py | 4 +--- netbox/ipam/models/ip.py | 3 +-- netbox/tenancy/models.py | 2 +- netbox/virtualization/models.py | 2 +- 6 files changed, 5 insertions(+), 11 deletions(-) diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 99de344662..3e81cd3f2a 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -4,13 +4,11 @@ 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, ) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index b5b69a5f25..68bf604ae4 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -11,7 +11,7 @@ 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.ordering import naturalize_interface from utilities.querysets import RestrictedQuerySet from utilities.query_functions import CollateAsChar -from utilities.utils import serialize_object __all__ = ( diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index 417e0b9148..5aea4461f7 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -8,13 +8,11 @@ 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', diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index b37b67c79b..c2c4fbf82e 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -9,7 +9,7 @@ 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.querysets import PrefixQuerySet from ipam.validators import DNSValidator from utilities.querysets import RestrictedQuerySet -from utilities.utils import serialize_object from virtualization.models import VirtualMachine diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index 757afb09eb..60c9454472 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -3,7 +3,7 @@ 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 diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 5744003597..4280c6b8ea 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -6,7 +6,7 @@ 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 From 8e1fe6339e46e671c4ba6f3f49256d3d77ebb5c4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 5 Mar 2021 13:06:21 -0500 Subject: [PATCH 2/6] Rename parent attribute on CableTerminations to parent_object --- netbox/circuits/models.py | 2 +- netbox/dcim/models/device_components.py | 28 +++++++++---------- netbox/dcim/models/power.py | 2 +- netbox/dcim/tables/template_code.py | 4 +-- netbox/dcim/views.py | 4 +-- netbox/templates/dcim/cable_trace.html | 4 +-- .../templates/dcim/inc/cabletermination.html | 10 +++---- .../dcim/inc/endpoint_connection.html | 2 +- 8 files changed, 28 insertions(+), 28 deletions(-) diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 26104d4d65..6542077350 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -295,7 +295,7 @@ def to_objectchange(self, action): return super().to_objectchange(action, related_object=circuit) @property - def parent(self): + def parent_object(self): return self.circuit def get_peer_termination(self): diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 68bf604ae4..4a027b373c 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -84,8 +84,8 @@ def to_objectchange(self, action): 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): @@ -152,6 +152,10 @@ def get_cable_peer(self): 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): """ @@ -207,7 +211,7 @@ def connected_endpoint(self): # @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. """ @@ -251,7 +255,7 @@ def to_csv(self): # @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. """ @@ -295,7 +299,7 @@ def to_csv(self): # @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. """ @@ -407,7 +411,7 @@ def get_power_draw(self): # @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. """ @@ -508,7 +512,7 @@ def save(self, *args, **kwargs): @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. """ @@ -619,16 +623,12 @@ def clean(self): 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 @@ -655,7 +655,7 @@ def count_ipaddresses(self): # @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. """ @@ -721,7 +721,7 @@ def clean(self): @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. """ diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index b7b351f379..db98f2fbab 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -201,7 +201,7 @@ def save(self, *args, **kwargs): super().save(*args, **kwargs) @property - def parent(self): + def parent_object(self): return self.power_panel def get_type_class(self): diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index f634cf4261..7449bb9c8b 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -1,6 +1,6 @@ CABLETERMINATION = """ {% if value %} - {{ value.parent }} + {{ value.parent_object }} {{ value }} {% else %} @@ -64,7 +64,7 @@ """ POWERFEED_CABLETERMINATION = """ -{{ value.parent }} +{{ value.parent_object }} {{ value }} """ diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 4c76e16a6c..f39391a8b4 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2178,13 +2178,13 @@ def get(self, request, *args, **kwargs): 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) diff --git a/netbox/templates/dcim/cable_trace.html b/netbox/templates/dcim/cable_trace.html index a39ada1ced..1a8667787a 100644 --- a/netbox/templates/dcim/cable_trace.html +++ b/netbox/templates/dcim/cable_trace.html @@ -102,12 +102,12 @@
Total length: - {{ cablepath.origin.parent }} / {{ cablepath.origin }} + {{ cablepath.origin.parent_object }} / {{ cablepath.origin }} {% if cablepath.destination %} - {{ cablepath.destination }} ({{ cablepath.destination.parent }}) + {{ cablepath.destination }} ({{ cablepath.destination.parent_object }}) {% else %} Incomplete {% endif %} diff --git a/netbox/templates/dcim/inc/cabletermination.html b/netbox/templates/dcim/inc/cabletermination.html index 1962248e7b..26a7e1cd32 100644 --- a/netbox/templates/dcim/inc/cabletermination.html +++ b/netbox/templates/dcim/inc/cabletermination.html @@ -1,12 +1,12 @@ - {% if termination.parent.provider %} + {% if termination.parent_object.provider %} - - {{ termination.parent.provider }} - {{ termination.parent }} + + {{ termination.parent_object.provider }} + {{ termination.parent_object }} {% else %} - {{ termination.parent }} + {{ termination.parent_object }} {% endif %} diff --git a/netbox/templates/dcim/inc/endpoint_connection.html b/netbox/templates/dcim/inc/endpoint_connection.html index 3169d2ffc1..d5b9f6112a 100644 --- a/netbox/templates/dcim/inc/endpoint_connection.html +++ b/netbox/templates/dcim/inc/endpoint_connection.html @@ -1,6 +1,6 @@ {% if path.destination_id %} {% with endpoint=path.destination %} - {{ endpoint.parent }} + {{ endpoint.parent_object }} {{ endpoint }} {% endwith %} {% else %} From e1a86139dc8b4f092bbdb03b1f5b371b911e87e0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 5 Mar 2021 13:49:41 -0500 Subject: [PATCH 3/6] Add parent field to Interface --- netbox/dcim/api/serializers.py | 10 +++-- netbox/dcim/api/views.py | 2 +- netbox/dcim/filters.py | 5 +++ netbox/dcim/forms.py | 43 ++++++++++++++----- .../dcim/migrations/0129_interface_parent.py | 17 ++++++++ netbox/dcim/models/device_components.py | 34 ++++++++++++++- netbox/dcim/tables/devices.py | 14 +++--- netbox/templates/dcim/interface.html | 12 +++++- 8 files changed, 114 insertions(+), 23 deletions(-) create mode 100644 netbox/dcim/migrations/0129_interface_parent.py diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 469cea2a54..faeaaa11a8 100644 --- a/netbox/dcim/api/serializers.py +++ b/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): diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index a05076591f..e869749dbc 100644 --- a/netbox/dcim/api/views.py +++ b/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 diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index d7db936663..6eef1671e2 100644 --- a/netbox/dcim/filters.py +++ b/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(), diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 9a195b75ef..45ce2e1c25 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -2830,8 +2830,8 @@ 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(), @@ -2854,10 +2854,16 @@ def __init__(self, *args, **kwargs): else: device = 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) + + # Limit parent interface choices to interfaces belonging to this device or a peer VC member + self.fields['parent'].queryset = Interface.objects.filter(device_query).exclude( + type__in=(InterfaceTypeChoices.TYPE_VIRTUAL, InterfaceTypeChoices.TYPE_LAG) + ).exclude(pk=self.instance.pk) + + # Limit LAG choices to interfaces belonging to this device or a peer VC member self.fields['lag'].queryset = Interface.objects.filter( device_query, type=InterfaceTypeChoices.TYPE_LAG @@ -2878,6 +2884,12 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): required=False, initial=True ) + parent = forms.ModelChoiceField( + queryset=Interface.objects.all(), + required=False, + label='Parent interface', + widget=StaticSelect2(), + ) lag = forms.ModelChoiceField( queryset=Interface.objects.all(), required=False, @@ -2923,20 +2935,25 @@ 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 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) + + # Limit parent interface choices to interfaces belonging to this device or a peer VC member + self.fields['parent'].queryset = Interface.objects.filter(device_query).exclude( + type__in=(InterfaceTypeChoices.TYPE_VIRTUAL, InterfaceTypeChoices.TYPE_LAG) + ) + + # Limit LAG choices to interfaces belonging to this device or a peer VC member self.fields['lag'].queryset = Interface.objects.filter(device_query, type=InterfaceTypeChoices.TYPE_LAG) # Add current site to VLANs query params @@ -2956,7 +2973,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, @@ -3006,7 +3023,7 @@ 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): @@ -3024,7 +3041,7 @@ def __init__(self, *args, **kwargs): 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') @@ -3064,6 +3081,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, diff --git a/netbox/dcim/migrations/0129_interface_parent.py b/netbox/dcim/migrations/0129_interface_parent.py new file mode 100644 index 0000000000..37e722f0a2 --- /dev/null +++ b/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'), + ), + ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 4a027b373c..2625219d82 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -523,6 +523,14 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint): 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, @@ -563,8 +571,8 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint): 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: @@ -579,6 +587,7 @@ def to_csv(self): 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, @@ -602,6 +611,27 @@ def clean(self): "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: diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 8a3944a28a..49ff121904 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -436,6 +436,10 @@ class DeviceInterfaceTable(InterfaceTable): '{% endif %}"> {{ value }}', 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 '', diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index dbd66c7e7c..7511975b15 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -38,10 +38,20 @@ {% endif %} + + Parent + + {% if object.parent %} + {{ object.parent }} + {% else %} + None + {% endif %} + + LAG - {% if object.lag%} + {% if object.lag %} {{ object.lag }} {% else %} None From d74c07e1a25b2e6aa440defcf2c4a71cbe32d10c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 5 Mar 2021 14:13:03 -0500 Subject: [PATCH 4/6] Clean up parent/LAG interface form validation --- netbox/dcim/forms.py | 103 +++++++++++++--------- netbox/templates/dcim/interface_edit.html | 1 + 2 files changed, 60 insertions(+), 44 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 45ce2e1c25..fa440aadf1 100644 --- a/netbox/dcim/forms.py +++ b/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, @@ -2836,7 +2854,6 @@ class Meta: widgets = { 'device': forms.HiddenInput(), 'type': StaticSelect2(), - 'lag': StaticSelect2(), 'mode': StaticSelect2(), } labels = { @@ -2849,25 +2866,11 @@ class Meta: 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_query = Q(device=device) - if device.virtual_chassis: - device_query |= Q(device__virtual_chassis=device.virtual_chassis) - - # Limit parent interface choices to interfaces belonging to this device or a peer VC member - self.fields['parent'].queryset = Interface.objects.filter(device_query).exclude( - type__in=(InterfaceTypeChoices.TYPE_VIRTUAL, InterfaceTypeChoices.TYPE_LAG) - ).exclude(pk=self.instance.pk) + 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 - 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) @@ -2884,17 +2887,23 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): required=False, initial=True ) - parent = forms.ModelChoiceField( + parent = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, - label='Parent interface', - widget=StaticSelect2(), + display_field='display_name', + query_params={ + 'device_id': '$device', + 'kind': 'physical', + } ) - lag = forms.ModelChoiceField( + lag = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, - label='Parent LAG', - widget=StaticSelect2(), + display_field='display_name', + query_params={ + 'device_id': '$device', + 'type': 'lag', + } ) mtu = forms.IntegerField( required=False, @@ -2941,22 +2950,11 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + + # 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) - - # Limit parent interface choices to interfaces belonging to this device or a peer VC member - self.fields['parent'].queryset = Interface.objects.filter(device_query).exclude( - type__in=(InterfaceTypeChoices.TYPE_VIRTUAL, InterfaceTypeChoices.TYPE_LAG) - ) - - # Limit LAG choices to interfaces belonging to this device or a peer VC member - 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) @@ -2993,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, @@ -3028,18 +3042,17 @@ class Meta: 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 if 'pk' in self.initial: @@ -3059,6 +3072,8 @@ def __init__(self, *args, **kwargs): 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 diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index d2440bd425..8a0c85a12d 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/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 %} From 69a3d14b5370e1161dae3a103adf77e2ecf965c0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 5 Mar 2021 16:20:46 -0500 Subject: [PATCH 5/6] Add filter tests for interface parent and LAG --- netbox/dcim/tests/test_filters.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/netbox/dcim/tests/test_filters.py b/netbox/dcim/tests/test_filters.py index aa76bdc644..f491c2f750 100644 --- a/netbox/dcim/tests/test_filters.py +++ b/netbox/dcim/tests/test_filters.py @@ -1931,6 +1931,34 @@ def test_description(self): 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]} From 2ef85ea195166b3092b9b859c5f5e7ae89183ed6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 5 Mar 2021 16:25:39 -0500 Subject: [PATCH 6/6] Changelog for #1519 --- docs/release-notes/version-2.11.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 9ca13d6394..03b3205a02 100644 --- a/docs/release-notes/version-2.11.md +++ b/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