diff --git a/docs/models/dcim/macaddress.md b/docs/models/dcim/macaddress.md index 33ca1d9ff..311571904 100644 --- a/docs/models/dcim/macaddress.md +++ b/docs/models/dcim/macaddress.md @@ -1,8 +1,8 @@ # MAC Addresses -A MAC address object in NetBox comprises a single physical (hardware) address, and represents a MAC address as reported by or assigned to a network interface. MAC addresses can be assigned to [device](../dcim/device.md) and [virtual machine](../virtualization/virtualmachine.md) interfaces. A MAC address can be specified as the "primary" MAC address for a given interface or VM interface. +A MAC address object in NetBox comprises a single Ethernet link layer address, and represents a MAC address as reported by or assigned to a network interface. MAC addresses can be assigned to [device](../dcim/device.md) and [virtual machine](../virtualization/virtualmachine.md) interfaces. A MAC address can be specified as the "primary" MAC address for a given interface or VM interface. -Most interfaces only have a single MAC address, hard-coded at the factory. However, on some devices (particularly virtual interfaces) it is possible to assign additional MAC addresses or change existing ones. For this reason NetBox allows multiple MACAddress objects to be assigned to a single interface. However, for convenience and backward compatibiility reasons, the value of the `mac_address` field of the primary (or single) MAC address on an interface is reflected as a simple property in the interface detail page. +Most interfaces have only a single MAC address, hard-coded at the factory. However, on some devices (particularly virtual interfaces) it is possible to assign additional MAC addresses or change existing ones. For this reason NetBox allows multiple MACAddress objects to be assigned to a single interface. However, for convenience and backward compatibiility reasons, the value of the `mac_address` field of the primary (or single) MAC address on an interface is reflected as a simple property in the interface detail page. ## Fields diff --git a/netbox/dcim/api/serializers_/device_components.py b/netbox/dcim/api/serializers_/device_components.py index 9e3aa4d37..6bace5bd7 100644 --- a/netbox/dcim/api/serializers_/device_components.py +++ b/netbox/dcim/api/serializers_/device_components.py @@ -10,7 +10,6 @@ from dcim.models import ( ) from ipam.api.serializers_.vlans import VLANSerializer, VLANTranslationPolicySerializer from ipam.api.serializers_.vrfs import VRFSerializer -from ipam.api.serializers_.ip import IPAddressSerializer from ipam.models import VLAN from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer @@ -216,7 +215,6 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect read_only=True ) mac_addresses = MACAddressSerializer(many=True, nested=True, read_only=True, allow_null=True) - ip_addresses = IPAddressSerializer(many=True, nested=True, read_only=True, allow_null=True) wwn = serializers.CharField(required=False, default=None, allow_blank=True, allow_null=True) class Meta: @@ -229,7 +227,7 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect 'cable', 'cable_end', 'wireless_link', 'link_peers', 'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied', - 'mac_addresses', 'ip_addresses', + 'mac_addresses', ] brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied') diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 30e6d20cd..e4116e63d 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -20,7 +20,7 @@ from utilities.filters import ( ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter, ) -from virtualization.models import Cluster, ClusterGroup, VMInterface +from virtualization.models import Cluster, ClusterGroup, VMInterface, VirtualMachine from vpn.models import L2VPN from wireless.choices import WirelessRoleChoices, WirelessChannelChoices from wireless.models import WirelessLAN, WirelessLink @@ -1659,7 +1659,7 @@ class MACAddressFilterSet(NetBoxModelFilterSet): return queryset.filter(qs_filter) def filter_device(self, queryset, name, value): - devices = Device.objects.filter(**{'{}__in'.format(name): value}) + devices = Device.objects.filter(**{f'{name}__in': value}) if not devices.exists(): return queryset.none() interface_ids = [] @@ -1670,7 +1670,7 @@ class MACAddressFilterSet(NetBoxModelFilterSet): ) def filter_virtual_machine(self, queryset, name, value): - virtual_machines = VirtualMachine.objects.filter(**{'{}__in'.format(name): value}) + virtual_machines = VirtualMachine.objects.filter(**{f'{name}__in': value}) if not virtual_machines.exists(): return queryset.none() interface_ids = [] diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 33713e9b2..4a185dd3b 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -1233,10 +1233,9 @@ class MACAddressImportForm(NetBoxModelImportForm): is_primary = self.cleaned_data.get('is_primary') # Validate is_primary - # TODO: scope to interface rather than device/VM - if is_primary and not device and not virtual_machine: + if interface and not device and not virtual_machine: raise forms.ValidationError({ - "is_primary": _("No device or virtual machine specified; cannot set as primary") + "interface": _("Must specify the parent device or VM when assigning an interface") }) if is_primary and not interface: raise forms.ValidationError({ diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 6ce747d1f..73d7ec986 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -1583,7 +1583,7 @@ class MACAddressFilterForm(NetBoxModelFilterSetForm): model = MACAddress fieldsets = ( FieldSet('q', 'filter_id', 'tag'), - FieldSet('mac_address', name=_('Addressing')), + FieldSet('mac_address', 'is_primary', name=_('Addressing')), FieldSet('device_id', 'virtual_machine_id', name=_('Device/VM')), ) selector_fields = ('filter_id', 'q', 'device_id', 'virtual_machine_id') @@ -1591,6 +1591,13 @@ class MACAddressFilterForm(NetBoxModelFilterSetForm): required=False, label=_('MAC address') ) + is_primary = forms.NullBooleanField( + required=False, + label=_('Is primary'), + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 3642100a8..5d044630e 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -369,12 +369,19 @@ class FrontPortTemplateType(ModularComponentTemplateType): @strawberry_django.type( models.MACAddress, - fields='__all__', + exclude=('assigned_object_type', 'assigned_object_id'), filters=MACAddressFilter ) class MACAddressType(NetBoxObjectType): mac_address: str + @strawberry_django.field + def assigned_object(self) -> Annotated[Union[ + Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')], + Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')], + ], strawberry.union("MACAddressAssignmentType")] | None: + return self.assigned_object + @strawberry_django.type( models.Interface, diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index d9dfc3fbd..a49b192b0 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -606,7 +606,7 @@ class BaseInterface(models.Model): @cached_property def mac_address(self): - if macaddress := self.mac_addresses.order_by('-is_primary').first(): + if macaddress := self.mac_addresses.order_by('-is_primary', 'mac_address').first(): return macaddress.mac_address return None diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index dd8771e24..2a2186d27 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -1516,7 +1516,12 @@ class MACAddress(PrimaryModel): super().clean() if self.is_primary and self.assigned_object: - if self.assigned_object.mac_addresses.filter(is_primary=True).exclude(pk=self.pk).exists(): + peer_macs = MACAddress.objects.exclude(pk=self.pk).filter( + assigned_object_type=self.assigned_object_type, + assigned_object_id=self.assigned_object_id, + is_primary=True + ) + if peer_macs.exists(): raise ValidationError({ - 'is_primary': _("There is already a primary MAC address for this interface.") + 'is_primary': _("A primary MAC address is already designated for this interface.") }) diff --git a/netbox/virtualization/api/serializers_/virtualmachines.py b/netbox/virtualization/api/serializers_/virtualmachines.py index 488eb67b5..48ddfaf4e 100644 --- a/netbox/virtualization/api/serializers_/virtualmachines.py +++ b/netbox/virtualization/api/serializers_/virtualmachines.py @@ -101,7 +101,6 @@ class VMInterfaceSerializer(NetBoxModelSerializer): read_only=True ) mac_addresses = MACAddressSerializer(many=True, nested=True, read_only=True, allow_null=True) - ip_addresses = IPAddressSerializer(many=True, nested=True, read_only=True, allow_null=True) class Meta: model = VMInterface @@ -109,7 +108,7 @@ class VMInterfaceSerializer(NetBoxModelSerializer): 'id', 'url', 'display_url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'bridge', 'mtu', 'mac_address', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf', 'l2vpn_termination', 'tags', 'custom_fields', 'created', 'last_updated', - 'count_ipaddresses', 'count_fhrp_groups', 'mac_addresses', 'ip_addresses', + 'count_ipaddresses', 'count_fhrp_groups', 'mac_addresses', ] brief_fields = ('id', 'url', 'display', 'virtual_machine', 'name', 'description')