From 95919d1a79d035274b65c7790bc41eeb0e90e638 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 18 Nov 2024 12:26:46 -0500 Subject: [PATCH] Designate primary MAC address via a ForeignKey on the interface models --- netbox/dcim/api/serializers_/devices.py | 7 +- netbox/dcim/filtersets.py | 13 +- netbox/dcim/forms/bulk_edit.py | 11 +- netbox/dcim/forms/bulk_import.py | 23 +-- netbox/dcim/forms/common.py | 11 ++ netbox/dcim/forms/filtersets.py | 10 +- netbox/dcim/forms/model_forms.py | 27 +--- netbox/dcim/graphql/types.py | 2 +- netbox/dcim/migrations/0199_macaddress.py | 1 - .../migrations/0200_populate_mac_addresses.py | 26 ++- netbox/dcim/models/device_components.py | 21 ++- netbox/dcim/models/devices.py | 18 --- netbox/dcim/tables/devices.py | 23 ++- netbox/dcim/tests/test_filtersets.py | 22 +-- .../templates/virtualization/vminterface.html | 149 +++++++++--------- netbox/virtualization/filtersets.py | 13 +- netbox/virtualization/forms/model_forms.py | 5 +- netbox/virtualization/graphql/types.py | 1 + ...ress.py => 0047_populate_mac_addresses.py} | 26 ++- .../virtualization/tables/virtualmachines.py | 4 +- 20 files changed, 217 insertions(+), 196 deletions(-) rename netbox/virtualization/migrations/{0047_vminterface_rename_mac_address.py => 0047_populate_mac_addresses.py} (56%) diff --git a/netbox/dcim/api/serializers_/devices.py b/netbox/dcim/api/serializers_/devices.py index deb53706b..eb6c4f9bc 100644 --- a/netbox/dcim/api/serializers_/devices.py +++ b/netbox/dcim/api/serializers_/devices.py @@ -169,8 +169,11 @@ class MACAddressSerializer(NetBoxModelSerializer): class Meta: model = MACAddress - fields = ['mac_address', 'is_primary', 'assigned_object_type', 'assigned_object'] - brief_fields = ('mac_address',) + fields = [ + 'id', 'url', 'display_url', 'display', 'mac_address', 'assigned_object_type', 'assigned_object', + 'description', 'comments', + ] + brief_fields = ('id', 'url', 'display', 'mac_address', 'description') @extend_schema_field(serializers.JSONField(allow_null=True)) def get_assigned_object(self, obj): diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index e4116e63d..73631e50b 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1647,7 +1647,7 @@ class MACAddressFilterSet(NetBoxModelFilterSet): class Meta: model = MACAddress - fields = ('id', 'description', 'is_primary', 'assigned_object_type', 'assigned_object_id') + fields = ('id', 'description', 'assigned_object_type', 'assigned_object_id') def search(self, queryset, name, value): if not value.strip(): @@ -1789,6 +1789,17 @@ class InterfaceFilterSet( 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'), + ) wwn = MultiValueWWNFilter() poe_mode = django_filters.MultipleChoiceFilter( choices=InterfacePoEModeChoices diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 8577d93b3..955f2cea0 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -1732,17 +1732,10 @@ class MACAddressBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) - is_primary = forms.NullBooleanField( - label=_('Is primary'), - required=False, - widget=BulkEditNullBooleanSelect(), - ) comments = CommentField() model = MACAddress fieldsets = ( - FieldSet('description', 'is_primary'), - ) - nullable_fields = ( - 'description', 'comments', + FieldSet('description'), ) + nullable_fields = ('description', 'comments') diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 4a185dd3b..b8a7a007c 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -1203,8 +1203,7 @@ class MACAddressImportForm(NetBoxModelImportForm): class Meta: model = MACAddress fields = [ - 'mac_address', 'device', 'virtual_machine', 'interface', 'is_primary', - 'description', 'comments', 'tags', + 'mac_address', 'device', 'virtual_machine', 'interface', 'is_primary', 'description', 'comments', 'tags', ] def __init__(self, data=None, *args, **kwargs): @@ -1230,25 +1229,27 @@ class MACAddressImportForm(NetBoxModelImportForm): device = self.cleaned_data.get('device') virtual_machine = self.cleaned_data.get('virtual_machine') interface = self.cleaned_data.get('interface') - is_primary = self.cleaned_data.get('is_primary') - # Validate is_primary + # Validate interface assignment if interface and not device and not virtual_machine: raise forms.ValidationError({ "interface": _("Must specify the parent device or VM when assigning an interface") }) - if is_primary and not interface: - raise forms.ValidationError({ - "is_primary": _("No interface specified; cannot set as primary") - }) def save(self, *args, **kwargs): # Set interface assignment - if self.cleaned_data.get('interface'): - self.instance.assigned_object = self.cleaned_data['interface'] + if interface := self.cleaned_data.get('interface'): + self.instance.assigned_object = interface - return super().save(*args, **kwargs) + instance = super().save(*args, **kwargs) + + # Assign the MAC address as primary for its interface, if designated as such + if interface and self.cleaned_data['is_primary'] and self.instance.pk: + interface.primary_mac_address = self.instance + interface.save() + + return instance # diff --git a/netbox/dcim/forms/common.py b/netbox/dcim/forms/common.py index 100898f90..d30ac0784 100644 --- a/netbox/dcim/forms/common.py +++ b/netbox/dcim/forms/common.py @@ -3,7 +3,9 @@ from django.utils.translation import gettext_lazy as _ from dcim.choices import * from dcim.constants import * +from dcim.models import MACAddress from utilities.forms import get_field_value +from utilities.forms.fields import DynamicModelChoiceField __all__ = ( 'InterfaceCommonForm', @@ -18,6 +20,11 @@ class InterfaceCommonForm(forms.Form): max_value=INTERFACE_MTU_MAX, label=_('MTU') ) + primary_mac_address = DynamicModelChoiceField( + queryset=MACAddress.objects.all(), + label=_('Primary MAC address'), + required=False + ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -35,6 +42,10 @@ class InterfaceCommonForm(forms.Form): if interface_mode != InterfaceModeChoices.MODE_Q_IN_Q: del self.fields['qinq_svlan'] + if self.instance and self.instance.pk: + filter_name = f'{self._meta.model._meta.model_name}_id' + self.fields['primary_mac_address'].widget.add_query_param(filter_name, self.instance.pk) + def clean(self): super().clean() diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 73d7ec986..4a373a996 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -1583,21 +1583,13 @@ class MACAddressFilterForm(NetBoxModelFilterSetForm): model = MACAddress fieldsets = ( FieldSet('q', 'filter_id', 'tag'), - FieldSet('mac_address', 'is_primary', name=_('Addressing')), - FieldSet('device_id', 'virtual_machine_id', name=_('Device/VM')), + FieldSet('mac_address', 'device_id', 'virtual_machine_id', name=_('MAC address')), ) selector_fields = ('filter_id', 'q', 'device_id', 'virtual_machine_id') mac_address = forms.CharField( required=False, label=_('MAC address') ) - is_primary = forms.NullBooleanField( - required=False, - label=_('Is primary'), - widget=forms.Select( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index d49b141a5..3e6a99e45 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -1,6 +1,5 @@ from django import forms from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ from timezone_field import TimeZoneFormField @@ -1405,7 +1404,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): FieldSet( 'device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags', name=_('Interface') ), - FieldSet('vrf', 'wwn', name=_('Addressing')), + FieldSet('vrf', 'primary_mac_address', 'wwn', name=_('Addressing')), FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')), FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')), FieldSet('poe_mode', 'poe_type', name=_('PoE')), @@ -1425,7 +1424,8 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): 'device', 'module', 'vdcs', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'poe_mode', 'poe_type', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans', - 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf', 'tags', + 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf', 'primary_mac_address', + 'tags', ] widgets = { 'speed': NumberWithOptions( @@ -1740,10 +1740,6 @@ class MACAddressForm(NetBoxModelForm): queryset=VMInterface.objects.all(), required=False, ) - is_primary = forms.BooleanField( - required=False, - label=_('Primary for interface'), - ) fieldsets = ( FieldSet( @@ -1754,14 +1750,13 @@ class MACAddressForm(NetBoxModelForm): FieldSet('interface', name=_('Device')), FieldSet('vminterface', name=_('Virtual Machine')), ), - 'is_primary', name=_('Assignment') ), ) class Meta: model = MACAddress fields = [ - 'mac_address', 'interface', 'vminterface', 'is_primary', 'description', 'tags', + 'mac_address', 'interface', 'vminterface', 'description', 'tags', ] def __init__(self, *args, **kwargs): @@ -1790,18 +1785,6 @@ class MACAddressForm(NetBoxModelForm): selected_objects[1]: _("A MAC address can only be assigned to a single object.") }) elif selected_objects: - assigned_object = self.cleaned_data[selected_objects[0]] - if self.instance.pk and self.instance.assigned_object and self.instance.is_primary and assigned_object != self.instance.assigned_object: - raise ValidationError( - _("Cannot reassign MAC address while it is designated as the primary for the interface") - ) - self.instance.assigned_object = assigned_object + self.instance.assigned_object = self.cleaned_data[selected_objects[0]] else: self.instance.assigned_object = None - - # Primary MAC address assignment is only available if an interface has been assigned. - interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface') - if self.cleaned_data.get('is_primary') and not interface: - self.add_error( - 'is_primary', _("Only IP addresses assigned to an interface can be designated as primary IPs.") - ) diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 5d044630e..7e51790e2 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -390,7 +390,6 @@ class MACAddressType(NetBoxObjectType): ) class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, PathEndpointMixin): _name: str - mac_address: str | None wwn: str | None parent: Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')] | None bridge: Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')] | None @@ -398,6 +397,7 @@ class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, P wireless_link: Annotated["WirelessLinkType", strawberry.lazy('wireless.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 diff --git a/netbox/dcim/migrations/0199_macaddress.py b/netbox/dcim/migrations/0199_macaddress.py index f9cc6a8df..8068c7436 100644 --- a/netbox/dcim/migrations/0199_macaddress.py +++ b/netbox/dcim/migrations/0199_macaddress.py @@ -24,7 +24,6 @@ class Migration(migrations.Migration): ('description', models.CharField(blank=True, max_length=200)), ('comments', models.TextField(blank=True)), ('mac_address', dcim.fields.MACAddressField()), - ('is_primary', models.BooleanField(default=True)), ('assigned_object_id', models.PositiveBigIntegerField(blank=True, null=True)), ('assigned_object_type', models.ForeignKey(blank=True, limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'dcim'), ('model', 'interface')), models.Q(('app_label', 'virtualization'), ('model', 'vminterface')), _connector='OR')), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')), ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), diff --git a/netbox/dcim/migrations/0200_populate_mac_addresses.py b/netbox/dcim/migrations/0200_populate_mac_addresses.py index 98bf83f5c..1f3c5dee9 100644 --- a/netbox/dcim/migrations/0200_populate_mac_addresses.py +++ b/netbox/dcim/migrations/0200_populate_mac_addresses.py @@ -1,4 +1,5 @@ -from django.db import migrations +import django.db.models.deletion +from django.db import migrations, models def populate_mac_addresses(apps, schema_editor): @@ -9,14 +10,18 @@ def populate_mac_addresses(apps, schema_editor): mac_addresses = [ MACAddress( - mac_address=interface._mac_address, + mac_address=interface.mac_address, assigned_object_type=interface_ct, assigned_object_id=interface.pk ) - for interface in Interface.objects.filter(_mac_address__isnull=False) + for interface in Interface.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: + Interface.objects.filter(pk=mac_address.assigned_object_id).update(primary_mac_address=mac_address) + class Migration(migrations.Migration): @@ -25,11 +30,16 @@ class Migration(migrations.Migration): ] operations = [ - # Rename mac_address field to avoid conflict with property - migrations.RenameField( + migrations.AddField( model_name='interface', - old_name='mac_address', - new_name='_mac_address', + 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, @@ -37,6 +47,6 @@ class Migration(migrations.Migration): ), migrations.RemoveField( model_name='interface', - name='_mac_address', + name='mac_address', ), ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index a49b192b0..9b108bcca 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -567,6 +567,14 @@ class BaseInterface(models.Model): blank=True, verbose_name=_('VLAN Translation Policy') ) + primary_mac_address = models.OneToOneField( + to='dcim.MACAddress', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True, + verbose_name=_('primary MAC address') + ) class Meta: abstract = True @@ -580,6 +588,14 @@ class BaseInterface(models.Model): 'qinq_svlan': _("Only Q-in-Q interfaces may specify a service VLAN.") }) + # Check that the primary MAC address (if any) is assigned to this interface + if self.primary_mac_address and self.primary_mac_address.assigned_object != self: + raise ValidationError({ + 'primary_mac_address': _("MAC address {mac_address} is not assigned to this interface.").format( + mac_address=self.primary_mac_address + ) + }) + def save(self, *args, **kwargs): # Remove untagged VLAN assignment for non-802.1Q interfaces @@ -606,9 +622,8 @@ class BaseInterface(models.Model): @cached_property def mac_address(self): - if macaddress := self.mac_addresses.order_by('-is_primary', 'mac_address').first(): - return macaddress.mac_address - return None + if self.primary_mac_address: + return self.primary_mac_address.mac_address class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEndpoint, TrackingModelMixin): diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index ff374644f..b49d680a3 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -1499,10 +1499,6 @@ class MACAddress(PrimaryModel): ct_field='assigned_object_type', fk_field='assigned_object_id' ) - is_primary = models.BooleanField( - verbose_name=_('is primary'), - default=True - ) class Meta: ordering = ('mac_address',) @@ -1511,17 +1507,3 @@ class MACAddress(PrimaryModel): def __str__(self): return str(self.mac_address) - - def clean(self): - super().clean() - - if self.is_primary and self.assigned_object: - peer_macs = MACAddress.objects.exclude(pk=self.pk).filter( - assigned_object_type=self.assigned_object_type, - assigned_object_id=self.assigned_object_id, - is_primary=True - ) - if peer_macs.exists(): - raise ValidationError({ - 'is_primary': _("A primary MAC address is already designated for this interface.") - }) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 70bd8242e..d226c7335 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -599,6 +599,10 @@ class BaseInterfaceTable(NetBoxTable): verbose_name=_('Q-in-Q SVLAN'), linkify=True ) + primary_mac_address = tables.Column( + verbose_name=_('MAC Address'), + linkify=True + ) def value_ip_addresses(self, value): return ",".join([str(obj.address) for obj in value.all()]) @@ -649,11 +653,11 @@ class InterfaceTable(BaseInterfaceTable, ModularDeviceComponentTable, PathEndpoi model = models.Interface fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', - 'speed', 'speed_formatted', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', - 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', - 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', - 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', - 'inventory_items', 'created', 'last_updated', + 'speed', 'speed_formatted', 'duplex', 'mode', 'primary_mac_address', 'wwn', 'poe_mode', 'poe_type', + 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', + 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', + 'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', + 'qinq_svlan', 'inventory_items', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') @@ -1127,10 +1131,6 @@ class MACAddressTable(NetBoxTable): orderable=False, verbose_name=_('Parent') ) - is_primary = columns.BooleanColumn( - verbose_name=_('Primary'), - false_mark=None - ) tags = columns.TagColumn( url_name='dcim:macaddress_list' ) @@ -1141,7 +1141,6 @@ class MACAddressTable(NetBoxTable): class Meta(DeviceComponentTable.Meta): model = models.MACAddress fields = ( - 'pk', 'id', 'mac_address', 'assigned_object_parent', 'assigned_object', 'is_primary', 'created', - 'last_updated', + 'pk', 'id', 'mac_address', 'assigned_object_parent', 'assigned_object', 'created', 'last_updated', ) - default_columns = ('pk', 'mac_address', 'assigned_object_parent', 'assigned_object', 'is_primary') + default_columns = ('pk', 'mac_address', 'assigned_object_parent', 'assigned_object') diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 03eb52e90..897612b84 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -5891,15 +5891,15 @@ class MACAddressTestCase(TestCase, ChangeLoggedFilterSetTests): mac_addresses = ( # Device MACs - MACAddress(mac_address='00-00-00-01-01-01', assigned_object=interfaces[0], is_primary=True), - MACAddress(mac_address='00-00-00-02-01-01', assigned_object=interfaces[1], is_primary=True), - MACAddress(mac_address='00-00-00-03-01-01', assigned_object=interfaces[2], is_primary=True), - MACAddress(mac_address='00-00-00-03-01-02', assigned_object=interfaces[2], is_primary=False), + MACAddress(mac_address='00-00-00-01-01-01', assigned_object=interfaces[0]), + MACAddress(mac_address='00-00-00-02-01-01', assigned_object=interfaces[1]), + MACAddress(mac_address='00-00-00-03-01-01', assigned_object=interfaces[2]), + MACAddress(mac_address='00-00-00-03-01-02', assigned_object=interfaces[2]), # VM MACs - MACAddress(mac_address='00-00-00-04-01-01', assigned_object=vm_interfaces[0], is_primary=True), - MACAddress(mac_address='00-00-00-05-01-01', assigned_object=vm_interfaces[1], is_primary=True), - MACAddress(mac_address='00-00-00-06-01-01', assigned_object=vm_interfaces[2], is_primary=True), - MACAddress(mac_address='00-00-00-06-01-02', assigned_object=vm_interfaces[2], is_primary=False), + MACAddress(mac_address='00-00-00-04-01-01', assigned_object=vm_interfaces[0]), + MACAddress(mac_address='00-00-00-05-01-01', assigned_object=vm_interfaces[1]), + MACAddress(mac_address='00-00-00-06-01-01', assigned_object=vm_interfaces[2]), + MACAddress(mac_address='00-00-00-06-01-02', assigned_object=vm_interfaces[2]), ) MACAddress.objects.bulk_create(mac_addresses) @@ -5907,12 +5907,6 @@ class MACAddressTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'mac_address': ['00-00-00-01-01-01', '00-00-00-02-01-01']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_is_primary(self): - params = {'is_primary': True} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) - params = {'is_primary': False} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_device(self): devices = Device.objects.all()[:2] params = {'device_id': [devices[0].pk, devices[1].pk]} diff --git a/netbox/templates/virtualization/vminterface.html b/netbox/templates/virtualization/vminterface.html index 1709bcb5f..88c9379cf 100644 --- a/netbox/templates/virtualization/vminterface.html +++ b/netbox/templates/virtualization/vminterface.html @@ -14,80 +14,85 @@ {% block content %}
-
-

