Просмотр исходного кода

Closes #18821: Simplify setting/updating primary MAC through interface model

- Add writable `mac_address` shortcut field to `InterfaceSerializer` and
  `VMInterfaceSerializer`: on write, creates, promotes, or clears the
  primary MACAddress in one API request (find-or-create to prevent
  duplicates on the same interface)
- Replace the `primary_mac_address` DynamicModelChoiceField in
  `InterfaceForm` / `VMInterfaceForm` with a plain text `mac_address`
  field via `InterfaceCommonForm`; `save()` applies the same
  find-or-create logic
- Add `MACAddressSetPrimaryView` (`dcim:macaddress_set_primary`) and
  surface it as a "Set as primary" entry in the `MACAddressTable` actions
  dropdown, visible only for non-primary MACs assigned to an interface
- Add "Add" button to the MAC Addresses inline panel on the Interface
  detail view, pre-scoped to the current interface

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Brian Tiemann 1 неделя назад
Родитель
Сommit
790f462715

+ 54 - 3
netbox/dcim/api/serializers_/device_components.py

@@ -1,5 +1,6 @@
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
+from netaddr import EUI, AddrFormatError
 from rest_framework import serializers
 from rest_framework import serializers
 
 
 from dcim.choices import *
 from dcim.choices import *
@@ -11,6 +12,7 @@ from dcim.models import (
     FrontPort,
     FrontPort,
     Interface,
     Interface,
     InventoryItem,
     InventoryItem,
+    MACAddress,
     ModuleBay,
     ModuleBay,
     PortMapping,
     PortMapping,
     PowerOutlet,
     PowerOutlet,
@@ -38,6 +40,8 @@ from .manufacturers import ManufacturerSerializer
 from .nested import NestedInterfaceSerializer
 from .nested import NestedInterfaceSerializer
 from .roles import InventoryItemRoleSerializer
 from .roles import InventoryItemRoleSerializer
 
 
+_UNSET = object()
+
 __all__ = (
 __all__ = (
     'ConsolePortSerializer',
     'ConsolePortSerializer',
     'ConsoleServerPortSerializer',
     'ConsoleServerPortSerializer',
@@ -249,8 +253,9 @@ class InterfaceSerializer(
     )
     )
     count_ipaddresses = serializers.IntegerField(read_only=True)
     count_ipaddresses = serializers.IntegerField(read_only=True)
     count_fhrp_groups = serializers.IntegerField(read_only=True)
     count_fhrp_groups = serializers.IntegerField(read_only=True)
-    # Maintains backward compatibility with NetBox <v4.2
-    mac_address = serializers.CharField(allow_null=True, read_only=True)
+    # Maintains backward compatibility with NetBox <v4.2; also accepts a MAC string on write to
+    # create/update the primary MAC address in a single request.
+    mac_address = serializers.CharField(allow_null=True, required=False)
     primary_mac_address = MACAddressSerializer(nested=True, required=False, allow_null=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)
     mac_addresses = MACAddressSerializer(many=True, nested=True, read_only=True, allow_null=True)
     wwn = serializers.CharField(required=False, default=None, allow_blank=True, allow_null=True)
     wwn = serializers.CharField(required=False, default=None, allow_blank=True, allow_null=True)
@@ -270,8 +275,18 @@ class InterfaceSerializer(
         brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
         brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
 
 
     def validate(self, data):
     def validate(self, data):
+        # Pop mac_address before model validation — it's a cached_property, not a model field,
+        # and passing it to Interface(**attrs) in ValidatedModelSerializer.validate() would raise TypeError.
+        mac_address = data.pop('mac_address', _UNSET)
 
 
         if not self.nested:
         if not self.nested:
+            if mac_address not in (_UNSET, None):
+                try:
+                    EUI(mac_address, version=48)
+                except (AddrFormatError, ValueError, TypeError):
+                    raise serializers.ValidationError({
+                        'mac_address': _('Enter a valid MAC address (e.g. 00:11:22:33:44:55).')
+                    })
 
 
             # Validate 802.1q mode and vlan(s)
             # Validate 802.1q mode and vlan(s)
             mode = None
             mode = None
@@ -329,7 +344,43 @@ class InterfaceSerializer(
                                         f"or it must be global."
                                         f"or it must be global."
                     })
                     })
 
 
