Sfoglia il codice sorgente

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

bctiemann 1 mese fa
parent
commit
800db5727f

+ 27 - 5
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 *
@@ -35,6 +36,7 @@ from .base import ConnectedEndpointsSerializer, PortSerializer
 from .cables import CabledObjectSerializer
 from .cables import CabledObjectSerializer
 from .devices import DeviceSerializer, MACAddressSerializer, ModuleSerializer, VirtualDeviceContextSerializer
 from .devices import DeviceSerializer, MACAddressSerializer, ModuleSerializer, VirtualDeviceContextSerializer
 from .manufacturers import ManufacturerSerializer
 from .manufacturers import ManufacturerSerializer
+from .mixins import _UNSET, MACAddressShortcutMixin
 from .nested import NestedInterfaceSerializer
 from .nested import NestedInterfaceSerializer
 from .roles import InventoryItemRoleSerializer
 from .roles import InventoryItemRoleSerializer
 
 
@@ -197,6 +199,7 @@ class PowerOutletSerializer(
 
 
 
 
 class InterfaceSerializer(
 class InterfaceSerializer(
+    MACAddressShortcutMixin,
     OwnerMixin,
     OwnerMixin,
     NetBoxModelSerializer,
     NetBoxModelSerializer,
     CabledObjectSerializer,
     CabledObjectSerializer,
@@ -249,8 +252,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 +274,21 @@ 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):
-
-        if not self.nested:
+        # 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.
+        # data may be an Interface 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):
+            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 +346,12 @@ 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
 
 
 
 
 class RearPortMappingSerializer(serializers.ModelSerializer):
 class RearPortMappingSerializer(serializers.ModelSerializer):

+ 69 - 0
netbox/dcim/api/serializers_/mixins.py

@@ -0,0 +1,69 @@
+from django.db import transaction
+from django.utils.translation import gettext as _
+from rest_framework.exceptions import PermissionDenied
+
+from dcim.models import MACAddress
+
+_UNSET = object()
+
+__all__ = (
+    '_UNSET',
+    'MACAddressShortcutMixin',
+)
+
+
+class MACAddressShortcutMixin:
+    """
+    Mixin for Interface and VMInterface serializers that adds a write-only `mac_address` shortcut
+    field for creating/updating the primary MACAddress in a single request.
+    """
+
+    def create(self, validated_data):
+        mac_address = validated_data.pop('mac_address', None)
+        if mac_address is not None:
+            request = self.context.get('request')
+            if request and not request.user.has_perm('dcim.add_macaddress'):
+                raise PermissionDenied(_('You do not have permission to create MAC addresses.'))
+        with transaction.atomic():
+            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)
+
+        # Check permission and locate any existing MAC before any writes.
+        if mac_address not in (_UNSET, None):
+            existing_mac = instance.mac_addresses.filter(mac_address=mac_address).first()
+            if existing_mac is None:
+                request = self.context.get('request')
+                if request and not request.user.has_perm('dcim.add_macaddress'):
+                    raise PermissionDenied(_('You do not have permission to create MAC addresses.'))
+        else:
+            existing_mac = None
+
+        with transaction.atomic():
+            instance = super().update(instance, validated_data)
+            if mac_address is _UNSET:
+                pass
+            elif mac_address is None:
+                if instance.primary_mac_address_id is not None:
+                    instance.snapshot()
+                    instance.primary_mac_address = None
+                    instance.save()
+            else:
+                # Find-or-create: prefer existing MAC on this interface; create only if absent.
+                mac = existing_mac
+                if mac is None:
+                    mac = MACAddress.objects.create(mac_address=mac_address, assigned_object=instance)
+                if instance.primary_mac_address_id != mac.pk:
+                    instance.snapshot()
+                    instance.primary_mac_address = mac
+                    instance.save()
+
+        instance.__dict__.pop('mac_address', None)
+        return instance

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

@@ -1,9 +1,13 @@
 from django import forms
 from django import forms
+from django.db import transaction
 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 *
+from dcim.models import MACAddress
 from dcim.utils import get_module_bay_positions, resolve_module_placeholder
 from dcim.utils import get_module_bay_positions, resolve_module_placeholder
+from netbox.context import current_request
 from utilities.forms import get_field_value
 from utilities.forms import get_field_value
 
 
 __all__ = (
 __all__ = (
@@ -19,6 +23,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 +46,27 @@ 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).')
+                })
+            # Require add_macaddress permission when a MAC value is provided (it may need to be created).
+            request = current_request.get()
+            if request is not None and not request.user.has_perm('dcim.add_macaddress'):
+                raise forms.ValidationError({
+                    'mac_address': _('You do not have permission to create MAC addresses.')
+                })
         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 +93,35 @@ 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 not commit or 'mac_address' not in self.changed_data:
+            return instance
+
+        mac_address = self.cleaned_data.get('mac_address')
+
+        with transaction.atomic():
+            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.snapshot()
+                    instance.primary_mac_address = mac
+                    instance.save()
+            else:
+                if instance.primary_mac_address_id is not None:
+                    instance.snapshot()
+                    instance.primary_mac_address = None
+                    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

@@ -1886,13 +1886,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,
@@ -1908,7 +1901,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')),
@@ -1929,7 +1922,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 = {

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

@@ -1,4 +1,8 @@
 import django_tables2 as tables
 import django_tables2 as tables
+from django.middleware.csrf import get_token
+from django.urls import reverse
+from django.utils.html import format_html
+from django.utils.safestring import mark_safe
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 from django_tables2.utils import Accessor
 from django_tables2.utils import Accessor
 
 
@@ -1218,6 +1222,42 @@ 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):
+        # Always exclude set_primary from the parent's action loop (which renders GET links).
+        # We inject a CSRF-protected POST form for set_primary in the dropdown below.
+        show_set_primary = not record.is_primary and record.assigned_object_id
+        original_actions = self.actions
+        self.actions = {k: v for k, v in original_actions.items() if k != 'set_primary'}
+        try:
+            html = super().render(record, table, **kwargs)
+        finally:
+            self.actions = original_actions
+
+        if show_set_primary and html:
+            request = getattr(table, 'context', {}).get('request')
+            if request:
+                url = reverse('dcim:macaddress_set_primary', kwargs={'pk': record.pk})
+                form_li = format_html(
+                    '<li><form method="post" action="{}">'
+                    '<input type="hidden" name="csrfmiddlewaretoken" value="{}">'
+                    '<button type="submit" class="dropdown-item">'
+                    '<i class="mdi mdi-star-outline"></i> {}'
+                    '</button></form></li>',
+                    url, get_token(request), _('Set as primary'),
+                )
+                html_str = str(html)
+                if '</ul>' in html_str:
+                    html = mark_safe(html_str.replace('</ul>', str(form_li) + '</ul>', 1))
+
+        return html
+
+
 class MACAddressTable(PrimaryModelTable):
 class MACAddressTable(PrimaryModelTable):
     mac_address = tables.TemplateColumn(
     mac_address = tables.TemplateColumn(
         template_code=MACADDRESS_LINK,
         template_code=MACADDRESS_LINK,
@@ -1241,7 +1281,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
     )
     )
 
 

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