{% trans "Interface" %}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{% trans "Virtual Machine" %}{{ object.virtual_machine|linkify }}
{% trans "Name" %}{{ object.name }}
{% trans "Enabled" %} - {% if object.enabled %} - - {% else %} - - {% endif %} -
{% trans "Parent" %}{{ object.parent|linkify|placeholder }}
{% trans "Bridge" %}{{ object.bridge|linkify|placeholder }}
{% trans "VRF" %}{{ object.vrf|linkify|placeholder }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "MTU" %}{{ object.mtu|placeholder }}
{% trans "MAC Address" %} - {% if object.mac_address %} - {{ object.mac_address }} - {% trans "Primary" %} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "802.1Q Mode" %}{{ object.get_mode_display|placeholder }}
{% trans "Tunnel" %}{{ object.tunnel_termination.tunnel|linkify|placeholder }}
{% trans "VLAN Translation" %}{{ object.vlan_translation_policy|linkify|placeholder }}
-
- {% include 'inc/panels/tags.html' %} - {% plugin_left_page object %} +
+

{% trans "Interface" %}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "Virtual Machine" %}{{ object.virtual_machine|linkify }}
{% trans "Name" %}{{ object.name }}
{% trans "Enabled" %} + {% if object.enabled %} + + {% else %} + + {% endif %} +
{% trans "Parent" %}{{ object.parent|linkify|placeholder }}
{% trans "Bridge" %}{{ object.bridge|linkify|placeholder }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "MTU" %}{{ object.mtu|placeholder }}
{% trans "802.1Q Mode" %}{{ object.get_mode_display|placeholder }}
{% trans "Tunnel" %}{{ object.tunnel_termination.tunnel|linkify|placeholder }}
-
- {% include 'inc/panels/custom_fields.html' %} - {% include 'ipam/inc/panels/fhrp_groups.html' %} - {% plugin_right_page object %} + {% include 'inc/panels/tags.html' %} + {% plugin_left_page object %} +
+
+ {% include 'inc/panels/custom_fields.html' %} +
+