-        return super().validate(data)
+        data = super().validate(data)
+
+        if mac_address is not _UNSET:
+            data['mac_address'] = mac_address
+
+        return data
+
+    def create(self, validated_data):
+        mac_address = validated_data.pop('mac_address', None)
+        instance = super().create(validated_data)
+        if mac_address is not None:
+            mac = MACAddress.objects.create(mac_address=mac_address, assigned_object=instance)
+            instance.primary_mac_address = mac
+            instance.save()
+            instance.__dict__.pop('mac_address', None)
+        return instance
+
+    def update(self, instance, validated_data):
+        mac_address = validated_data.pop('mac_address', _UNSET)
+        instance = super().update(instance, validated_data)
+        if mac_address is _UNSET:
+            pass
+        elif mac_address is None:
+            instance.primary_mac_address = None
+            instance.save()
+            instance.__dict__.pop('mac_address', None)
+        else:
+            # Find an existing MACAddress on this interface with the target value, or create one.
+            # Using find-or-create avoids duplicating a MAC that already exists on this interface.
+            mac = instance.mac_addresses.filter(mac_address=mac_address).first()
+            if mac is None:
+                mac = MACAddress.objects.create(mac_address=mac_address, assigned_object=instance)
+            if instance.primary_mac_address_id != mac.pk:
+                instance.primary_mac_address = mac
+                instance.save()
+            instance.__dict__.pop('mac_address', None)
+        return instance
 
 
 
 
 class RearPortMappingSerializer(serializers.ModelSerializer):
 class RearPortMappingSerializer(serializers.ModelSerializer):

+ 47 - 3
netbox/dcim/forms/common.py

@@ -1,5 +1,6 @@
 from django import forms
 from django import forms
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
+from netaddr import EUI, AddrFormatError
 
 
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
@@ -19,6 +20,12 @@ class InterfaceCommonForm(forms.Form):
         max_value=INTERFACE_MTU_MAX,
         max_value=INTERFACE_MTU_MAX,
         label=_('MTU')
         label=_('MTU')
     )
     )
+    mac_address = forms.CharField(
+        required=False,
+        empty_value=None,
+        label=_('MAC address'),
+        help_text=_('Enter a MAC address to create and assign it as the primary MAC in one step.')
+    )
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
@@ -36,12 +43,21 @@ class InterfaceCommonForm(forms.Form):
         if interface_mode != InterfaceModeChoices.MODE_Q_IN_Q:
         if interface_mode != InterfaceModeChoices.MODE_Q_IN_Q:
             del self.fields['qinq_svlan']
             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)
+        if self.instance and self.instance.pk and self.instance.primary_mac_address:
+            # Pre-populate mac_address with the current primary MAC string so it round-trips cleanly
+            self.fields['mac_address'].initial = str(self.instance.primary_mac_address.mac_address)
 
 
     def clean(self):
     def clean(self):
         super().clean()
         super().clean()
+
+        mac_address = self.cleaned_data.get('mac_address')
+        if mac_address:
+            try:
+                EUI(mac_address, version=48)
+            except (AddrFormatError, ValueError, TypeError):
+                raise forms.ValidationError({
+                    'mac_address': _('Enter a valid MAC address (e.g. 00:11:22:33:44:55).')
+                })
         parent_field = 'device' if 'device' in self.cleaned_data else 'virtual_machine'
         parent_field = 'device' if 'device' in self.cleaned_data else 'virtual_machine'
         if 'tagged_vlans' in self.fields.keys():
         if 'tagged_vlans' in self.fields.keys():
             tagged_vlans = self.cleaned_data.get('tagged_vlans') if self.is_bound else \
             tagged_vlans = self.cleaned_data.get('tagged_vlans') if self.is_bound else \
@@ -68,6 +84,34 @@ class InterfaceCommonForm(forms.Form):
             if 'tagged_vlans' not in self.cleaned_data and self.instance.tagged_vlans is not None:
             if 'tagged_vlans' not in self.cleaned_data and self.instance.tagged_vlans is not None:
                 self.instance.tagged_vlans.clear()
                 self.instance.tagged_vlans.clear()
 
 
