diff --git a/netbox/dcim/api/serializers_/devices.py b/netbox/dcim/api/serializers_/devices.py
index deb53706b..eb6c4f9bc 100644
--- a/netbox/dcim/api/serializers_/devices.py
+++ b/netbox/dcim/api/serializers_/devices.py
@@ -169,8 +169,11 @@ class MACAddressSerializer(NetBoxModelSerializer):
class Meta:
model = MACAddress
- fields = ['mac_address', 'is_primary', 'assigned_object_type', 'assigned_object']
- brief_fields = ('mac_address',)
+ fields = [
+ 'id', 'url', 'display_url', 'display', 'mac_address', 'assigned_object_type', 'assigned_object',
+ 'description', 'comments',
+ ]
+ brief_fields = ('id', 'url', 'display', 'mac_address', 'description')
@extend_schema_field(serializers.JSONField(allow_null=True))
def get_assigned_object(self, obj):
diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py
index e4116e63d..73631e50b 100644
--- a/netbox/dcim/filtersets.py
+++ b/netbox/dcim/filtersets.py
@@ -1647,7 +1647,7 @@ class MACAddressFilterSet(NetBoxModelFilterSet):
class Meta:
model = MACAddress
- fields = ('id', 'description', 'is_primary', 'assigned_object_type', 'assigned_object_id')
+ fields = ('id', 'description', 'assigned_object_type', 'assigned_object_id')
def search(self, queryset, name, value):
if not value.strip():
@@ -1789,6 +1789,17 @@ class InterfaceFilterSet(
field_name='mac_addresses__mac_address',
label=_('MAC Address')
)
+ primary_mac_address_id = django_filters.ModelMultipleChoiceFilter(
+ field_name='primary_mac_address',
+ queryset=MACAddress.objects.all(),
+ label=_('Primary MAC address (ID)'),
+ )
+ primary_mac_address = django_filters.ModelMultipleChoiceFilter(
+ field_name='primary_mac_address__mac_address',
+ queryset=MACAddress.objects.all(),
+ to_field_name='mac_address',
+ label=_('Primary MAC address'),
+ )
wwn = MultiValueWWNFilter()
poe_mode = django_filters.MultipleChoiceFilter(
choices=InterfacePoEModeChoices
diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py
index 8577d93b3..955f2cea0 100644
--- a/netbox/dcim/forms/bulk_edit.py
+++ b/netbox/dcim/forms/bulk_edit.py
@@ -1732,17 +1732,10 @@ class MACAddressBulkEditForm(NetBoxModelBulkEditForm):
max_length=200,
required=False
)
- is_primary = forms.NullBooleanField(
- label=_('Is primary'),
- required=False,
- widget=BulkEditNullBooleanSelect(),
- )
comments = CommentField()
model = MACAddress
fieldsets = (
- FieldSet('description', 'is_primary'),
- )
- nullable_fields = (
- 'description', 'comments',
+ FieldSet('description'),
)
+ nullable_fields = ('description', 'comments')
diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py
index 4a185dd3b..b8a7a007c 100644
--- a/netbox/dcim/forms/bulk_import.py
+++ b/netbox/dcim/forms/bulk_import.py
@@ -1203,8 +1203,7 @@ class MACAddressImportForm(NetBoxModelImportForm):
class Meta:
model = MACAddress
fields = [
- 'mac_address', 'device', 'virtual_machine', 'interface', 'is_primary',
- 'description', 'comments', 'tags',
+ 'mac_address', 'device', 'virtual_machine', 'interface', 'is_primary', 'description', 'comments', 'tags',
]
def __init__(self, data=None, *args, **kwargs):
@@ -1230,25 +1229,27 @@ class MACAddressImportForm(NetBoxModelImportForm):
device = self.cleaned_data.get('device')
virtual_machine = self.cleaned_data.get('virtual_machine')
interface = self.cleaned_data.get('interface')
- is_primary = self.cleaned_data.get('is_primary')
- # Validate is_primary
+ # Validate interface assignment
if interface and not device and not virtual_machine:
raise forms.ValidationError({
"interface": _("Must specify the parent device or VM when assigning an interface")
})
- if is_primary and not interface:
- raise forms.ValidationError({
- "is_primary": _("No interface specified; cannot set as primary")
- })
def save(self, *args, **kwargs):
# Set interface assignment
- if self.cleaned_data.get('interface'):
- self.instance.assigned_object = self.cleaned_data['interface']
+ if interface := self.cleaned_data.get('interface'):
+ self.instance.assigned_object = interface
- return super().save(*args, **kwargs)
+ instance = super().save(*args, **kwargs)
+
+ # Assign the MAC address as primary for its interface, if designated as such
+ if interface and self.cleaned_data['is_primary'] and self.instance.pk:
+ interface.primary_mac_address = self.instance
+ interface.save()
+
+ return instance
#
diff --git a/netbox/dcim/forms/common.py b/netbox/dcim/forms/common.py
index 100898f90..d30ac0784 100644
--- a/netbox/dcim/forms/common.py
+++ b/netbox/dcim/forms/common.py
@@ -3,7 +3,9 @@ from django.utils.translation import gettext_lazy as _
from dcim.choices import *
from dcim.constants import *
+from dcim.models import MACAddress
from utilities.forms import get_field_value
+from utilities.forms.fields import DynamicModelChoiceField
__all__ = (
'InterfaceCommonForm',
@@ -18,6 +20,11 @@ class InterfaceCommonForm(forms.Form):
max_value=INTERFACE_MTU_MAX,
label=_('MTU')
)
+ primary_mac_address = DynamicModelChoiceField(
+ queryset=MACAddress.objects.all(),
+ label=_('Primary MAC address'),
+ required=False
+ )
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -35,6 +42,10 @@ 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)
+
def clean(self):
super().clean()
diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py
index 73d7ec986..4a373a996 100644
--- a/netbox/dcim/forms/filtersets.py
+++ b/netbox/dcim/forms/filtersets.py
@@ -1583,21 +1583,13 @@ class MACAddressFilterForm(NetBoxModelFilterSetForm):
model = MACAddress
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
- FieldSet('mac_address', 'is_primary', name=_('Addressing')),
- FieldSet('device_id', 'virtual_machine_id', name=_('Device/VM')),
+ FieldSet('mac_address', 'device_id', 'virtual_machine_id', name=_('MAC address')),
)
selector_fields = ('filter_id', 'q', 'device_id', 'virtual_machine_id')
mac_address = forms.CharField(
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/forms/model_forms.py b/netbox/dcim/forms/model_forms.py
index d49b141a5..3e6a99e45 100644
--- a/netbox/dcim/forms/model_forms.py
+++ b/netbox/dcim/forms/model_forms.py
@@ -1,6 +1,5 @@
from django import forms
from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from timezone_field import TimeZoneFormField
@@ -1405,7 +1404,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
FieldSet(
'device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags', name=_('Interface')
),
- FieldSet('vrf', 'wwn', name=_('Addressing')),
+ FieldSet('vrf', 'primary_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')),
@@ -1425,7 +1424,8 @@ 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', 'tags',
+ 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf', 'primary_mac_address',
+ 'tags',
]
widgets = {
'speed': NumberWithOptions(
@@ -1740,10 +1740,6 @@ class MACAddressForm(NetBoxModelForm):
queryset=VMInterface.objects.all(),
required=False,
)
- is_primary = forms.BooleanField(
- required=False,
- label=_('Primary for interface'),
- )
fieldsets = (
FieldSet(
@@ -1754,14 +1750,13 @@ class MACAddressForm(NetBoxModelForm):
FieldSet('interface', name=_('Device')),
FieldSet('vminterface', name=_('Virtual Machine')),
),
- 'is_primary', name=_('Assignment')
),
)
class Meta:
model = MACAddress
fields = [
- 'mac_address', 'interface', 'vminterface', 'is_primary', 'description', 'tags',
+ 'mac_address', 'interface', 'vminterface', 'description', 'tags',
]
def __init__(self, *args, **kwargs):
@@ -1790,18 +1785,6 @@ class MACAddressForm(NetBoxModelForm):
selected_objects[1]: _("A MAC address can only be assigned to a single object.")
})
elif selected_objects:
- assigned_object = self.cleaned_data[selected_objects[0]]
- if self.instance.pk and self.instance.assigned_object and self.instance.is_primary and assigned_object != self.instance.assigned_object:
- raise ValidationError(
- _("Cannot reassign MAC address while it is designated as the primary for the interface")
- )
- self.instance.assigned_object = assigned_object
+ self.instance.assigned_object = self.cleaned_data[selected_objects[0]]
else:
self.instance.assigned_object = None
-
- # Primary MAC address assignment is only available if an interface has been assigned.
- interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')
- if self.cleaned_data.get('is_primary') and not interface:
- self.add_error(
- 'is_primary', _("Only IP addresses assigned to an interface can be designated as primary IPs.")
- )
diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py
index 5d044630e..7e51790e2 100644
--- a/netbox/dcim/graphql/types.py
+++ b/netbox/dcim/graphql/types.py
@@ -390,7 +390,6 @@ class MACAddressType(NetBoxObjectType):
)
class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, PathEndpointMixin):
_name: str
- mac_address: str | None
wwn: str | None
parent: Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')] | None
bridge: Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')] | None
@@ -398,6 +397,7 @@ class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, P
wireless_link: Annotated["WirelessLinkType", strawberry.lazy('wireless.graphql.types')] | None
untagged_vlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
+ primary_mac_address: Annotated["MACAddressType", strawberry.lazy('dcim.graphql.types')] | None
qinq_svlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
vlan_translation_policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] | None
diff --git a/netbox/dcim/migrations/0199_macaddress.py b/netbox/dcim/migrations/0199_macaddress.py
index f9cc6a8df..8068c7436 100644
--- a/netbox/dcim/migrations/0199_macaddress.py
+++ b/netbox/dcim/migrations/0199_macaddress.py
@@ -24,7 +24,6 @@ class Migration(migrations.Migration):
('description', models.CharField(blank=True, max_length=200)),
('comments', models.TextField(blank=True)),
('mac_address', dcim.fields.MACAddressField()),
- ('is_primary', models.BooleanField(default=True)),
('assigned_object_id', models.PositiveBigIntegerField(blank=True, null=True)),
('assigned_object_type', models.ForeignKey(blank=True, limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'dcim'), ('model', 'interface')), models.Q(('app_label', 'virtualization'), ('model', 'vminterface')), _connector='OR')), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
diff --git a/netbox/dcim/migrations/0200_populate_mac_addresses.py b/netbox/dcim/migrations/0200_populate_mac_addresses.py
index 98bf83f5c..1f3c5dee9 100644
--- a/netbox/dcim/migrations/0200_populate_mac_addresses.py
+++ b/netbox/dcim/migrations/0200_populate_mac_addresses.py
@@ -1,4 +1,5 @@
-from django.db import migrations
+import django.db.models.deletion
+from django.db import migrations, models
def populate_mac_addresses(apps, schema_editor):
@@ -9,14 +10,18 @@ def populate_mac_addresses(apps, schema_editor):
mac_addresses = [
MACAddress(
- mac_address=interface._mac_address,
+ mac_address=interface.mac_address,
assigned_object_type=interface_ct,
assigned_object_id=interface.pk
)
- for interface in Interface.objects.filter(_mac_address__isnull=False)
+ for interface in Interface.objects.filter(mac_address__isnull=False)
]
MACAddress.objects.bulk_create(mac_addresses, batch_size=100)
+ # TODO: Optimize interface updates
+ for mac_address in mac_addresses:
+ Interface.objects.filter(pk=mac_address.assigned_object_id).update(primary_mac_address=mac_address)
+
class Migration(migrations.Migration):
@@ -25,11 +30,16 @@ class Migration(migrations.Migration):
]
operations = [
- # Rename mac_address field to avoid conflict with property
- migrations.RenameField(
+ migrations.AddField(
model_name='interface',
- old_name='mac_address',
- new_name='_mac_address',
+ name='primary_mac_address',
+ field=models.OneToOneField(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name='+',
+ to='dcim.macaddress'
+ ),
),
migrations.RunPython(
code=populate_mac_addresses,
@@ -37,6 +47,6 @@ class Migration(migrations.Migration):
),
migrations.RemoveField(
model_name='interface',
- name='_mac_address',
+ name='mac_address',
),
]
diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py
index a49b192b0..9b108bcca 100644
--- a/netbox/dcim/models/device_components.py
+++ b/netbox/dcim/models/device_components.py
@@ -567,6 +567,14 @@ class BaseInterface(models.Model):
blank=True,
verbose_name=_('VLAN Translation Policy')
)
+ primary_mac_address = models.OneToOneField(
+ to='dcim.MACAddress',
+ on_delete=models.SET_NULL,
+ related_name='+',
+ blank=True,
+ null=True,
+ verbose_name=_('primary MAC address')
+ )
class Meta:
abstract = True
@@ -580,6 +588,14 @@ class BaseInterface(models.Model):
'qinq_svlan': _("Only Q-in-Q interfaces may specify a service VLAN.")
})
+ # Check that the primary MAC address (if any) is assigned to this interface
+ if self.primary_mac_address and self.primary_mac_address.assigned_object != self:
+ raise ValidationError({
+ 'primary_mac_address': _("MAC address {mac_address} is not assigned to this interface.").format(
+ mac_address=self.primary_mac_address
+ )
+ })
+
def save(self, *args, **kwargs):
# Remove untagged VLAN assignment for non-802.1Q interfaces
@@ -606,9 +622,8 @@ class BaseInterface(models.Model):
@cached_property
def mac_address(self):
- if macaddress := self.mac_addresses.order_by('-is_primary', 'mac_address').first():
- return macaddress.mac_address
- return None
+ if self.primary_mac_address:
+ return self.primary_mac_address.mac_address
class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEndpoint, TrackingModelMixin):
diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py
index ff374644f..b49d680a3 100644
--- a/netbox/dcim/models/devices.py
+++ b/netbox/dcim/models/devices.py
@@ -1499,10 +1499,6 @@ class MACAddress(PrimaryModel):
ct_field='assigned_object_type',
fk_field='assigned_object_id'
)
- is_primary = models.BooleanField(
- verbose_name=_('is primary'),
- default=True
- )
class Meta:
ordering = ('mac_address',)
@@ -1511,17 +1507,3 @@ class MACAddress(PrimaryModel):
def __str__(self):
return str(self.mac_address)
-
- def clean(self):
- super().clean()
-
- if self.is_primary and self.assigned_object:
- 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': _("A primary MAC address is already designated for this interface.")
- })
diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py
index 70bd8242e..d226c7335 100644
--- a/netbox/dcim/tables/devices.py
+++ b/netbox/dcim/tables/devices.py
@@ -599,6 +599,10 @@ class BaseInterfaceTable(NetBoxTable):
verbose_name=_('Q-in-Q SVLAN'),
linkify=True
)
+ primary_mac_address = tables.Column(
+ verbose_name=_('MAC Address'),
+ linkify=True
+ )
def value_ip_addresses(self, value):
return ",".join([str(obj.address) for obj in value.all()])
@@ -649,11 +653,11 @@ class InterfaceTable(BaseInterfaceTable, ModularDeviceComponentTable, PathEndpoi
model = models.Interface
fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu',
- 'speed', 'speed_formatted', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role',
- 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected',
- 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf',
- 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan',
- 'inventory_items', 'created', 'last_updated',
+ 'speed', 'speed_formatted', 'duplex', 'mode', 'primary_mac_address', 'wwn', 'poe_mode', 'poe_type',
+ 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description',
+ 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection',
+ 'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans',
+ 'qinq_svlan', 'inventory_items', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
@@ -1127,10 +1131,6 @@ class MACAddressTable(NetBoxTable):
orderable=False,
verbose_name=_('Parent')
)
- is_primary = columns.BooleanColumn(
- verbose_name=_('Primary'),
- false_mark=None
- )
tags = columns.TagColumn(
url_name='dcim:macaddress_list'
)
@@ -1141,7 +1141,6 @@ class MACAddressTable(NetBoxTable):
class Meta(DeviceComponentTable.Meta):
model = models.MACAddress
fields = (
- 'pk', 'id', 'mac_address', 'assigned_object_parent', 'assigned_object', 'is_primary', 'created',
- 'last_updated',
+ 'pk', 'id', 'mac_address', 'assigned_object_parent', 'assigned_object', 'created', 'last_updated',
)
- default_columns = ('pk', 'mac_address', 'assigned_object_parent', 'assigned_object', 'is_primary')
+ default_columns = ('pk', 'mac_address', 'assigned_object_parent', 'assigned_object')
diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py
index 03eb52e90..897612b84 100644
--- a/netbox/dcim/tests/test_filtersets.py
+++ b/netbox/dcim/tests/test_filtersets.py
@@ -5891,15 +5891,15 @@ class MACAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
mac_addresses = (
# Device MACs
- MACAddress(mac_address='00-00-00-01-01-01', assigned_object=interfaces[0], is_primary=True),
- MACAddress(mac_address='00-00-00-02-01-01', assigned_object=interfaces[1], is_primary=True),
- MACAddress(mac_address='00-00-00-03-01-01', assigned_object=interfaces[2], is_primary=True),
- MACAddress(mac_address='00-00-00-03-01-02', assigned_object=interfaces[2], is_primary=False),
+ MACAddress(mac_address='00-00-00-01-01-01', assigned_object=interfaces[0]),
+ MACAddress(mac_address='00-00-00-02-01-01', assigned_object=interfaces[1]),
+ MACAddress(mac_address='00-00-00-03-01-01', assigned_object=interfaces[2]),
+ MACAddress(mac_address='00-00-00-03-01-02', assigned_object=interfaces[2]),
# VM MACs
- MACAddress(mac_address='00-00-00-04-01-01', assigned_object=vm_interfaces[0], is_primary=True),
- MACAddress(mac_address='00-00-00-05-01-01', assigned_object=vm_interfaces[1], is_primary=True),
- MACAddress(mac_address='00-00-00-06-01-01', assigned_object=vm_interfaces[2], is_primary=True),
- MACAddress(mac_address='00-00-00-06-01-02', assigned_object=vm_interfaces[2], is_primary=False),
+ MACAddress(mac_address='00-00-00-04-01-01', assigned_object=vm_interfaces[0]),
+ MACAddress(mac_address='00-00-00-05-01-01', assigned_object=vm_interfaces[1]),
+ MACAddress(mac_address='00-00-00-06-01-01', assigned_object=vm_interfaces[2]),
+ MACAddress(mac_address='00-00-00-06-01-02', assigned_object=vm_interfaces[2]),
)
MACAddress.objects.bulk_create(mac_addresses)
@@ -5907,12 +5907,6 @@ class MACAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'mac_address': ['00-00-00-01-01-01', '00-00-00-02-01-01']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
- def test_is_primary(self):
- params = {'is_primary': True}
- self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
- params = {'is_primary': False}
- self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
def test_device(self):
devices = Device.objects.all()[:2]
params = {'device_id': [devices[0].pk, devices[1].pk]}
diff --git a/netbox/templates/virtualization/vminterface.html b/netbox/templates/virtualization/vminterface.html
index 1709bcb5f..88c9379cf 100644
--- a/netbox/templates/virtualization/vminterface.html
+++ b/netbox/templates/virtualization/vminterface.html
@@ -14,80 +14,85 @@
{% block content %}
-
-
-
-
- {% trans "Virtual Machine" %} |
- {{ object.virtual_machine|linkify }} |
-
-
- {% trans "Name" %} |
- {{ object.name }} |
-
-
- {% trans "Enabled" %} |
-
- {% if object.enabled %}
-
- {% else %}
-
- {% endif %}
- |
-
-
- {% trans "Parent" %} |
- {{ object.parent|linkify|placeholder }} |
-
-
- {% trans "Bridge" %} |
- {{ object.bridge|linkify|placeholder }} |
-
-
- {% trans "VRF" %} |
- {{ object.vrf|linkify|placeholder }} |
-
-
- {% trans "Description" %} |
- {{ object.description|placeholder }} |
-
-
- {% trans "MTU" %} |
- {{ object.mtu|placeholder }} |
-
-
- {% trans "MAC Address" %} |
-
- {% if object.mac_address %}
- {{ object.mac_address }}
- {% trans "Primary" %}
- {% else %}
- {{ ''|placeholder }}
- {% endif %}
- |
-
-
- {% trans "802.1Q Mode" %} |
- {{ object.get_mode_display|placeholder }} |
-
-
- {% trans "Tunnel" %} |
- {{ object.tunnel_termination.tunnel|linkify|placeholder }} |
-
-
- {% trans "VLAN Translation" %} |
- {{ object.vlan_translation_policy|linkify|placeholder }} |
-
-
-
- {% include 'inc/panels/tags.html' %}
- {% plugin_left_page object %}
+
+
+
+
+ {% trans "Virtual Machine" %} |
+ {{ object.virtual_machine|linkify }} |
+
+
+ {% trans "Name" %} |
+ {{ object.name }} |
+
+
+ {% trans "Enabled" %} |
+
+ {% if object.enabled %}
+
+ {% else %}
+
+ {% endif %}
+ |
+
+
+ {% trans "Parent" %} |
+ {{ object.parent|linkify|placeholder }} |
+
+
+ {% trans "Bridge" %} |
+ {{ object.bridge|linkify|placeholder }} |
+
+
+ {% trans "Description" %} |
+ {{ object.description|placeholder }} |
+
+
+ {% trans "MTU" %} |
+ {{ object.mtu|placeholder }} |
+
+
+ {% trans "802.1Q Mode" %} |
+ {{ object.get_mode_display|placeholder }} |
+
+
+ {% trans "Tunnel" %} |
+ {{ object.tunnel_termination.tunnel|linkify|placeholder }} |
+
+
-
- {% include 'inc/panels/custom_fields.html' %}
- {% include 'ipam/inc/panels/fhrp_groups.html' %}
- {% plugin_right_page object %}
+ {% include 'inc/panels/tags.html' %}
+ {% plugin_left_page object %}
+
+
+ {% include 'inc/panels/custom_fields.html' %}
+
+
+
+
+ {% trans "MAC Address" %} |
+
+ {% if object.mac_address %}
+ {{ object.mac_address }}
+ {% trans "Primary" %}
+ {% else %}
+ {{ ''|placeholder }}
+ {% endif %}
+ |
+
+
+ {% trans "VRF" %} |
+ {{ object.vrf|linkify|placeholder }} |
+
+
+ {% trans "VLAN Translation" %} |
+ {{ object.vlan_translation_policy|linkify|placeholder }} |
+
+
+ {% include 'ipam/inc/panels/fhrp_groups.html' %}
+ {% plugin_right_page object %}
+
diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py
index 59e017c0e..2f2c67a9f 100644
--- a/netbox/virtualization/filtersets.py
+++ b/netbox/virtualization/filtersets.py
@@ -3,7 +3,7 @@ from django.db.models import Q
from django.utils.translation import gettext as _
from dcim.filtersets import CommonInterfaceFilterSet, ScopedFilterSet
-from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
+from dcim.models import Device, DeviceRole, MACAddress, Platform, Region, Site, SiteGroup
from extras.filtersets import LocalConfigContextFilterSet
from extras.models import ConfigTemplate
from ipam.filtersets import PrimaryIPFilterSet
@@ -265,6 +265,17 @@ class VMInterfaceFilterSet(NetBoxModelFilterSet, CommonInterfaceFilterSet):
field_name='mac_addresses__mac_address',
label=_('MAC address'),
)
+ primary_mac_address_id = django_filters.ModelMultipleChoiceFilter(
+ field_name='primary_mac_address',
+ queryset=MACAddress.objects.all(),
+ label=_('Primary MAC address (ID)'),
+ )
+ primary_mac_address = django_filters.ModelMultipleChoiceFilter(
+ field_name='primary_mac_address__mac_address',
+ queryset=MACAddress.objects.all(),
+ to_field_name='mac_address',
+ label=_('Primary MAC address'),
+ )
class Meta:
model = VMInterface
diff --git a/netbox/virtualization/forms/model_forms.py b/netbox/virtualization/forms/model_forms.py
index eb1630d63..bf5727a08 100644
--- a/netbox/virtualization/forms/model_forms.py
+++ b/netbox/virtualization/forms/model_forms.py
@@ -358,7 +358,7 @@ class VMInterfaceForm(InterfaceCommonForm, VMComponentForm):
fieldsets = (
FieldSet('virtual_machine', 'name', 'description', 'tags', name=_('Interface')),
- FieldSet('vrf', name=_('Addressing')),
+ FieldSet('vrf', 'primary_mac_address', name=_('Addressing')),
FieldSet('mtu', 'enabled', name=_('Operation')),
FieldSet('parent', 'bridge', name=_('Related Interfaces')),
FieldSet(
@@ -371,7 +371,8 @@ 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', 'tags',
+ 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf', 'primary_mac_address',
+ 'tags',
]
labels = {
'mode': _('802.1Q Mode'),
diff --git a/netbox/virtualization/graphql/types.py b/netbox/virtualization/graphql/types.py
index 35e783fa7..33d6ce450 100644
--- a/netbox/virtualization/graphql/types.py
+++ b/netbox/virtualization/graphql/types.py
@@ -106,6 +106,7 @@ class VMInterfaceType(IPAddressesMixin, ComponentType):
bridge: Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')] | None
untagged_vlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
+ primary_mac_address: Annotated["MACAddressType", strawberry.lazy('dcim.graphql.types')] | None
qinq_svlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
vlan_translation_policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] | None
diff --git a/netbox/virtualization/migrations/0047_vminterface_rename_mac_address.py b/netbox/virtualization/migrations/0047_populate_mac_addresses.py
similarity index 56%
rename from netbox/virtualization/migrations/0047_vminterface_rename_mac_address.py
rename to netbox/virtualization/migrations/0047_populate_mac_addresses.py
index 092b9eb50..95316837c 100644
--- a/netbox/virtualization/migrations/0047_vminterface_rename_mac_address.py
+++ b/netbox/virtualization/migrations/0047_populate_mac_addresses.py
@@ -1,4 +1,5 @@
-from django.db import migrations
+import django.db.models.deletion
+from django.db import migrations, models
def populate_mac_addresses(apps, schema_editor):
@@ -9,14 +10,18 @@ def populate_mac_addresses(apps, schema_editor):
mac_addresses = [
MACAddress(
- mac_address=vminterface._mac_address,
+ mac_address=vminterface.mac_address,
assigned_object_type=vminterface_ct,
assigned_object_id=vminterface.pk
)
- for vminterface in VMInterface.objects.filter(_mac_address__isnull=False)
+ for vminterface in VMInterface.objects.filter(mac_address__isnull=False)
]
MACAddress.objects.bulk_create(mac_addresses, batch_size=100)
+ # TODO: Optimize interface updates
+ for mac_address in mac_addresses:
+ VMInterface.objects.filter(pk=mac_address.assigned_object_id).update(primary_mac_address=mac_address)
+
class Migration(migrations.Migration):
@@ -26,11 +31,16 @@ class Migration(migrations.Migration):
]
operations = [
- # Rename mac_address field to avoid conflict with property
- migrations.RenameField(
+ migrations.AddField(
model_name='vminterface',
- old_name='mac_address',
- new_name='_mac_address',
+ name='primary_mac_address',
+ field=models.OneToOneField(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name='+',
+ to='dcim.macaddress'
+ ),
),
migrations.RunPython(
code=populate_mac_addresses,
@@ -38,6 +48,6 @@ class Migration(migrations.Migration):
),
migrations.RemoveField(
model_name='vminterface',
- name='_mac_address',
+ name='mac_address',
),
]
diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py
index ad41d5c13..fe7a66ac1 100644
--- a/netbox/virtualization/tables/virtualmachines.py
+++ b/netbox/virtualization/tables/virtualmachines.py
@@ -153,8 +153,8 @@ class VMInterfaceTable(BaseInterfaceTable):
model = VMInterface
fields = (
'pk', 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags',
- 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan',
- 'created', 'last_updated',
+ 'vrf', 'primary_mac_address', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan',
+ 'tagged_vlans', 'qinq_svlan', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description')