@@ -2881,6 +2881,93 @@ 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)
+
+    def test_mac_address_find_or_create(self):
+        """
+        Patching mac_address with a MAC that already exists on the interface promotes it to primary
+        without creating a duplicate MACAddress record.
+        """
+        self.add_permissions('dcim.change_interface', 'dcim.add_macaddress', 'dcim.change_macaddress')
+        iface = Interface.objects.first()
+
+        # Pre-create two MACs assigned to this interface
+        mac1 = MACAddress.objects.create(mac_address='CC:DD:EE:FF:00:01', assigned_object=iface)
+        mac2 = MACAddress.objects.create(mac_address='CC:DD:EE:FF:00:02', assigned_object=iface)
+        iface.primary_mac_address = mac1
+        iface.save()
+
+        mac_count_before = iface.mac_addresses.count()
+        url = self._get_detail_url(iface)
+
+        # PATCH with mac2's address — should promote mac2, not create a new record
+        response = self.client.patch(url, {'mac_address': 'CC:DD:EE:FF:00:02'}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+
+        iface.refresh_from_db()
+        self.assertEqual(iface.primary_mac_address.pk, mac2.pk)
+        self.assertEqual(iface.mac_addresses.count(), mac_count_before)
+
 
 
 class FrontPortTestCase(APIViewTestCases.APIViewTestCase):
 class FrontPortTestCase(APIViewTestCases.APIViewTestCase):
     model = FrontPort
     model = FrontPort

+ 111 - 1
netbox/dcim/tests/test_views.py

@@ -3447,6 +3447,59 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
         self.assertHttpStatus(response, 302)
         self.assertHttpStatus(response, 302)
         self.assertEqual(Interface.objects.filter(device=device, name__startswith='xe').count(), 37)
         self.assertEqual(Interface.objects.filter(device=device, name__startswith='xe').count(), 37)
 
 
+    def test_mac_address_shortcut_create(self):
+        """
+        Submitting the Interface form with a mac_address string creates a MACAddress
+        and sets it as primary in one request.
+        """
+        self.add_permissions('dcim.add_interface', 'dcim.add_macaddress')
+
+        data = {**self.form_data, 'mac_address': 'AA:BB:CC:DD:EE:FF', 'changelog_message': 'test'}
+        response = self.client.post(self._get_url('add'), data=post_data(data))
+        self.assertHttpStatus(response, 302)
+
+        interface = Interface.objects.get(device=data['device'], name=data['name'])
+        self.assertIsNotNone(interface.primary_mac_address)
+        self.assertEqual(str(interface.primary_mac_address.mac_address), 'AA:BB:CC:DD:EE:FF')
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
+    def test_mac_address_shortcut_edit(self):
+        """
+        Submitting the Interface edit form with a mac_address string creates a MACAddress
+        and assigns it as primary when none existed before.
+        """
+        self.add_permissions('dcim.change_interface', 'dcim.add_macaddress')
+
+        instance = Interface.objects.filter(device_id=self.form_data['device']).first()
+        self.assertIsNone(instance.primary_mac_address)
+
+        data = {**self.form_data, 'mac_address': '11:22:33:44:55:66', 'changelog_message': 'test'}
+        response = self.client.post(self._get_url('edit', instance), data=post_data(data))
+        self.assertHttpStatus(response, 302)
+
+        instance.refresh_from_db()
+        self.assertIsNotNone(instance.primary_mac_address)
+        self.assertEqual(str(instance.primary_mac_address.mac_address), '11:22:33:44:55:66')
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
+    def test_mac_address_shortcut_clear(self):
+        """
+        Submitting the Interface edit form with an empty mac_address clears the primary MAC.
+        """
+        self.add_permissions('dcim.change_interface')
+
+        instance = Interface.objects.filter(device_id=self.form_data['device']).first()
+        mac = MACAddress.objects.create(mac_address='AA:BB:CC:DD:EE:FF', assigned_object=instance)
+        instance.primary_mac_address = mac
+        instance.save()
+
+        data = {**self.form_data, 'mac_address': '', 'changelog_message': 'test'}
+        response = self.client.post(self._get_url('edit', instance), data=post_data(data))
+        self.assertHttpStatus(response, 302)
+
+        instance.refresh_from_db()
+        self.assertIsNone(instance.primary_mac_address)
+
 
 
 class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = FrontPort
     model = FrontPort
@@ -4403,10 +4456,67 @@ class MACAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'description': 'New description',
             'description': 'New description',
         }
         }
 
 
+    def test_set_primary(self):
+        """
+        Test that MACAddressSetPrimaryView promotes a non-primary MAC to primary and
+        redirects to the assigned interface's detail page.
+        """
+        self.add_permissions('dcim.view_macaddress', 'dcim.change_interface')
+
+        # Use the first MAC fixture which is assigned to an interface but not yet primary
+        mac = MACAddress.objects.first()
+        interface = mac.assigned_object
+        self.assertIsNotNone(interface)
+        self.assertIsNone(interface.primary_mac_address)
+
+        url = reverse('dcim:macaddress_set_primary', kwargs={'pk': mac.pk})
+        response = self.client.post(url)
+
+        self.assertHttpStatus(response, 302)
+        self.assertEqual(response['Location'], interface.get_absolute_url())
+        interface.refresh_from_db()
+        self.assertEqual(interface.primary_mac_address_id, mac.pk)
+
+    def test_set_primary_already_primary(self):
+        """
+        Clicking Set as primary on the current primary MAC is a no-op and still
+        redirects to the interface.
+        """
+        self.add_permissions('dcim.view_macaddress', 'dcim.change_interface')
+
+        mac = MACAddress.objects.first()
+        interface = mac.assigned_object
+        interface.primary_mac_address = mac
+        interface.save()
+
+        url = reverse('dcim:macaddress_set_primary', kwargs={'pk': mac.pk})
+        response = self.client.post(url)
+
+        self.assertHttpStatus(response, 302)
+        self.assertEqual(response['Location'], interface.get_absolute_url())
+        interface.refresh_from_db()
+        self.assertEqual(interface.primary_mac_address_id, mac.pk)
+
+    def test_set_primary_requires_interface_change_permission(self):
+        """
+        Attempting to set a primary MAC without change_interface permission
+        redirects to the MAC's detail page with an error.
+        """
+        self.add_permissions('dcim.view_macaddress')
+
+        mac = MACAddress.objects.first()
+        url = reverse('dcim:macaddress_set_primary', kwargs={'pk': mac.pk})
+        response = self.client.post(url)
+
+        self.assertHttpStatus(response, 302)
+        self.assertEqual(response['Location'], mac.get_absolute_url())
+        mac.assigned_object.refresh_from_db()
+        self.assertIsNone(mac.assigned_object.primary_mac_address)
+
     @tag('regression')  # Issue #20542
     @tag('regression')  # Issue #20542
     def test_create_macaddress_via_quickadd(self):
     def test_create_macaddress_via_quickadd(self):
         """
         """
