Designate primary MAC address via a ForeignKey on the interface models

This commit is contained in:
Jeremy Stretch 2024-11-18 12:26:46 -05:00
parent a0db3b0719
commit 95919d1a79
20 changed files with 217 additions and 196 deletions

View File

@ -169,8 +169,11 @@ class MACAddressSerializer(NetBoxModelSerializer):
class Meta: class Meta:
model = MACAddress model = MACAddress
fields = ['mac_address', 'is_primary', 'assigned_object_type', 'assigned_object'] fields = [
brief_fields = ('mac_address',) '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)) @extend_schema_field(serializers.JSONField(allow_null=True))
def get_assigned_object(self, obj): def get_assigned_object(self, obj):

View File

@ -1647,7 +1647,7 @@ class MACAddressFilterSet(NetBoxModelFilterSet):
class Meta: class Meta:
model = MACAddress 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): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
@ -1789,6 +1789,17 @@ class InterfaceFilterSet(
field_name='mac_addresses__mac_address', field_name='mac_addresses__mac_address',
label=_('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() wwn = MultiValueWWNFilter()
poe_mode = django_filters.MultipleChoiceFilter( poe_mode = django_filters.MultipleChoiceFilter(
choices=InterfacePoEModeChoices choices=InterfacePoEModeChoices

View File

@ -1732,17 +1732,10 @@ class MACAddressBulkEditForm(NetBoxModelBulkEditForm):
max_length=200, max_length=200,
required=False required=False
) )
is_primary = forms.NullBooleanField(
label=_('Is primary'),
required=False,
widget=BulkEditNullBooleanSelect(),
)
comments = CommentField() comments = CommentField()
model = MACAddress model = MACAddress
fieldsets = ( fieldsets = (
FieldSet('description', 'is_primary'), FieldSet('description'),
)
nullable_fields = (
'description', 'comments',
) )
nullable_fields = ('description', 'comments')

View File

@ -1203,8 +1203,7 @@ class MACAddressImportForm(NetBoxModelImportForm):
class Meta: class Meta:
model = MACAddress model = MACAddress
fields = [ fields = [
'mac_address', 'device', 'virtual_machine', 'interface', 'is_primary', 'mac_address', 'device', 'virtual_machine', 'interface', 'is_primary', 'description', 'comments', 'tags',
'description', 'comments', 'tags',
] ]
def __init__(self, data=None, *args, **kwargs): def __init__(self, data=None, *args, **kwargs):
@ -1230,25 +1229,27 @@ class MACAddressImportForm(NetBoxModelImportForm):
device = self.cleaned_data.get('device') device = self.cleaned_data.get('device')
virtual_machine = self.cleaned_data.get('virtual_machine') virtual_machine = self.cleaned_data.get('virtual_machine')
interface = self.cleaned_data.get('interface') 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: if interface and not device and not virtual_machine:
raise forms.ValidationError({ raise forms.ValidationError({
"interface": _("Must specify the parent device or VM when assigning an interface") "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): def save(self, *args, **kwargs):
# Set interface assignment # Set interface assignment
if self.cleaned_data.get('interface'): if interface := self.cleaned_data.get('interface'):
self.instance.assigned_object = self.cleaned_data['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
# #

View File

@ -3,7 +3,9 @@ from django.utils.translation import gettext_lazy as _
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.models import MACAddress
from utilities.forms import get_field_value from utilities.forms import get_field_value
from utilities.forms.fields import DynamicModelChoiceField
__all__ = ( __all__ = (
'InterfaceCommonForm', 'InterfaceCommonForm',
@ -18,6 +20,11 @@ class InterfaceCommonForm(forms.Form):
max_value=INTERFACE_MTU_MAX, max_value=INTERFACE_MTU_MAX,
label=_('MTU') label=_('MTU')
) )
primary_mac_address = DynamicModelChoiceField(
queryset=MACAddress.objects.all(),
label=_('Primary MAC address'),
required=False
)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -35,6 +42,10 @@ class InterfaceCommonForm(forms.Form):
if interface_mode != InterfaceModeChoices.MODE_Q_IN_Q: if interface_mode != InterfaceModeChoices.MODE_Q_IN_Q:
del self.fields['qinq_svlan'] 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): def clean(self):
super().clean() super().clean()

View File

@ -1583,21 +1583,13 @@ class MACAddressFilterForm(NetBoxModelFilterSetForm):
model = MACAddress model = MACAddress
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('mac_address', 'is_primary', name=_('Addressing')), FieldSet('mac_address', 'device_id', 'virtual_machine_id', name=_('MAC address')),
FieldSet('device_id', 'virtual_machine_id', name=_('Device/VM')),
) )
selector_fields = ('filter_id', 'q', 'device_id', 'virtual_machine_id') selector_fields = ('filter_id', 'q', 'device_id', 'virtual_machine_id')
mac_address = forms.CharField( mac_address = forms.CharField(
required=False, required=False,
label=_('MAC address') label=_('MAC address')
) )
is_primary = forms.NullBooleanField(
required=False,
label=_('Is primary'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
device_id = DynamicModelMultipleChoiceField( device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.all(),
required=False, required=False,

View File

@ -1,6 +1,5 @@
from django import forms from django import forms
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from timezone_field import TimeZoneFormField from timezone_field import TimeZoneFormField
@ -1405,7 +1404,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
FieldSet( FieldSet(
'device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags', name=_('Interface') '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('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')),
FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')), FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')),
FieldSet('poe_mode', 'poe_type', name=_('PoE')), 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', 'device', 'module', 'vdcs', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge',
'lag', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'poe_mode', 'poe_type', 'mode', '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', '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 = { widgets = {
'speed': NumberWithOptions( 'speed': NumberWithOptions(
@ -1740,10 +1740,6 @@ class MACAddressForm(NetBoxModelForm):
queryset=VMInterface.objects.all(), queryset=VMInterface.objects.all(),
required=False, required=False,
) )
is_primary = forms.BooleanField(
required=False,
label=_('Primary for interface'),
)
fieldsets = ( fieldsets = (
FieldSet( FieldSet(
@ -1754,14 +1750,13 @@ class MACAddressForm(NetBoxModelForm):
FieldSet('interface', name=_('Device')), FieldSet('interface', name=_('Device')),
FieldSet('vminterface', name=_('Virtual Machine')), FieldSet('vminterface', name=_('Virtual Machine')),
), ),
'is_primary', name=_('Assignment')
), ),
) )
class Meta: class Meta:
model = MACAddress model = MACAddress
fields = [ fields = [
'mac_address', 'interface', 'vminterface', 'is_primary', 'description', 'tags', 'mac_address', 'interface', 'vminterface', 'description', 'tags',
] ]
def __init__(self, *args, **kwargs): 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.") selected_objects[1]: _("A MAC address can only be assigned to a single object.")
}) })
elif selected_objects: elif selected_objects:
assigned_object = self.cleaned_data[selected_objects[0]] self.instance.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
else: else:
self.instance.assigned_object = None 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.")
)

