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

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.utils.translation import gettext as _
+from netaddr import EUI, AddrFormatError
 from rest_framework import serializers
 
 from dcim.choices import *
@@ -11,6 +12,7 @@ from dcim.models import (
     FrontPort,
     Interface,
     InventoryItem,
+    MACAddress,
     ModuleBay,
     PortMapping,
     PowerOutlet,
@@ -38,6 +40,8 @@ from .manufacturers import ManufacturerSerializer
 from .nested import NestedInterfaceSerializer
 from .roles import InventoryItemRoleSerializer
 
+_UNSET = object()
+
 __all__ = (
     'ConsolePortSerializer',
     'ConsoleServerPortSerializer',
@@ -249,8 +253,9 @@ class InterfaceSerializer(
     )
     count_ipaddresses = 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)
     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)
@@ -270,8 +275,18 @@ class InterfaceSerializer(
         brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
 
     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 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)
             mode = None
@@ -329,7 +344,43 @@ class InterfaceSerializer(
                                         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):

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

@@ -1,5 +1,6 @@
 from django import forms
 from django.utils.translation import gettext_lazy as _
+from netaddr import EUI, AddrFormatError
 
 from dcim.choices import *
 from dcim.constants import *
@@ -19,6 +20,12 @@ class InterfaceCommonForm(forms.Form):
         max_value=INTERFACE_MTU_MAX,
         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):
         super().__init__(*args, **kwargs)
@@ -36,12 +43,21 @@ 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)
+        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):
         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'
         if 'tagged_vlans' in self.fields.keys():
             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:
                 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):
 

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

@@ -1618,13 +1618,6 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
         required=False,
         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(
         empty_value=None,
         required=False,
@@ -1640,7 +1633,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
         FieldSet(
             '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('parent', 'bridge', 'lag', name=_('Related Interfaces')),
         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',
             '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', 'primary_mac_address',
+            'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf',
             'owner', 'tags',
         ]
         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):
     mac_address = tables.TemplateColumn(
         template_code=MACADDRESS_LINK,
@@ -1241,7 +1259,8 @@ class MACAddressTable(PrimaryModelTable):
     tags = columns.TagColumn(
         url_name='dcim:macaddress_list'
     )
-    actions = columns.ActionsColumn(
+    actions = MACAddressActionsColumn(
+        actions=('edit', 'delete', 'changelog', 'set_primary'),
         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
         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):
     model = FrontPort

+ 49 - 0
netbox/dcim/views.py

@@ -3415,6 +3415,11 @@ class InterfaceView(generic.ObjectView):
                 filters={'interface_id': lambda ctx: ctx['object'].pk},
                 title=_('MAC Addresses'),
                 exclude_columns=['assigned_object', 'assigned_object_parent'],
+                actions=[
+                    actions.AddObject(
+                        'dcim.MACAddress', url_params={'interface': lambda ctx: ctx['object'].pk}
+                    ),
+                ],
             ),
             ObjectsTablePanel(
                 model='ipam.VLAN',
@@ -5025,6 +5030,50 @@ class MACAddressDeleteView(generic.ObjectDeleteView):
     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)
 class MACAddressBulkImportView(generic.BulkImportView):
     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 netaddr import EUI, AddrFormatError
 from rest_framework import serializers
 
 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_.sites import SiteSerializer
 from dcim.choices import InterfaceModeChoices
+from dcim.models import MACAddress
 from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
 from ipam.api.serializers_.ip import IPAddressSerializer
 from ipam.api.serializers_.vlans import VLANSerializer, VLANTranslationPolicySerializer
@@ -22,6 +25,8 @@ from ...models import VirtualDisk, VirtualMachine, VirtualMachineType, VMInterfa
 from .clusters import ClusterSerializer
 from .nested import NestedVMInterfaceSerializer
 
+_UNSET = object()
+
 __all__ = (
     'VMInterfaceSerializer',
     'VirtualDiskSerializer',
@@ -120,8 +125,9 @@ class VMInterfaceSerializer(OwnerMixin, NetBoxModelSerializer):
     l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True)
     count_ipaddresses = serializers.IntegerField(read_only=True)
     count_fhrp_groups = serializers.IntegerField(read_only=True)
-    # 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)
     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')
 
     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
         virtual_machine = None
         tagged_vlans = []
@@ -163,7 +183,43 @@ class VMInterfaceSerializer(OwnerMixin, NetBoxModelSerializer):
                                         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.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 ipam.choices import VLANQinQRoleChoices
 from ipam.models import VLAN, VRF, IPAddress, VLANGroup, VLANTranslationPolicy
@@ -381,13 +381,6 @@ class VMComponentForm(OwnerMixin, NetBoxModelForm):
 
 
 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(
         queryset=VMInterface.objects.all(),
         required=False,
@@ -450,7 +443,7 @@ class VMInterfaceForm(InterfaceCommonForm, VMComponentForm):
 
     fieldsets = (
         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('parent', 'bridge', name=_('Related Interfaces')),
         FieldSet(
@@ -464,7 +457,7 @@ 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', 'primary_mac_address',
+            'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf',
             'owner', 'tags',
         ]
         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.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):
     model = VirtualDisk