This commit is contained in:
parent
2f604551c1
commit
800db5727f
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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):
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
#
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue