diff --git a/netbox/dcim/api/serializers_/device_components.py b/netbox/dcim/api/serializers_/device_components.py index 48957bdae..f1b592e98 100644 --- a/netbox/dcim/api/serializers_/device_components.py +++ b/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 * @@ -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
' + '' + '
', + url, get_token(request), _('Set as primary'), + ) + html_str = str(html) + if '' in html_str: + html = mark_safe(html_str.replace('', str(form_li) + '', 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 ) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 0fc6fae3e..ec0ef1a50 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -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 diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index dc32c7bd9..c4f0e3afb 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -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') diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 114e3e306..d9d48fd58 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -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() diff --git a/netbox/virtualization/api/serializers_/virtualmachines.py b/netbox/virtualization/api/serializers_/virtualmachines.py index 629d1639f..94bce2a81 100644 --- a/netbox/virtualization/api/serializers_/virtualmachines.py +++ b/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 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