View File

@ -390,7 +390,6 @@ class MACAddressType(NetBoxObjectType):
) )
class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, PathEndpointMixin): class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, PathEndpointMixin):
_name: str _name: str
mac_address: str | None
wwn: str | None wwn: str | None
parent: Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')] | None parent: Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')] | None
bridge: 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 wireless_link: Annotated["WirelessLinkType", strawberry.lazy('wireless.graphql.types')] | None
untagged_vlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None untagged_vlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
vrf: Annotated["VRFType", 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 qinq_svlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
vlan_translation_policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] | None vlan_translation_policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] | None

View File

@ -24,7 +24,6 @@ class Migration(migrations.Migration):
('description', models.CharField(blank=True, max_length=200)), ('description', models.CharField(blank=True, max_length=200)),
('comments', models.TextField(blank=True)), ('comments', models.TextField(blank=True)),
('mac_address', dcim.fields.MACAddressField()), ('mac_address', dcim.fields.MACAddressField()),
('is_primary', models.BooleanField(default=True)),
('assigned_object_id', models.PositiveBigIntegerField(blank=True, null=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')), ('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')), ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),

View File

@ -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): def populate_mac_addresses(apps, schema_editor):
@ -9,14 +10,18 @@ def populate_mac_addresses(apps, schema_editor):
mac_addresses = [ mac_addresses = [
MACAddress( MACAddress(
mac_address=interface._mac_address, mac_address=interface.mac_address,
assigned_object_type=interface_ct, assigned_object_type=interface_ct,
assigned_object_id=interface.pk 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) 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): class Migration(migrations.Migration):
@ -25,11 +30,16 @@ class Migration(migrations.Migration):
] ]
operations = [ operations = [
# Rename mac_address field to avoid conflict with property migrations.AddField(
migrations.RenameField(
model_name='interface', model_name='interface',
old_name='mac_address', name='primary_mac_address',
new_name='_mac_address', field=models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='+',
to='dcim.macaddress'
),
), ),
migrations.RunPython( migrations.RunPython(
code=populate_mac_addresses, code=populate_mac_addresses,
@ -37,6 +47,6 @@ class Migration(migrations.Migration):
), ),
migrations.RemoveField( migrations.RemoveField(
model_name='interface', model_name='interface',
name='_mac_address', name='mac_address',
), ),
] ]

