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

This commit is contained in:
bctiemann 2026-07-08 12:31:16 -04:00 committed by GitHub
parent 2f604551c1
commit 800db5727f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 606 additions and 26 deletions

View File

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

View File

@ -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

View File

@ -1,9 +1,13 @@
from django import forms
from django.db import transaction
from django.utils.translation import gettext_lazy as _
from netaddr import EUI, AddrFormatError
from dcim.choices import *
from dcim.constants import *
from dcim.models import MACAddress
from dcim.utils import get_module_bay_positions, resolve_module_placeholder
from netbox.context import current_request
from utilities.forms import get_field_value
__all__ = (
@ -19,6 +23,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 +46,27 @@ 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).')
})
# 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'
if 'tagged_vlans' in self.fields.keys():
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:
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):

View File

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

View File

@ -1,4 +1,8 @@
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_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):
mac_address = tables.TemplateColumn(
template_code=MACADDRESS_LINK,
@ -1241,7 +1281,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
)

View File

@ -2881,6 +2881,93 @@ 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)
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):
model = FrontPort

View File

@ -3447,6 +3447,59 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
self.assertHttpStatus(response, 302)
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):
model = FrontPort
@ -4403,10 +4456,67 @@ class MACAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'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
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.
"""
self.add_permissions('dcim.view_macaddress', 'dcim.view_interface', 'extras.view_tag')

View File

@ -1,5 +1,6 @@
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.views import redirect_to_login
from django.contrib.contenttypes.models import ContentType
from django.core.paginator import EmptyPage, PageNotAnInteger
from django.db import router, transaction
@ -3482,6 +3483,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',
@ -5123,6 +5129,43 @@ class MACAddressDeleteView(generic.ObjectDeleteView):
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)
class MACAddressBulkImportView(generic.BulkImportView):
queryset = MACAddress.objects.all()

View File

@ -1,7 +1,10 @@
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
from dcim.api.serializers_.mixins import _UNSET, MACAddressShortcutMixin
from dcim.api.serializers_.platforms import PlatformSerializer
from dcim.api.serializers_.roles import DeviceRoleSerializer
from dcim.api.serializers_.sites import SiteSerializer
@ -101,7 +104,7 @@ class VirtualMachineSerializer(PrimaryModelSerializer):
# VM interfaces
#
class VMInterfaceSerializer(OwnerMixin, NetBoxModelSerializer):
class VMInterfaceSerializer(MACAddressShortcutMixin, OwnerMixin, NetBoxModelSerializer):
virtual_machine = VirtualMachineSerializer(nested=True)
parent = 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)
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 +140,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 +181,12 @@ 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
#

View File

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

View File

@ -759,6 +759,90 @@ 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)
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):
model = VirtualDisk

View File

@ -1,13 +1,14 @@
from decimal import Decimal
from django.contrib.contenttypes.models import ContentType
from django.test import override_settings
from django.urls import reverse
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 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.models import *
@ -687,6 +688,59 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
self.client.post(self._get_url('bulk_delete'), data)
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):
model = VirtualDisk