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

Closes #1519: Enable parent assignment for interfaces
This commit is contained in:
Jeremy Stretch 2021-03-05 16:51:39 -05:00 committed by GitHub
commit 1c66733b8a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 223 additions and 88 deletions

View File

@ -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

View File

@ -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):

View File

@ -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):

View File

@ -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

View File

@ -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(),

View File

@ -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,

View File

@ -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'),
),
]

View File

@ -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,
)

View File

@ -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.
"""

View File

@ -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):

View File

@ -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',

View File

@ -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 '',

View File

@ -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>
"""

View File

@ -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]}

View File

@ -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)

View File

@ -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

View File

@ -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 %}

View File

@ -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>

View File

@ -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 %}

View File

@ -38,6 +38,16 @@
{% 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>

View File

@ -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 %}

View File

@ -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

View File

@ -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