View File

@ -567,6 +567,14 @@ class BaseInterface(models.Model):
blank=True, blank=True,
verbose_name=_('VLAN Translation Policy') 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: class Meta:
abstract = True abstract = True
@ -580,6 +588,14 @@ class BaseInterface(models.Model):
'qinq_svlan': _("Only Q-in-Q interfaces may specify a service VLAN.") '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): def save(self, *args, **kwargs):
# Remove untagged VLAN assignment for non-802.1Q interfaces # Remove untagged VLAN assignment for non-802.1Q interfaces
@ -606,9 +622,8 @@ class BaseInterface(models.Model):
@cached_property @cached_property
def mac_address(self): def mac_address(self):
if macaddress := self.mac_addresses.order_by('-is_primary', 'mac_address').first(): if self.primary_mac_address:
return macaddress.mac_address return self.primary_mac_address.mac_address
return None
class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEndpoint, TrackingModelMixin): class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEndpoint, TrackingModelMixin):

View File

@ -1499,10 +1499,6 @@ class MACAddress(PrimaryModel):
ct_field='assigned_object_type', ct_field='assigned_object_type',
fk_field='assigned_object_id' fk_field='assigned_object_id'
) )
is_primary = models.BooleanField(
verbose_name=_('is primary'),
default=True
)
class Meta: class Meta:
ordering = ('mac_address',) ordering = ('mac_address',)
@ -1511,17 +1507,3 @@ class MACAddress(PrimaryModel):
def __str__(self): def __str__(self):
return str(self.mac_address) 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.")
})

View File

@ -599,6 +599,10 @@ class BaseInterfaceTable(NetBoxTable):
verbose_name=_('Q-in-Q SVLAN'), verbose_name=_('Q-in-Q SVLAN'),
linkify=True linkify=True
) )
primary_mac_address = tables.Column(
verbose_name=_('MAC Address'),
linkify=True
)
def value_ip_addresses(self, value): def value_ip_addresses(self, value):
return ",".join([str(obj.address) for obj in value.all()]) return ",".join([str(obj.address) for obj in value.all()])
@ -649,11 +653,11 @@ class InterfaceTable(BaseInterfaceTable, ModularDeviceComponentTable, PathEndpoi
model = models.Interface model = models.Interface
fields = ( fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', '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', 'speed', 'speed_formatted', 'duplex', 'mode', 'primary_mac_address', 'wwn', 'poe_mode', 'poe_type',
'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description',
'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection',
'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans',
'inventory_items', 'created', 'last_updated', 'qinq_svlan', 'inventory_items', 'created', 'last_updated',
) )
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
@ -1127,10 +1131,6 @@ class MACAddressTable(NetBoxTable):
orderable=False, orderable=False,
verbose_name=_('Parent') verbose_name=_('Parent')
) )
is_primary = columns.BooleanColumn(
verbose_name=_('Primary'),
false_mark=None
)
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='dcim:macaddress_list' url_name='dcim:macaddress_list'
) )
@ -1141,7 +1141,6 @@ class MACAddressTable(NetBoxTable):
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
model = models.MACAddress model = models.MACAddress
fields = ( fields = (
'pk', 'id', 'mac_address', 'assigned_object_parent', 'assigned_object', 'is_primary', 'created', 'pk', 'id', 'mac_address', 'assigned_object_parent', 'assigned_object', 'created', 'last_updated',
'last_updated',
) )
default_columns = ('pk', 'mac_address', 'assigned_object_parent', 'assigned_object', 'is_primary') default_columns = ('pk', 'mac_address', 'assigned_object_parent', 'assigned_object')

