mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-15 19:52:52 -06:00
Closes #6121: Extend parent interface assignment to VM interfaces
This commit is contained in:
parent
7439faad34
commit
a3721a94ce
@ -6,6 +6,7 @@
|
||||
|
||||
* [#6097](https://github.com/netbox-community/netbox/issues/6097) - Redirect old slug-based object views
|
||||
* [#6109](https://github.com/netbox-community/netbox/issues/6109) - Add device counts to locations table
|
||||
* [#6121](https://github.com/netbox-community/netbox/issues/6121) - Extend parent interface assignment to VM interfaces
|
||||
* [#6125](https://github.com/netbox-community/netbox/issues/6125) - Add locations count to home page
|
||||
|
||||
### Bug Fixes (from Beta)
|
||||
@ -44,7 +45,7 @@ NetBox now supports journaling for all primary objects. The journal is a collect
|
||||
|
||||
#### 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 object. This is helpful for associating subinterfaces with their physical counterpart. For example, you might assign virtual interfaces Gi0/0.100 and Gi0/0.200 as children of the physical interface Gi0/0.
|
||||
Virtual device and VM interfaces can now be assigned to a "parent" interface by setting the `parent` field on the interface object. This is helpful for associating subinterfaces with their physical counterpart. For example, you might assign virtual interfaces Gi0/0.100 and Gi0/0.200 as children of the physical interface Gi0/0.
|
||||
|
||||
#### Pre- and Post-Change Snapshots in Webhooks ([#3451](https://github.com/netbox-community/netbox/issues/3451))
|
||||
|
||||
@ -186,3 +187,5 @@ A new provider network model has been introduced to represent the boundary of a
|
||||
* Dropped the `site` foreign key field
|
||||
* virtualization.VirtualMachine
|
||||
* `vcpus` has been changed from an integer to a decimal value
|
||||
* virtualization.VMInterface
|
||||
* Added the `parent` field
|
||||
|
@ -12,7 +12,7 @@
|
||||
|
||||
{% block buttons %}
|
||||
{% if perms.virtualization.add_vminterface %}
|
||||
<a href="{% url 'virtualization:vminterface_add' %}?virtual_machine={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary">
|
||||
<a href="{% url 'virtualization:vminterface_add' %}?virtual_machine={{ object.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}" class="btn btn-primary">
|
||||
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Interfaces
|
||||
</a>
|
||||
{% endif %}
|
||||
|
@ -39,6 +39,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>Description</td>
|
||||
<td>{{ object.description|placeholder }} </td>
|
||||
@ -91,9 +101,14 @@
|
||||
{% include 'panel_table.html' with table=vlan_table heading="VLANs" %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
{% include 'panel_table.html' with table=child_interfaces_table heading="Child Interfaces" %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -17,6 +17,7 @@
|
||||
{% endif %}
|
||||
{% render_field form.name %}
|
||||
{% render_field form.enabled %}
|
||||
{% render_field form.parent %}
|
||||
{% render_field form.mac_address %}
|
||||
{% render_field form.mtu %}
|
||||
{% render_field form.description %}
|
||||
|
@ -106,6 +106,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
|
||||
class VMInterfaceSerializer(PrimaryModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:vminterface-detail')
|
||||
virtual_machine = NestedVirtualMachineSerializer()
|
||||
parent = NestedVMInterfaceSerializer(required=False, allow_null=True)
|
||||
mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
|
||||
untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
|
||||
tagged_vlans = SerializedPKRelatedField(
|
||||
@ -118,8 +119,8 @@ class VMInterfaceSerializer(PrimaryModelSerializer):
|
||||
class Meta:
|
||||
model = VMInterface
|
||||
fields = [
|
||||
'id', 'url', 'display', 'virtual_machine', 'name', 'enabled', 'mtu', 'mac_address', 'description', 'mode',
|
||||
'untagged_vlan', 'tagged_vlans', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
'id', 'url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'mtu', 'mac_address', 'description',
|
||||
'mode', 'untagged_vlan', 'tagged_vlans', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
def validate(self, data):
|
||||
|
@ -80,7 +80,7 @@ class VirtualMachineViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet)
|
||||
|
||||
class VMInterfaceViewSet(ModelViewSet):
|
||||
queryset = VMInterface.objects.prefetch_related(
|
||||
'virtual_machine', 'tags', 'tagged_vlans'
|
||||
'virtual_machine', 'parent', 'tags', 'tagged_vlans'
|
||||
)
|
||||
serializer_class = serializers.VMInterfaceSerializer
|
||||
filterset_class = filters.VMInterfaceFilterSet
|
||||
|
@ -264,6 +264,11 @@ class VMInterfaceFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpda
|
||||
to_field_name='name',
|
||||
label='Virtual machine',
|
||||
)
|
||||
parent_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='parent',
|
||||
queryset=VMInterface.objects.all(),
|
||||
label='Parent interface (ID)',
|
||||
)
|
||||
mac_address = MultiValueMACAddressFilter(
|
||||
label='MAC address',
|
||||
)
|
||||
|
@ -603,6 +603,11 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
|
||||
#
|
||||
|
||||
class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm):
|
||||
parent = DynamicModelChoiceField(
|
||||
queryset=VMInterface.objects.all(),
|
||||
required=False,
|
||||
label='Parent interface'
|
||||
)
|
||||
untagged_vlan = DynamicModelChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
required=False,
|
||||
@ -621,8 +626,8 @@ class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm)
|
||||
class Meta:
|
||||
model = VMInterface
|
||||
fields = [
|
||||
'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'mode', 'tags', 'untagged_vlan',
|
||||
'tagged_vlans',
|
||||
'virtual_machine', 'name', 'enabled', 'parent', 'mac_address', 'mtu', 'description', 'mode', 'tags',
|
||||
'untagged_vlan', 'tagged_vlans',
|
||||
]
|
||||
widgets = {
|
||||
'virtual_machine': forms.HiddenInput(),
|
||||
@ -637,9 +642,12 @@ class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine')
|
||||
|
||||
# Restrict parent interface assignment by VM
|
||||
self.fields['parent'].widget.add_query_param('virtualmachine_id', vm_id)
|
||||
|
||||
# Limit VLAN choices by virtual machine
|
||||
vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine')
|
||||
self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id)
|
||||
self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id)
|
||||
|
||||
@ -655,6 +663,14 @@ class VMInterfaceCreateForm(BootstrapMixin, InterfaceCommonForm):
|
||||
required=False,
|
||||
initial=True
|
||||
)
|
||||
parent = DynamicModelChoiceField(
|
||||
queryset=VMInterface.objects.all(),
|
||||
required=False,
|
||||
display_field='display_name',
|
||||
query_params={
|
||||
'virtualmachine_id': 'virtual_machine',
|
||||
}
|
||||
)
|
||||
mtu = forms.IntegerField(
|
||||
required=False,
|
||||
min_value=INTERFACE_MTU_MIN,
|
||||
@ -689,9 +705,12 @@ class VMInterfaceCreateForm(BootstrapMixin, InterfaceCommonForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine')
|
||||
|
||||
# Restrict parent interface assignment by VM
|
||||
self.fields['parent'].widget.add_query_param('virtualmachine_id', vm_id)
|
||||
|
||||
# Limit VLAN choices by virtual machine
|
||||
vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine')
|
||||
self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id)
|
||||
self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id)
|
||||
|
||||
@ -730,6 +749,11 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
|
||||
disabled=True,
|
||||
widget=forms.HiddenInput()
|
||||
)
|
||||
parent = DynamicModelChoiceField(
|
||||
queryset=VMInterface.objects.all(),
|
||||
required=False,
|
||||
display_field='display_name'
|
||||
)
|
||||
enabled = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect()
|
||||
@ -760,14 +784,17 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
|
||||
|
||||
class Meta:
|
||||
nullable_fields = [
|
||||
'mtu', 'description',
|
||||
'parent', 'mtu', 'description',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine')
|
||||
|
||||
# Restrict parent interface assignment by VM
|
||||
self.fields['parent'].widget.add_query_param('virtualmachine_id', vm_id)
|
||||
|
||||
# Limit VLAN choices by virtual machine
|
||||
vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine')
|
||||
self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id)
|
||||
self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id)
|
||||
|
||||
|
17
netbox/virtualization/migrations/0022_vminterface_parent.py
Normal file
17
netbox/virtualization/migrations/0022_vminterface_parent.py
Normal file
@ -0,0 +1,17 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('virtualization', '0021_virtualmachine_vcpus_decimal'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='vminterface',
|
||||
name='parent',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='child_interfaces', to='virtualization.vminterface'),
|
||||
),
|
||||
]
|
@ -395,6 +395,14 @@ class VMInterface(PrimaryModel, BaseInterface):
|
||||
max_length=200,
|
||||
blank=True
|
||||
)
|
||||
parent = models.ForeignKey(
|
||||
to='self',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='child_interfaces',
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Parent interface'
|
||||
)
|
||||
untagged_vlan = models.ForeignKey(
|
||||
to='ipam.VLAN',
|
||||
on_delete=models.SET_NULL,
|
||||
@ -438,6 +446,7 @@ class VMInterface(PrimaryModel, BaseInterface):
|
||||
self.virtual_machine.name,
|
||||
self.name,
|
||||
self.enabled,
|
||||
self.parent.name if self.parent else None,
|
||||
self.mac_address,
|
||||
self.mtu,
|
||||
self.description,
|
||||
@ -447,6 +456,13 @@ class VMInterface(PrimaryModel, BaseInterface):
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# An interface's parent must belong to the same virtual machine
|
||||
if self.parent and self.parent.virtual_machine != self.virtual_machine:
|
||||
raise ValidationError({
|
||||
'parent': f"The selected parent interface ({self.parent}) belongs to a different virtual machine "
|
||||
f"({self.parent.virtual_machine})."
|
||||
})
|
||||
|
||||
# Validate untagged VLAN
|
||||
if self.untagged_vlan and self.untagged_vlan.site not in [self.virtual_machine.site, None]:
|
||||
raise ValidationError({
|
||||
|
@ -170,6 +170,9 @@ class VMInterfaceTable(BaseInterfaceTable):
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
parent = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='virtualization:vminterface_list'
|
||||
)
|
||||
@ -177,10 +180,10 @@ class VMInterfaceTable(BaseInterfaceTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VMInterface
|
||||
fields = (
|
||||
'pk', 'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags',
|
||||
'pk', 'virtual_machine', 'name', 'enabled', 'parent', 'mac_address', 'mtu', 'mode', 'description', 'tags',
|
||||
'ip_addresses', 'untagged_vlan', 'tagged_vlans',
|
||||
)
|
||||
default_columns = ('pk', 'virtual_machine', 'name', 'enabled', 'description')
|
||||
default_columns = ('pk', 'virtual_machine', 'name', 'enabled', 'parent', 'description')
|
||||
|
||||
|
||||
class VirtualMachineVMInterfaceTable(VMInterfaceTable):
|
||||
|
@ -453,12 +453,25 @@ class VMInterfaceTestCase(TestCase):
|
||||
params = {'name': ['Interface 1', 'Interface 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_assigned_to_interface(self):
|
||||
def test_enabled(self):
|
||||
params = {'enabled': 'true'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'enabled': 'false'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_parent(self):
|
||||
# Create child interfaces
|
||||
parent_interface = VMInterface.objects.first()
|
||||
child_interfaces = (
|
||||
VMInterface(virtual_machine=parent_interface.virtual_machine, name='Child 1', parent=parent_interface),
|
||||
VMInterface(virtual_machine=parent_interface.virtual_machine, name='Child 2', parent=parent_interface),
|
||||
VMInterface(virtual_machine=parent_interface.virtual_machine, name='Child 3', parent=parent_interface),
|
||||
)
|
||||
VMInterface.objects.bulk_create(child_interfaces)
|
||||
|
||||
params = {'parent_id': [parent_interface.pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
def test_mtu(self):
|
||||
params = {'mtu': [100, 200]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
@ -421,6 +421,14 @@ class VMInterfaceView(generic.ObjectView):
|
||||
orderable=False
|
||||
)
|
||||
|
||||
# Get child interfaces
|
||||
child_interfaces = VMInterface.objects.restrict(request.user, 'view').filter(parent=instance)
|
||||
child_interfaces_tables = tables.VMInterfaceTable(
|
||||
child_interfaces,
|
||||
orderable=False
|
||||
)
|
||||
child_interfaces_tables.columns.hide('virtual_machine')
|
||||
|
||||
# Get assigned VLANs and annotate whether each is tagged or untagged
|
||||
vlans = []
|
||||
if instance.untagged_vlan is not None:
|
||||
@ -437,6 +445,7 @@ class VMInterfaceView(generic.ObjectView):
|
||||
|
||||
return {
|
||||
'ipaddress_table': ipaddress_table,
|
||||
'child_interfaces_table': child_interfaces_tables,
|
||||
'vlan_table': vlan_table,
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user