mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-25 04:50:00 -06:00
4867 multiple mac addresses (#17902)
* Create MACAddress model and migrations to convert existing .mac_address fields to standalone objects * Add migrations * All views/filtering working and documentation done; no unit tests yet * Redo migrations following VLAN Translation * Remove mac_address filter fields and add table columns for device/vm * Remove unnecessary "bulk rename" * Fix filterset tests for Device * Fix filterset tests for Interface * Fix tests on single-object forms * Fix serializer tests * Fix filterset tests for VMInterface * Fix filterset tests for Device and VirtualMachine * Move new field check into lookup_map iteration * Fix general MACAddress filter tests * Add GraphQL types/filters/schema * Fix bulk edit/create tests (bulk editing Interfaces will be unsupported because of inheritance from ComponentBulkEditForm) * Make mac_address read_only on InterfaceSerializer/VMInterfaceSerializer * Undo unrelated work * Cleanup unused IPAddress derived stuff * API endpoints * Add serializer objects to interface serializers * Clean up unnecessary bulk create forms/views/routes * Add SearchIndex and adjust indexable fields for Interface and VMInterface * Reorganize MACAddress classes out of association with DeviceComponents * Move MACAddressSerializer * Enforce saving only a single is_primary MACAddress per interface/vminterface * Perform is_primary validation on MACAddress model and just check if one already exists for the interface * Remove form-level validation * Fix check for current is_primary setting when reassigning * Model cleanup * Documentation notes and cleanup * Simplify serializer and add ip_addresses * Add to VMInterfaceSerializer too * Style cleanup * Standardize "MAC Address" instead of "MAC" * Remove unused views * Add is_primary field for bulk edit * HTML cleanup and add copy-to-clipboard button * Remove mac_address from Interface and VMInterface bulk-edit forms * Add device and VM filtering * Use combined assigned_object_parent in table to match structure of IPAddressTable * Add GFK fields to MACAddressSerializer * Reorganize "Addressing" sections to remove from proximity to "Device Components" and related groupings * Clean up migrations * Misc cleanup * Add filterset test * Remove mac_address field from interface forms * Designate primary MAC address via a ForeignKey on the interface models * Add serializer fields for primary_mac_address * Update docs --------- Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
This commit is contained in:
@@ -2,6 +2,7 @@ from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
|
||||
from dcim.api.serializers_.devices import DeviceSerializer
|
||||
from dcim.api.serializers_.device_components import MACAddressSerializer
|
||||
from dcim.api.serializers_.platforms import PlatformSerializer
|
||||
from dcim.api.serializers_.roles import DeviceRoleSerializer
|
||||
from dcim.api.serializers_.sites import SiteSerializer
|
||||
@@ -95,19 +96,18 @@ class VMInterfaceSerializer(NetBoxModelSerializer):
|
||||
l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True)
|
||||
count_ipaddresses = serializers.IntegerField(read_only=True)
|
||||
count_fhrp_groups = serializers.IntegerField(read_only=True)
|
||||
mac_address = serializers.CharField(
|
||||
required=False,
|
||||
default=None,
|
||||
allow_null=True
|
||||
)
|
||||
# Maintains backward compatibility with NetBox <v4.2
|
||||
mac_address = serializers.CharField(allow_null=True, read_only=True)
|
||||
primary_mac_address = MACAddressSerializer(nested=True, required=False, allow_null=True)
|
||||
mac_addresses = MACAddressSerializer(many=True, nested=True, read_only=True, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = VMInterface
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'bridge', 'mtu',
|
||||
'mac_address', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan',
|
||||
'vlan_translation_policy', 'vrf', 'l2vpn_termination', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
'count_ipaddresses', 'count_fhrp_groups',
|
||||
'mac_address', 'primary_mac_address', 'mac_addresses', 'description', 'mode', 'untagged_vlan',
|
||||
'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf', 'l2vpn_termination', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'virtual_machine', 'name', 'description')
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@ import django_filters
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from dcim.filtersets import CommonInterfaceFilterSet
|
||||
from dcim.base_filtersets import ScopedFilterSet
|
||||
from dcim.filtersets import CommonInterfaceFilterSet
|
||||
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
|
||||
from dcim.models import MACAddress
|
||||
from extras.filtersets import LocalConfigContextFilterSet
|
||||
from extras.models import ConfigTemplate
|
||||
from ipam.filtersets import PrimaryIPFilterSet
|
||||
@@ -191,7 +192,7 @@ class VirtualMachineFilterSet(
|
||||
label=_('Platform (slug)'),
|
||||
)
|
||||
mac_address = MultiValueMACAddressFilter(
|
||||
field_name='interfaces__mac_address',
|
||||
field_name='interfaces__mac_addresses__mac_address',
|
||||
label=_('MAC address'),
|
||||
)
|
||||
has_primary_ip = django_filters.BooleanFilter(
|
||||
@@ -263,8 +264,20 @@ class VMInterfaceFilterSet(NetBoxModelFilterSet, CommonInterfaceFilterSet):
|
||||
label=_('Bridged interface (ID)'),
|
||||
)
|
||||
mac_address = MultiValueMACAddressFilter(
|
||||
field_name='mac_addresses__mac_address',
|
||||
label=_('MAC address'),
|
||||
)
|
||||
primary_mac_address_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='primary_mac_address',
|
||||
queryset=MACAddress.objects.all(),
|
||||
label=_('Primary MAC address (ID)'),
|
||||
)
|
||||
primary_mac_address = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='primary_mac_address__mac_address',
|
||||
queryset=MACAddress.objects.all(),
|
||||
to_field_name='mac_address',
|
||||
label=_('Primary MAC address'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = VMInterface
|
||||
|
||||
@@ -182,7 +182,7 @@ class VMInterfaceImportForm(NetBoxModelImportForm):
|
||||
class Meta:
|
||||
model = VMInterface
|
||||
fields = (
|
||||
'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode',
|
||||
'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mtu', 'description', 'mode',
|
||||
'vrf', 'tags'
|
||||
)
|
||||
|
||||
|
||||
@@ -360,7 +360,7 @@ class VMInterfaceForm(InterfaceCommonForm, VMComponentForm):
|
||||
|
||||
fieldsets = (
|
||||
FieldSet('virtual_machine', 'name', 'description', 'tags', name=_('Interface')),
|
||||
FieldSet('vrf', 'mac_address', name=_('Addressing')),
|
||||
FieldSet('vrf', 'primary_mac_address', name=_('Addressing')),
|
||||
FieldSet('mtu', 'enabled', name=_('Operation')),
|
||||
FieldSet('parent', 'bridge', name=_('Related Interfaces')),
|
||||
FieldSet(
|
||||
@@ -372,8 +372,9 @@ class VMInterfaceForm(InterfaceCommonForm, VMComponentForm):
|
||||
class Meta:
|
||||
model = VMInterface
|
||||
fields = [
|
||||
'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode',
|
||||
'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf', 'tags',
|
||||
'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mtu', 'description', 'mode', 'vlan_group',
|
||||
'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf', 'primary_mac_address',
|
||||
'tags',
|
||||
]
|
||||
labels = {
|
||||
'mode': _('802.1Q Mode'),
|
||||
|
||||
@@ -106,12 +106,14 @@ class VMInterfaceType(IPAddressesMixin, ComponentType):
|
||||
bridge: Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')] | None
|
||||
untagged_vlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
|
||||
vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
|
||||
primary_mac_address: Annotated["MACAddressType", strawberry.lazy('dcim.graphql.types')] | None
|
||||
qinq_svlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
|
||||
vlan_translation_policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] | None
|
||||
|
||||
tagged_vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]]
|
||||
bridge_interfaces: List[Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')]]
|
||||
child_interfaces: List[Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')]]
|
||||
mac_addresses: List[Annotated["MACAddressType", strawberry.lazy('dcim.graphql.types')]]
|
||||
|
||||
|
||||
@strawberry_django.type(
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def populate_mac_addresses(apps, schema_editor):
|
||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||
VMInterface = apps.get_model('virtualization', 'VMInterface')
|
||||
MACAddress = apps.get_model('dcim', 'MACAddress')
|
||||
vminterface_ct = ContentType.objects.get_for_model(VMInterface)
|
||||
|
||||
mac_addresses = [
|
||||
MACAddress(
|
||||
mac_address=vminterface.mac_address,
|
||||
assigned_object_type=vminterface_ct,
|
||||
assigned_object_id=vminterface.pk
|
||||
)
|
||||
for vminterface in VMInterface.objects.filter(mac_address__isnull=False)
|
||||
]
|
||||
MACAddress.objects.bulk_create(mac_addresses, batch_size=100)
|
||||
|
||||
# TODO: Optimize interface updates
|
||||
for mac_address in mac_addresses:
|
||||
VMInterface.objects.filter(pk=mac_address.assigned_object_id).update(primary_mac_address=mac_address)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0199_macaddress'),
|
||||
('virtualization', '0047_natural_ordering'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='vminterface',
|
||||
name='primary_mac_address',
|
||||
field=models.OneToOneField(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='+',
|
||||
to='dcim.macaddress'
|
||||
),
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=populate_mac_addresses,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='vminterface',
|
||||
name='mac_address',
|
||||
),
|
||||
]
|
||||
@@ -348,6 +348,12 @@ class VMInterface(ComponentModel, BaseInterface, TrackingModelMixin):
|
||||
object_id_field='assigned_object_id',
|
||||
related_query_name='vminterface',
|
||||
)
|
||||
mac_addresses = GenericRelation(
|
||||
to='dcim.MACAddress',
|
||||
content_type_field='assigned_object_type',
|
||||
object_id_field='assigned_object_id',
|
||||
related_query_name='vminterface'
|
||||
)
|
||||
|
||||
class Meta(ComponentModel.Meta):
|
||||
verbose_name = _('interface')
|
||||
|
||||
@@ -52,11 +52,10 @@ class VMInterfaceIndex(SearchIndex):
|
||||
model = models.VMInterface
|
||||
fields = (
|
||||
('name', 100),
|
||||
('mac_address', 300),
|
||||
('description', 500),
|
||||
('mtu', 2000),
|
||||
)
|
||||
display_attrs = ('virtual_machine', 'mac_address', 'description')
|
||||
display_attrs = ('virtual_machine', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
|
||||
@@ -25,6 +25,9 @@ VMINTERFACE_BUTTONS = """
|
||||
{% if perms.ipam.add_ipaddress %}
|
||||
<li><a class="dropdown-item" href="{% url 'ipam:ipaddress_add' %}?vminterface={{ record.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}">IP Address</a></li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_macaddress %}
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:macaddress_add' %}?vminterface={{ record.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}">MAC Address</a></li>
|
||||
{% endif %}
|
||||
{% if perms.vpn.add_l2vpntermination %}
|
||||
<li><a class="dropdown-item" href="{% url 'vpn:l2vpntermination_add' %}?virtual_machine={{ object.pk }}&vminterface={{ record.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}">L2VPN Termination</a></li>
|
||||
{% endif %}
|
||||
@@ -150,8 +153,8 @@ class VMInterfaceTable(BaseInterfaceTable):
|
||||
model = VMInterface
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags',
|
||||
'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan',
|
||||
'created', 'last_updated',
|
||||
'vrf', 'primary_mac_address', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan',
|
||||
'tagged_vlans', 'qinq_svlan', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description')
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from dcim.choices import InterfaceModeChoices
|
||||
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
|
||||
from dcim.models import Device, DeviceRole, MACAddress, Platform, Region, Site, SiteGroup
|
||||
from ipam.choices import VLANQinQRoleChoices
|
||||
from ipam.models import IPAddress, VLAN, VLANTranslationPolicy, VRF
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
@@ -366,13 +366,24 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
)
|
||||
VirtualMachine.objects.bulk_create(vms)
|
||||
|
||||
mac_addresses = (
|
||||
MACAddress(mac_address='00-00-00-00-00-01'),
|
||||
MACAddress(mac_address='00-00-00-00-00-02'),
|
||||
MACAddress(mac_address='00-00-00-00-00-03'),
|
||||
)
|
||||
MACAddress.objects.bulk_create(mac_addresses)
|
||||
|
||||
interfaces = (
|
||||
VMInterface(virtual_machine=vms[0], name='Interface 1', mac_address='00-00-00-00-00-01'),
|
||||
VMInterface(virtual_machine=vms[1], name='Interface 2', mac_address='00-00-00-00-00-02'),
|
||||
VMInterface(virtual_machine=vms[2], name='Interface 3', mac_address='00-00-00-00-00-03'),
|
||||
VMInterface(virtual_machine=vms[0], name='Interface 1'),
|
||||
VMInterface(virtual_machine=vms[1], name='Interface 2'),
|
||||
VMInterface(virtual_machine=vms[2], name='Interface 3'),
|
||||
)
|
||||
VMInterface.objects.bulk_create(interfaces)
|
||||
|
||||
interfaces[0].mac_addresses.set([mac_addresses[0]])
|
||||
interfaces[1].mac_addresses.set([mac_addresses[1]])
|
||||
interfaces[2].mac_addresses.set([mac_addresses[2]])
|
||||
|
||||
# Assign primary IPs for filtering
|
||||
ipaddresses = (
|
||||
IPAddress(address='192.0.2.1/24', assigned_object=interfaces[0]),
|
||||
@@ -579,13 +590,19 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
)
|
||||
VLANTranslationPolicy.objects.bulk_create(vlan_translation_policies)
|
||||
|
||||
mac_addresses = (
|
||||
MACAddress(mac_address='00-00-00-00-00-01'),
|
||||
MACAddress(mac_address='00-00-00-00-00-02'),
|
||||
MACAddress(mac_address='00-00-00-00-00-03'),
|
||||
)
|
||||
MACAddress.objects.bulk_create(mac_addresses)
|
||||
|
||||
interfaces = (
|
||||
VMInterface(
|
||||
virtual_machine=vms[0],
|
||||
name='Interface 1',
|
||||
enabled=True,
|
||||
mtu=100,
|
||||
mac_address='00-00-00-00-00-01',
|
||||
vrf=vrfs[0],
|
||||
description='foobar1',
|
||||
vlan_translation_policy=vlan_translation_policies[0],
|
||||
@@ -595,7 +612,6 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
name='Interface 2',
|
||||
enabled=True,
|
||||
mtu=200,
|
||||
mac_address='00-00-00-00-00-02',
|
||||
vrf=vrfs[1],
|
||||
description='foobar2',
|
||||
vlan_translation_policy=vlan_translation_policies[0],
|
||||
@@ -605,7 +621,6 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
name='Interface 3',
|
||||
enabled=False,
|
||||
mtu=300,
|
||||
mac_address='00-00-00-00-00-03',
|
||||
vrf=vrfs[2],
|
||||
description='foobar3',
|
||||
mode=InterfaceModeChoices.MODE_Q_IN_Q,
|
||||
@@ -614,6 +629,10 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
)
|
||||
VMInterface.objects.bulk_create(interfaces)
|
||||
|
||||
interfaces[0].mac_addresses.set([mac_addresses[0]])
|
||||
interfaces[1].mac_addresses.set([mac_addresses[1]])
|
||||
interfaces[2].mac_addresses.set([mac_addresses[2]])
|
||||
|
||||
def test_q(self):
|
||||
params = {'q': 'foobar1'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from netaddr import EUI
|
||||
|
||||
from dcim.choices import InterfaceModeChoices
|
||||
from dcim.models import DeviceRole, Platform, Site
|
||||
@@ -331,7 +330,6 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
'name': 'Interface X',
|
||||
'enabled': False,
|
||||
'bridge': interfaces[1].pk,
|
||||
'mac_address': EUI('01-02-03-04-05-06'),
|
||||
'mtu': 65000,
|
||||
'description': 'New description',
|
||||
'mode': InterfaceModeChoices.MODE_TAGGED,
|
||||
@@ -346,7 +344,6 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
'name': 'Interface [4-6]',
|
||||
'enabled': False,
|
||||
'bridge': interfaces[3].pk,
|
||||
'mac_address': EUI('01-02-03-04-05-06'),
|
||||
'mtu': 2000,
|
||||
'description': 'New description',
|
||||
'mode': InterfaceModeChoices.MODE_TAGGED,
|
||||
|
||||
Reference in New Issue
Block a user