{% trans "Addressing" %}

+ + + + + + + + + + + + + +
{% trans "MAC Address" %} + {% if object.mac_address %} + {{ object.mac_address }} + {% trans "Primary" %} + {% else %} + {{ ''|placeholder }} + {% endif %} +
{% trans "VRF" %}{{ object.vrf|linkify|placeholder }}
{% trans "VLAN Translation" %}{{ object.vlan_translation_policy|linkify|placeholder }}
+ {% include 'ipam/inc/panels/fhrp_groups.html' %} + {% plugin_right_page object %} +
diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index 59e017c0e..2f2c67a9f 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -3,7 +3,7 @@ from django.db.models import Q from django.utils.translation import gettext as _ from dcim.filtersets import CommonInterfaceFilterSet, ScopedFilterSet -from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup +from dcim.models import Device, DeviceRole, MACAddress, Platform, Region, Site, SiteGroup from extras.filtersets import LocalConfigContextFilterSet from extras.models import ConfigTemplate from ipam.filtersets import PrimaryIPFilterSet @@ -265,6 +265,17 @@ class VMInterfaceFilterSet(NetBoxModelFilterSet, CommonInterfaceFilterSet): 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 diff --git a/netbox/virtualization/forms/model_forms.py b/netbox/virtualization/forms/model_forms.py index eb1630d63..bf5727a08 100644 --- a/netbox/virtualization/forms/model_forms.py +++ b/netbox/virtualization/forms/model_forms.py @@ -358,7 +358,7 @@ class VMInterfaceForm(InterfaceCommonForm, VMComponentForm): fieldsets = ( FieldSet('virtual_machine', 'name', 'description', 'tags', name=_('Interface')), - FieldSet('vrf', name=_('Addressing')), + FieldSet('vrf', 'primary_mac_address', name=_('Addressing')), FieldSet('mtu', 'enabled', name=_('Operation')), FieldSet('parent', 'bridge', name=_('Related Interfaces')), FieldSet( @@ -371,7 +371,8 @@ class VMInterfaceForm(InterfaceCommonForm, VMComponentForm): model = VMInterface fields = [ 'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mtu', 'description', 'mode', 'vlan_group', - 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf', 'tags', + 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf', 'primary_mac_address', + 'tags', ] labels = { 'mode': _('802.1Q Mode'), diff --git a/netbox/virtualization/graphql/types.py b/netbox/virtualization/graphql/types.py index 35e783fa7..33d6ce450 100644 --- a/netbox/virtualization/graphql/types.py +++ b/netbox/virtualization/graphql/types.py @@ -106,6 +106,7 @@ 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 diff --git a/netbox/virtualization/migrations/0047_vminterface_rename_mac_address.py b/netbox/virtualization/migrations/0047_populate_mac_addresses.py similarity index 56% rename from netbox/virtualization/migrations/0047_vminterface_rename_mac_address.py rename to netbox/virtualization/migrations/0047_populate_mac_addresses.py index 092b9eb50..95316837c 100644 --- a/netbox/virtualization/migrations/0047_vminterface_rename_mac_address.py +++ b/netbox/virtualization/migrations/0047_populate_mac_addresses.py @@ -1,4 +1,5 @@ -from django.db import migrations +import django.db.models.deletion +from django.db import migrations, models def populate_mac_addresses(apps, schema_editor): @@ -9,14 +10,18 @@ def populate_mac_addresses(apps, schema_editor): mac_addresses = [ MACAddress( - mac_address=vminterface._mac_address, + 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) + 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): @@ -26,11 +31,16 @@ class Migration(migrations.Migration): ] operations = [ - # Rename mac_address field to avoid conflict with property - migrations.RenameField( + migrations.AddField( model_name='vminterface', - old_name='mac_address', - new_name='_mac_address', + 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, @@ -38,6 +48,6 @@ class Migration(migrations.Migration): ), migrations.RemoveField( model_name='vminterface', - name='_mac_address', + name='mac_address', ), ] diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py index ad41d5c13..fe7a66ac1 100644 --- a/netbox/virtualization/tables/virtualmachines.py +++ b/netbox/virtualization/tables/virtualmachines.py @@ -153,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')