+    def save(self, commit=True):
+        instance = super().save(commit=commit)
+
+        if 'mac_address' not in self.changed_data:
+            return instance
+
+        from dcim.models import MACAddress
+        mac_address = self.cleaned_data.get('mac_address')
+
+        if mac_address:
+            # Find an existing MACAddress on this interface with the target value, or create one.
+            # Using find-or-create avoids duplicating a MAC that already exists on this interface.
+            mac = instance.mac_addresses.filter(mac_address=mac_address).first()
+            if mac is None:
+                mac = MACAddress(mac_address=mac_address, assigned_object=instance)
+                mac.save()
+            if instance.primary_mac_address_id != mac.pk:
+                instance.primary_mac_address = mac
+                if commit:
+                    instance.save()
+        else:
+            instance.primary_mac_address = None
+            if commit:
+                instance.save()
+
+        instance.__dict__.pop('mac_address', None)
+        return instance
+
 
 
 class ModuleCommonForm(forms.Form):
 class ModuleCommonForm(forms.Form):
 
 

+ 2 - 9
netbox/dcim/forms/model_forms.py

@@ -1618,13 +1618,6 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
         required=False,
         required=False,
         label=_('VRF')
         label=_('VRF')
     )
     )
-    primary_mac_address = DynamicModelChoiceField(
-        queryset=MACAddress.objects.all(),
-        label=_('Primary MAC address'),
-        required=False,
-        quick_add=True,
-        quick_add_params={'interface': '$pk'}
-    )
     wwn = forms.CharField(
     wwn = forms.CharField(
         empty_value=None,
         empty_value=None,
         required=False,
         required=False,
@@ -1640,7 +1633,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
         FieldSet(
         FieldSet(
             'device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags', name=_('Interface')
             'device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags', name=_('Interface')
         ),
         ),
-        FieldSet('vrf', 'primary_mac_address', 'wwn', name=_('Addressing')),
+        FieldSet('vrf', 'mac_address', 'wwn', name=_('Addressing')),
         FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')),
         FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')),
         FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')),
         FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')),
         FieldSet('poe_mode', 'poe_type', name=_('PoE')),
         FieldSet('poe_mode', 'poe_type', name=_('PoE')),
@@ -1661,7 +1654,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
             'device', 'module', 'vdcs', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge',
             'device', 'module', 'vdcs', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge',
             'lag', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'poe_mode', 'poe_type', 'mode',
             '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',
             'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans',
-            'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf', 'primary_mac_address',
+            'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf',
             'owner', 'tags',
             'owner', 'tags',
         ]
         ]
         widgets = {
         widgets = {

+ 20 - 1
netbox/dcim/tables/devices.py

@@ -1218,6 +1218,24 @@ class VirtualDeviceContextTable(TenancyColumnsMixin, PrimaryModelTable):
         )
         )
 
 
 
 
+class MACAddressActionsColumn(columns.ActionsColumn):
+    actions = {
+        **columns.ActionsColumn.actions,
+        'set_primary': columns.ActionsItem('Set as primary', 'star-outline', None, 'warning'),
+    }
+
+    def render(self, record, table, **kwargs):
+        # Only offer "Set as primary" for non-primary MACs that are assigned to an interface.
+        if record.is_primary or not record.assigned_object_id:
+            saved = self.actions
+            try:
+                self.actions = {k: v for k, v in saved.items() if k != 'set_primary'}
+                return super().render(record, table, **kwargs)
+            finally:
+                self.actions = saved
+        return super().render(record, table, **kwargs)
+
+
 class MACAddressTable(PrimaryModelTable):
 class MACAddressTable(PrimaryModelTable):
     mac_address = tables.TemplateColumn(
     mac_address = tables.TemplateColumn(
         template_code=MACADDRESS_LINK,
         template_code=MACADDRESS_LINK,
@@ -1241,7 +1259,8 @@ class MACAddressTable(PrimaryModelTable):
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='dcim:macaddress_list'
         url_name='dcim:macaddress_list'
     )
     )
-    actions = columns.ActionsColumn(
+    actions = MACAddressActionsColumn(
+        actions=('edit', 'delete', 'changelog', 'set_primary'),
         extra_buttons=MACADDRESS_COPY_BUTTON
         extra_buttons=MACADDRESS_COPY_BUTTON
     )
     )
 
 

+ 62 - 0
netbox/dcim/tests/test_api.py

@@ -2829,6 +2829,68 @@ class InterfaceTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTest
         # Tagged-all mode, qinq service vlan
         # Tagged-all mode, qinq service vlan
         self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_TAGGED_ALL, invalid_data)
         self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_TAGGED_ALL, invalid_data)
 
 