-        Test creating a MAC address via quick-add modal (e.g., from Interface form).
+        Test creating a MAC address via the quick-add modal mechanism.
         Regression test for issue #20542 where form prefix was missing in POST handler.
         Regression test for issue #20542 where form prefix was missing in POST handler.
         """
         """
         self.add_permissions('dcim.view_macaddress', 'dcim.view_interface', 'extras.view_tag')
         self.add_permissions('dcim.view_macaddress', 'dcim.view_interface', 'extras.view_tag')

+ 43 - 0
netbox/dcim/views.py

@@ -1,5 +1,6 @@
 from django.conf import settings
 from django.conf import settings
 from django.contrib import messages
 from django.contrib import messages
+from django.contrib.auth.views import redirect_to_login
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.paginator import EmptyPage, PageNotAnInteger
 from django.core.paginator import EmptyPage, PageNotAnInteger
 from django.db import router, transaction
 from django.db import router, transaction
@@ -3482,6 +3483,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',
@@ -5123,6 +5129,43 @@ 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 post(self, request, pk):
+        if not request.user.is_authenticated:
+            return redirect_to_login(request.get_full_path())
+
+        mac = get_object_or_404(self.queryset.restrict(request.user, 'view'), 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()

+ 27 - 4
netbox/virtualization/api/serializers_/virtualmachines.py

@@ -1,7 +1,10 @@
+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
+from dcim.api.serializers_.mixins import _UNSET, MACAddressShortcutMixin
 from dcim.api.serializers_.platforms import PlatformSerializer
 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
@@ -101,7 +104,7 @@ class VirtualMachineSerializer(PrimaryModelSerializer):
 # VM interfaces
 # VM interfaces
 #
 #
 
 
-class VMInterfaceSerializer(OwnerMixin, NetBoxModelSerializer):
+class VMInterfaceSerializer(MACAddressShortcutMixin, OwnerMixin, NetBoxModelSerializer):
     virtual_machine = VirtualMachineSerializer(nested=True)
     virtual_machine = VirtualMachineSerializer(nested=True)
     parent = NestedVMInterfaceSerializer(required=False, allow_null=True)
     parent = NestedVMInterfaceSerializer(required=False, allow_null=True)
     bridge = NestedVMInterfaceSerializer(required=False, allow_null=True)
     bridge = NestedVMInterfaceSerializer(required=False, allow_null=True)
@@ -120,8 +123,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 +140,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 +181,12 @@ 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
 
 
 
 
 #
 #

+ 2 - 2
netbox/virtualization/forms/model_forms.py

@@ -484,7 +484,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(
@@ -498,7 +498,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 = {

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

@@ -759,6 +759,90 @@ 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)
+
+    def test_mac_address_find_or_create(self):
+        """
+        Patching mac_address with a MAC that already exists on the VMInterface promotes it to
+        primary without creating a duplicate MACAddress record.
+        """
+        from dcim.models import MACAddress
+        self.add_permissions('virtualization.change_vminterface', 'dcim.add_macaddress', 'dcim.change_macaddress')
+        iface = VMInterface.objects.first()
+
+        mac1 = MACAddress.objects.create(mac_address='CC:DD:EE:FF:00:01', assigned_object=iface)
+        mac2 = MACAddress.objects.create(mac_address='CC:DD:EE:FF:00:02', assigned_object=iface)
+        iface.primary_mac_address = mac1
+        iface.save()
+
+        mac_count_before = iface.mac_addresses.count()
+        url = self._get_detail_url(iface)
+
+        response = self.client.patch(url, {'mac_address': 'CC:DD:EE:FF:00:02'}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+
+        iface.refresh_from_db()
+        self.assertEqual(iface.primary_mac_address.pk, mac2.pk)
+        self.assertEqual(iface.mac_addresses.count(), mac_count_before)
+
 
 
 class VirtualDiskTestCase(APIViewTestCases.APIViewTestCase):
 class VirtualDiskTestCase(APIViewTestCases.APIViewTestCase):
     model = VirtualDisk
     model = VirtualDisk

+ 56 - 2
netbox/virtualization/tests/test_views.py

@@ -1,13 +1,14 @@
 from decimal import Decimal
 from decimal import Decimal
 
 
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
+from django.test import override_settings
 from django.urls import reverse
 from django.urls import reverse
 
 
 from dcim.choices import InterfaceModeChoices
 from dcim.choices import InterfaceModeChoices
-from dcim.models import DeviceRole, Platform, Site
+from dcim.models import DeviceRole, MACAddress, Platform, Site
 from extras.models import ConfigTemplate
 from extras.models import ConfigTemplate
 from ipam.models import VLAN, VRF
 from ipam.models import VLAN, VRF
-from utilities.testing import ViewTestCases, create_tags, create_test_device, create_test_virtualmachine
+from utilities.testing import ViewTestCases, create_tags, create_test_device, create_test_virtualmachine, post_data
 from virtualization.choices import *
 from virtualization.choices import *
 from virtualization.models import *
 from virtualization.models import *
 
 
@@ -687,6 +688,59 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
         self.client.post(self._get_url('bulk_delete'), data)
         self.client.post(self._get_url('bulk_delete'), data)
         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_shortcut_create(self):
+        """
+        Submitting the VMInterface form with a mac_address string creates a MACAddress
+        and sets it as primary in one request.
+        """
+        self.add_permissions('virtualization.add_vminterface', 'dcim.add_macaddress')
+
+        data = {**self.form_data, 'mac_address': 'AA:BB:CC:DD:EE:FF', 'changelog_message': 'test'}
+        response = self.client.post(self._get_url('add'), data=post_data(data))
+        self.assertHttpStatus(response, 302)
+
+        interface = VMInterface.objects.get(virtual_machine=data['virtual_machine'], name=data['name'])
+        self.assertIsNotNone(interface.primary_mac_address)
+        self.assertEqual(str(interface.primary_mac_address.mac_address), 'AA:BB:CC:DD:EE:FF')
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
+    def test_mac_address_shortcut_edit(self):
+        """
+        Submitting the VMInterface edit form with a mac_address string creates a MACAddress
+        and assigns it as primary when none existed before.
+        """
+        self.add_permissions('virtualization.change_vminterface', 'dcim.add_macaddress')
+
+        instance = VMInterface.objects.filter(virtual_machine_id=self.form_data['virtual_machine']).first()
+        self.assertIsNone(instance.primary_mac_address)
+
+        data = {**self.form_data, 'mac_address': '11:22:33:44:55:66', 'changelog_message': 'test'}
+        response = self.client.post(self._get_url('edit', instance), data=post_data(data))
+        self.assertHttpStatus(response, 302)
+
+        instance.refresh_from_db()
+        self.assertIsNotNone(instance.primary_mac_address)
+        self.assertEqual(str(instance.primary_mac_address.mac_address), '11:22:33:44:55:66')
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
+    def test_mac_address_shortcut_clear(self):
+        """
+        Submitting the VMInterface edit form with an empty mac_address clears the primary MAC.
+        """
+        self.add_permissions('virtualization.change_vminterface')
+
+        instance = VMInterface.objects.filter(virtual_machine_id=self.form_data['virtual_machine']).first()
+        mac = MACAddress.objects.create(mac_address='AA:BB:CC:DD:EE:FF', assigned_object=instance)
+        instance.primary_mac_address = mac
+        instance.save()
+
+        data = {**self.form_data, 'mac_address': '', 'changelog_message': 'test'}
+        response = self.client.post(self._get_url('edit', instance), data=post_data(data))
+        self.assertHttpStatus(response, 302)
+
+        instance.refresh_from_db()
+        self.assertIsNone(instance.primary_mac_address)
+
 
 
 class VirtualDiskTestCase(ViewTestCases.DeviceComponentViewTestCase):
 class VirtualDiskTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = VirtualDisk
     model = VirtualDisk