View File

@ -5891,15 +5891,15 @@ class MACAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
mac_addresses = ( mac_addresses = (
# Device MACs # Device MACs
MACAddress(mac_address='00-00-00-01-01-01', assigned_object=interfaces[0], is_primary=True), 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], is_primary=True), 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], is_primary=True), 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], is_primary=False), MACAddress(mac_address='00-00-00-03-01-02', assigned_object=interfaces[2]),
# VM MACs # 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-04-01-01', assigned_object=vm_interfaces[0]),
MACAddress(mac_address='00-00-00-05-01-01', assigned_object=vm_interfaces[1], is_primary=True), 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], is_primary=True), 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], is_primary=False), MACAddress(mac_address='00-00-00-06-01-02', assigned_object=vm_interfaces[2]),
) )
MACAddress.objects.bulk_create(mac_addresses) 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']} 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) 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): def test_device(self):
devices = Device.objects.all()[:2] devices = Device.objects.all()[:2]
params = {'device_id': [devices[0].pk, devices[1].pk]} params = {'device_id': [devices[0].pk, devices[1].pk]}

View File

@ -14,80 +14,85 @@
{% block content %} {% block content %}
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-6"> <div class="col col-md-6">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Interface" %}</h2> <h2 class="card-header">{% trans "Interface" %}</h2>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
<tr> <tr>
<th scope="row">{% trans "Virtual Machine" %}</th> <th scope="row">{% trans "Virtual Machine" %}</th>
<td>{{ object.virtual_machine|linkify }}</td> <td>{{ object.virtual_machine|linkify }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Name" %}</th> <th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td> <td>{{ object.name }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Enabled" %}</th> <th scope="row">{% trans "Enabled" %}</th>
<td> <td>
{% if object.enabled %} {% if object.enabled %}
<span class="text-success"><i class="mdi mdi-check-bold"></i></span> <span class="text-success"><i class="mdi mdi-check-bold"></i></span>
{% else %} {% else %}
<span class="text-danger"><i class="mdi mdi-close"></i></span> <span class="text-danger"><i class="mdi mdi-close"></i></span>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Parent" %}</th> <th scope="row">{% trans "Parent" %}</th>
<td>{{ object.parent|linkify|placeholder }}</td> <td>{{ object.parent|linkify|placeholder }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Bridge" %}</th> <th scope="row">{% trans "Bridge" %}</th>
<td>{{ object.bridge|linkify|placeholder }}</td> <td>{{ object.bridge|linkify|placeholder }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "VRF" %}</th> <th scope="row">{% trans "Description" %}</th>
<td>{{ object.vrf|linkify|placeholder }}</td> <td>{{ object.description|placeholder }} </td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Description" %}</th> <th scope="row">{% trans "MTU" %}</th>
<td>{{ object.description|placeholder }} </td> <td>{{ object.mtu|placeholder }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "MTU" %}</th> <th scope="row">{% trans "802.1Q Mode" %}</th>
<td>{{ object.mtu|placeholder }}</td> <td>{{ object.get_mode_display|placeholder }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "MAC Address" %}</th> <th scope="row">{% trans "Tunnel" %}</th>
<td> <td>{{ object.tunnel_termination.tunnel|linkify|placeholder }}</td>
{% if object.mac_address %} </tr>
<span class="font-monospace">{{ object.mac_address }}</span> </table>
<span class="badge text-bg-primary">{% trans "Primary" %}</span>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "802.1Q Mode" %}</th>
<td>{{ object.get_mode_display|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Tunnel" %}</th>
<td>{{ object.tunnel_termination.tunnel|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "VLAN Translation" %}</th>
<td>{{ object.vlan_translation_policy|linkify|placeholder }}</td>
</tr>
</table>
</div>
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> {% include 'inc/panels/tags.html' %}
{% include 'inc/panels/custom_fields.html' %} {% plugin_left_page object %}
{% include 'ipam/inc/panels/fhrp_groups.html' %} </div>
{% plugin_right_page object %} <div class="col col-md-6">
{% include 'inc/panels/custom_fields.html' %}
<div class="card">
<h2 class="card-header">{% trans "Addressing" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "MAC Address" %}</th>
<td>
{% if object.mac_address %}
<span class="font-monospace">{{ object.mac_address }}</span>
<span class="badge text-bg-primary">{% trans "Primary" %}</span>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "VRF" %}</th>
<td>{{ object.vrf|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "VLAN Translation" %}</th>
<td>{{ object.vlan_translation_policy|linkify|placeholder }}</td>
</tr>
</table>
</div> </div>
{% include 'ipam/inc/panels/fhrp_groups.html' %}
{% plugin_right_page object %}
</div>
</div> </div>
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-12"> <div class="col col-md-12">

View File

@ -3,7 +3,7 @@ from django.db.models import Q
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from dcim.filtersets import CommonInterfaceFilterSet, ScopedFilterSet 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.filtersets import LocalConfigContextFilterSet
from extras.models import ConfigTemplate from extras.models import ConfigTemplate
from ipam.filtersets import PrimaryIPFilterSet from ipam.filtersets import PrimaryIPFilterSet
@ -265,6 +265,17 @@ class VMInterfaceFilterSet(NetBoxModelFilterSet, CommonInterfaceFilterSet):
field_name='mac_addresses__mac_address', field_name='mac_addresses__mac_address',
label=_('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: class Meta:
model = VMInterface model = VMInterface

View File

@ -358,7 +358,7 @@ class VMInterfaceForm(InterfaceCommonForm, VMComponentForm):
fieldsets = ( fieldsets = (
FieldSet('virtual_machine', 'name', 'description', 'tags', name=_('Interface')), 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('mtu', 'enabled', name=_('Operation')),
FieldSet('parent', 'bridge', name=_('Related Interfaces')), FieldSet('parent', 'bridge', name=_('Related Interfaces')),
FieldSet( FieldSet(
@ -371,7 +371,8 @@ class VMInterfaceForm(InterfaceCommonForm, VMComponentForm):
model = VMInterface model = VMInterface
fields = [ fields = [
'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mtu', 'description', 'mode', 'vlan_group', '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 = { labels = {
'mode': _('802.1Q Mode'), 'mode': _('802.1Q Mode'),

View File

@ -106,6 +106,7 @@ class VMInterfaceType(IPAddressesMixin, ComponentType):
bridge: Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')] | None bridge: Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')] | None
untagged_vlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None untagged_vlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
vrf: Annotated["VRFType", 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 qinq_svlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
vlan_translation_policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] | None vlan_translation_policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] | None

View File

@ -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): def populate_mac_addresses(apps, schema_editor):
@ -9,14 +10,18 @@ def populate_mac_addresses(apps, schema_editor):
mac_addresses = [ mac_addresses = [
MACAddress( MACAddress(
mac_address=vminterface._mac_address, mac_address=vminterface.mac_address,
assigned_object_type=vminterface_ct, assigned_object_type=vminterface_ct,
assigned_object_id=vminterface.pk 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) 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): class Migration(migrations.Migration):
@ -26,11 +31,16 @@ class Migration(migrations.Migration):
] ]
operations = [ operations = [
# Rename mac_address field to avoid conflict with property migrations.AddField(
migrations.RenameField(
model_name='vminterface', model_name='vminterface',
old_name='mac_address', name='primary_mac_address',
new_name='_mac_address', field=models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='+',
to='dcim.macaddress'
),
), ),
migrations.RunPython( migrations.RunPython(
code=populate_mac_addresses, code=populate_mac_addresses,
@ -38,6 +48,6 @@ class Migration(migrations.Migration):
), ),
migrations.RemoveField( migrations.RemoveField(
model_name='vminterface', model_name='vminterface',
name='_mac_address', name='mac_address',
), ),
] ]

View File

@ -153,8 +153,8 @@ class VMInterfaceTable(BaseInterfaceTable):
model = VMInterface model = VMInterface
fields = ( fields = (
'pk', 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags', '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', 'vrf', 'primary_mac_address', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan',
'created', 'last_updated', 'tagged_vlans', 'qinq_svlan', 'created', 'last_updated',
) )
default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description') default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description')