+    def test_mac_address_create(self):
+        """
+        Creating an interface with mac_address creates the primary MACAddress in one request.
+        """
+        self.add_permissions('dcim.add_interface', 'dcim.add_macaddress')
+        device = Device.objects.first()
+        data = {
+            'device': device.pk,
+            'name': 'Interface MAC Create',
+            'type': '1000base-t',
+            'mac_address': 'AA:BB:CC:DD:EE:FF',
+        }
+        response = self.client.post(self._get_list_url(), data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        iface = Interface.objects.get(pk=response.data['id'])
+        self.assertIsNotNone(iface.primary_mac_address)
+        self.assertEqual(str(iface.primary_mac_address.mac_address).upper(), 'AA:BB:CC:DD:EE:FF')
+        self.assertEqual(iface.primary_mac_address.assigned_object, iface)
+
+    def test_mac_address_update(self):
+        """
+        Patching mac_address creates/updates the primary MACAddress in one request.
+        """
+        self.add_permissions('dcim.change_interface', 'dcim.add_macaddress', 'dcim.change_macaddress')
+        iface = Interface.objects.first()
+        url = self._get_detail_url(iface)
+
+        # Set a new primary MAC via mac_address shortcut
+        response = self.client.patch(url, {'mac_address': '11:22:33:44:55:66'}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        iface.refresh_from_db()
+        self.assertIsNotNone(iface.primary_mac_address)
+        self.assertEqual(str(iface.primary_mac_address.mac_address).upper(), '11:22:33:44:55:66')
+
+        # Update the MAC to a new value
+        response = self.client.patch(url, {'mac_address': 'AA:BB:CC:DD:EE:FF'}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        iface.refresh_from_db()
+        self.assertEqual(str(iface.primary_mac_address.mac_address).upper(), 'AA:BB:CC:DD:EE:FF')
+
+        # Clear the primary MAC by sending null
+        response = self.client.patch(url, {'mac_address': None}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        iface.refresh_from_db()
+        self.assertIsNone(iface.primary_mac_address)
+
+    def test_mac_address_invalid(self):
+        """
+        Sending an invalid MAC address string returns a 400 error.
+        """
+        self.add_permissions('dcim.add_interface', 'dcim.add_macaddress')
+        device = Device.objects.first()
+        data = {
+            'device': device.pk,
+            'name': 'Interface MAC Bad',
+            'type': '1000base-t',
+            'mac_address': 'not-a-mac',
+        }
+        response = self.client.post(self._get_list_url(), data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertIn('mac_address', response.data)
+
 
 
 class FrontPortTestCase(APIViewTestCases.APIViewTestCase):
 class FrontPortTestCase(APIViewTestCases.APIViewTestCase):
     model = FrontPort
     model = FrontPort

+ 49 - 0
netbox/dcim/views.py

@@ -3415,6 +3415,11 @@ class InterfaceView(generic.ObjectView):
                 filters={'interface_id': lambda ctx: ctx['object'].pk},
                 filters={'interface_id': lambda ctx: ctx['object'].pk},
                 title=_('MAC Addresses'),
                 title=_('MAC Addresses'),
                 exclude_columns=['assigned_object', 'assigned_object_parent'],
                 exclude_columns=['assigned_object', 'assigned_object_parent'],
+                actions=[
+                    actions.AddObject(
+                        'dcim.MACAddress', url_params={'interface': lambda ctx: ctx['object'].pk}
+                    ),
+                ],
             ),
             ),
             ObjectsTablePanel(
             ObjectsTablePanel(
                 model='ipam.VLAN',
                 model='ipam.VLAN',
@@ -5025,6 +5030,50 @@ class MACAddressDeleteView(generic.ObjectDeleteView):
     queryset = MACAddress.objects.all()
     queryset = MACAddress.objects.all()
 
 
 
 
+@register_model_view(MACAddress, 'set_primary')
+class MACAddressSetPrimaryView(View):
+    queryset = MACAddress.objects.all()
+
+    def get(self, request, pk):
+        return self._handle(request, pk)
+
+    def post(self, request, pk):
+        return self._handle(request, pk)
+
+    def _handle(self, request, pk):
+        if not request.user.is_authenticated:
+            from django.contrib.auth.views import redirect_to_login
+            return redirect_to_login(request.get_full_path())
+
+        mac = get_object_or_404(self.queryset, pk=pk)
+        assigned_object = mac.assigned_object
+
+        if assigned_object is None:
+            messages.error(request, _('This MAC address is not assigned to an interface.'))
+            return redirect(mac.get_absolute_url())
+
+        perm = get_permission_for_model(assigned_object, 'change')
+        if not request.user.has_perm(perm):
+            messages.error(
+                request,
+                _('You do not have permission to modify {object}.').format(object=assigned_object)
+            )
+            return redirect(mac.get_absolute_url())
+
+        if assigned_object.primary_mac_address_id != mac.pk:
+            assigned_object.snapshot()
+            assigned_object.primary_mac_address = mac
+            assigned_object.save()
+            messages.success(
+                request,
+                _('Set {mac} as primary MAC address for {interface}.').format(
+                    mac=mac, interface=assigned_object
+                )
+            )
+
+        return redirect(assigned_object.get_absolute_url())
+
+
 @register_model_view(MACAddress, 'bulk_import', path='import', detail=False)
 @register_model_view(MACAddress, 'bulk_import', path='import', detail=False)
 class MACAddressBulkImportView(generic.BulkImportView):
 class MACAddressBulkImportView(generic.BulkImportView):
     queryset = MACAddress.objects.all()
     queryset = MACAddress.objects.all()

+ 59 - 3
netbox/virtualization/api/serializers_/virtualmachines.py

@@ -1,4 +1,6 @@
+from django.utils.translation import gettext as _
 from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.utils import extend_schema_field
+from netaddr import EUI, AddrFormatError
 from rest_framework import serializers
 from rest_framework import serializers
 
 
 from dcim.api.serializers_.devices import DeviceSerializer, MACAddressSerializer
 from dcim.api.serializers_.devices import DeviceSerializer, MACAddressSerializer
@@ -6,6 +8,7 @@ from dcim.api.serializers_.platforms import PlatformSerializer
 from dcim.api.serializers_.roles import DeviceRoleSerializer
 from dcim.api.serializers_.roles import DeviceRoleSerializer
 from dcim.api.serializers_.sites import SiteSerializer
 from dcim.api.serializers_.sites import SiteSerializer
 from dcim.choices import InterfaceModeChoices
 from dcim.choices import InterfaceModeChoices
+from dcim.models import MACAddress
 from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
 from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
 from ipam.api.serializers_.ip import IPAddressSerializer
 from ipam.api.serializers_.ip import IPAddressSerializer
 from ipam.api.serializers_.vlans import VLANSerializer, VLANTranslationPolicySerializer
 from ipam.api.serializers_.vlans import VLANSerializer, VLANTranslationPolicySerializer
@@ -22,6 +25,8 @@ from ...models import VirtualDisk, VirtualMachine, VirtualMachineType, VMInterfa
 from .clusters import ClusterSerializer
 from .clusters import ClusterSerializer
 from .nested import NestedVMInterfaceSerializer
 from .nested import NestedVMInterfaceSerializer
 
 
+_UNSET = object()
+
 __all__ = (
 __all__ = (
     'VMInterfaceSerializer',
     'VMInterfaceSerializer',
     'VirtualDiskSerializer',
     'VirtualDiskSerializer',
@@ -120,8 +125,9 @@ class VMInterfaceSerializer(OwnerMixin, NetBoxModelSerializer):
     l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True)
     l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True)
     count_ipaddresses = serializers.IntegerField(read_only=True)
     count_ipaddresses = serializers.IntegerField(read_only=True)
     count_fhrp_groups = serializers.IntegerField(read_only=True)
     count_fhrp_groups = serializers.IntegerField(read_only=True)
-    # Maintains backward compatibility with NetBox <v4.2
-    mac_address = serializers.CharField(allow_null=True, read_only=True)
+    # Maintains backward compatibility with NetBox <v4.2; also accepts a MAC string on write to
+    # create/update the primary MAC address in a single request.
+    mac_address = serializers.CharField(allow_null=True, required=False)
     primary_mac_address = MACAddressSerializer(nested=True, required=False, allow_null=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)
     mac_addresses = MACAddressSerializer(many=True, nested=True, read_only=True, allow_null=True)
 
 
@@ -136,6 +142,20 @@ class VMInterfaceSerializer(OwnerMixin, NetBoxModelSerializer):
         brief_fields = ('id', 'url', 'display', 'virtual_machine', 'name', 'description')
         brief_fields = ('id', 'url', 'display', 'virtual_machine', 'name', 'description')
 
 
     def validate(self, data):
     def validate(self, data):
+        # Pop mac_address before model validation — it's a cached_property, not a model field.
+        # data may be a VMInterface instance (not a dict) in some custom field code paths (#18887).
+        mac_address = _UNSET
+        if isinstance(data, dict):
+            mac_address = data.pop('mac_address', _UNSET)
+
+        if not self.nested and isinstance(data, dict) and mac_address not in (_UNSET, None):
+            try:
+                EUI(mac_address, version=48)
+            except (AddrFormatError, ValueError, TypeError):
+                raise serializers.ValidationError({
+                    'mac_address': _('Enter a valid MAC address (e.g. 00:11:22:33:44:55).')
+                })
+
         # Validate many-to-many VLAN assignments
         # Validate many-to-many VLAN assignments
         virtual_machine = None
         virtual_machine = None
         tagged_vlans = []
         tagged_vlans = []
@@ -163,7 +183,43 @@ class VMInterfaceSerializer(OwnerMixin, NetBoxModelSerializer):
                                         f"machine, or it must be global."
                                         f"machine, or it must be global."
                     })
                     })
 
 
-        return super().validate(data)
+        data = super().validate(data)
+
+        if mac_address is not _UNSET:
+            data['mac_address'] = mac_address
+
+        return data
+
+    def create(self, validated_data):
+        mac_address = validated_data.pop('mac_address', None)
+        instance = super().create(validated_data)
+        if mac_address is not None:
+            mac = MACAddress.objects.create(mac_address=mac_address, assigned_object=instance)
+            instance.primary_mac_address = mac
+            instance.save()
+            instance.__dict__.pop('mac_address', None)
+        return instance
+
+    def update(self, instance, validated_data):
+        mac_address = validated_data.pop('mac_address', _UNSET)
+        instance = super().update(instance, validated_data)
+        if mac_address is _UNSET:
+            pass
+        elif mac_address is None:
+            instance.primary_mac_address = None
+            instance.save()
+            instance.__dict__.pop('mac_address', None)
+        else:
+            # Find an existing MACAddress on this interface with the target value, or create one.
+            # Using find-or-create avoids duplicating a MAC that already exists on this interface.
+            mac = instance.mac_addresses.filter(mac_address=mac_address).first()
+            if mac is None:
+                mac = MACAddress.objects.create(mac_address=mac_address, assigned_object=instance)
+            if instance.primary_mac_address_id != mac.pk:
+                instance.primary_mac_address = mac
+                instance.save()
+            instance.__dict__.pop('mac_address', None)
+        return instance
 
 
 
 
 #
 #

+ 3 - 10
netbox/virtualization/forms/model_forms.py

@@ -7,7 +7,7 @@ from django.utils.translation import gettext_lazy as _
 
 
 from dcim.forms.common import InterfaceCommonForm
 from dcim.forms.common import InterfaceCommonForm
 from dcim.forms.mixins import ScopedForm
 from dcim.forms.mixins import ScopedForm
-from dcim.models import Device, DeviceRole, MACAddress, Platform, Rack, Region, Site, SiteGroup
+from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup
 from extras.models import ConfigTemplate
 from extras.models import ConfigTemplate
 from ipam.choices import VLANQinQRoleChoices
 from ipam.choices import VLANQinQRoleChoices
 from ipam.models import VLAN, VRF, IPAddress, VLANGroup, VLANTranslationPolicy
 from ipam.models import VLAN, VRF, IPAddress, VLANGroup, VLANTranslationPolicy
@@ -381,13 +381,6 @@ class VMComponentForm(OwnerMixin, NetBoxModelForm):
 
 
 
 
 class VMInterfaceForm(InterfaceCommonForm, VMComponentForm):
 class VMInterfaceForm(InterfaceCommonForm, VMComponentForm):
-    primary_mac_address = DynamicModelChoiceField(
-        queryset=MACAddress.objects.all(),
-        label=_('Primary MAC address'),
-        required=False,
-        quick_add=True,
-        quick_add_params={'vminterface': '$pk'}
-    )
     parent = DynamicModelChoiceField(
     parent = DynamicModelChoiceField(
         queryset=VMInterface.objects.all(),
         queryset=VMInterface.objects.all(),
         required=False,
         required=False,
@@ -450,7 +443,7 @@ class VMInterfaceForm(InterfaceCommonForm, VMComponentForm):
 
 
     fieldsets = (
     fieldsets = (
         FieldSet('virtual_machine', 'name', 'description', 'tags', name=_('Interface')),
         FieldSet('virtual_machine', 'name', 'description', 'tags', name=_('Interface')),
-        FieldSet('vrf', 'primary_mac_address', name=_('Addressing')),
+        FieldSet('vrf', 'mac_address', name=_('Addressing')),
         FieldSet('mtu', 'enabled', name=_('Operation')),
         FieldSet('mtu', 'enabled', name=_('Operation')),
         FieldSet('parent', 'bridge', name=_('Related Interfaces')),
         FieldSet('parent', 'bridge', name=_('Related Interfaces')),
         FieldSet(
         FieldSet(
@@ -464,7 +457,7 @@ class VMInterfaceForm(InterfaceCommonForm, VMComponentForm):
         model = VMInterface
         model = VMInterface
         fields = [
         fields = [
             'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mtu', 'description', 'mode', 'vlan_group',
             'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mtu', 'description', 'mode', 'vlan_group',
-            'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf', 'primary_mac_address',
+            'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf',
             'owner', 'tags',
             'owner', 'tags',
         ]
         ]
         labels = {
         labels = {

+ 60 - 0
netbox/virtualization/tests/test_api.py

@@ -727,6 +727,66 @@ class VMInterfaceTestCase(APIViewTestCases.APIViewTestCase):
         self.client.delete(self._get_list_url(), data, format='json', **self.header)
         self.client.delete(self._get_list_url(), data, format='json', **self.header)
         self.assertEqual(virtual_machine.interfaces.count(), 2)  # Child & parent were both deleted
         self.assertEqual(virtual_machine.interfaces.count(), 2)  # Child & parent were both deleted
 
 
+    def test_mac_address_create(self):
+        """
+        Creating a VMInterface with mac_address creates the primary MACAddress in one request.
+        """
+        self.add_permissions('virtualization.add_vminterface', 'dcim.add_macaddress')
+        vm = VMInterface.objects.first().virtual_machine
+        data = {
+            'virtual_machine': vm.pk,
+            'name': 'Interface MAC Create',
+            'mac_address': 'AA:BB:CC:DD:EE:FF',
+        }
+        response = self.client.post(self._get_list_url(), data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        iface = VMInterface.objects.get(pk=response.data['id'])
+        self.assertIsNotNone(iface.primary_mac_address)
+        self.assertEqual(str(iface.primary_mac_address.mac_address).upper(), 'AA:BB:CC:DD:EE:FF')
+        self.assertEqual(iface.primary_mac_address.assigned_object, iface)
+
+    def test_mac_address_update(self):
+        """
+        Patching mac_address creates/updates the primary MACAddress in one request.
+        """
+        self.add_permissions('virtualization.change_vminterface', 'dcim.add_macaddress', 'dcim.change_macaddress')
+        iface = VMInterface.objects.first()
+        url = self._get_detail_url(iface)
+
+        # Set a new primary MAC via mac_address shortcut
+        response = self.client.patch(url, {'mac_address': '11:22:33:44:55:66'}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        iface.refresh_from_db()
+        self.assertIsNotNone(iface.primary_mac_address)
+        self.assertEqual(str(iface.primary_mac_address.mac_address).upper(), '11:22:33:44:55:66')
+
+        # Update the MAC to a new value
+        response = self.client.patch(url, {'mac_address': 'AA:BB:CC:DD:EE:FF'}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        iface.refresh_from_db()
+        self.assertEqual(str(iface.primary_mac_address.mac_address).upper(), 'AA:BB:CC:DD:EE:FF')
+
+        # Clear the primary MAC by sending null
+        response = self.client.patch(url, {'mac_address': None}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        iface.refresh_from_db()
+        self.assertIsNone(iface.primary_mac_address)
+
+    def test_mac_address_invalid(self):
+        """
+        Sending an invalid MAC address string returns a 400 error.
+        """
+        self.add_permissions('virtualization.add_vminterface', 'dcim.add_macaddress')
+        vm = VMInterface.objects.first().virtual_machine
+        data = {
+            'virtual_machine': vm.pk,
+            'name': 'Interface MAC Bad',
+            'mac_address': 'not-a-mac',
+        }
+        response = self.client.post(self._get_list_url(), data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertIn('mac_address', response.data)
+
 
 
 class VirtualDiskTestCase(APIViewTestCases.APIViewTestCase):
 class VirtualDiskTestCase(APIViewTestCases.APIViewTestCase):
     model = VirtualDisk
     model = VirtualDisk