From b8572dc33f8aec9af28a6a8acccf0720927bd85f Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Wed, 30 Oct 2024 16:06:10 -0400 Subject: [PATCH] All views/filtering working and documentation done; no unit tests yet --- docs/models/dcim/interface.md | 7 +- docs/models/dcim/macaddress.md | 11 ++ docs/models/virtualization/vminterface.md | 7 +- .../api/serializers_/device_components.py | 11 +- netbox/dcim/constants.py | 10 ++ netbox/dcim/filtersets.py | 159 +++++++++++++++++- netbox/dcim/forms/bulk_create.py | 8 + netbox/dcim/forms/bulk_edit.py | 21 ++- netbox/dcim/forms/bulk_import.py | 97 ++++++++++- netbox/dcim/forms/filtersets.py | 15 ++ netbox/dcim/forms/model_forms.py | 119 ++++++++++++- netbox/dcim/forms/object_create.py | 7 + netbox/dcim/graphql/types.py | 2 +- ...dress_interface__mac_address_macaddress.py | 21 ++- .../0196_remove_interface__mac_address.py | 17 ++ netbox/dcim/models/device_components.py | 54 ++++-- netbox/dcim/tables/devices.py | 35 ++++ netbox/dcim/urls.py | 10 ++ netbox/dcim/views.py | 88 +++++++++- netbox/netbox/navigation/menu.py | 1 + netbox/templates/dcim/interface.html | 18 +- netbox/templates/dcim/macaddress.html | 50 ++++++ .../templates/dcim/macaddress_bulk_add.html | 30 ++++ .../templates/virtualization/vminterface.html | 18 ++ netbox/virtualization/filtersets.py | 16 +- netbox/virtualization/forms/bulk_import.py | 2 +- netbox/virtualization/graphql/types.py | 2 +- .../0043_remove_vminterface__mac_address.py | 17 ++ .../virtualization/models/virtualmachines.py | 6 + 29 files changed, 804 insertions(+), 55 deletions(-) create mode 100644 docs/models/dcim/macaddress.md create mode 100644 netbox/dcim/migrations/0196_remove_interface__mac_address.py create mode 100644 netbox/templates/dcim/macaddress.html create mode 100644 netbox/templates/dcim/macaddress_bulk_add.html create mode 100644 netbox/virtualization/migrations/0043_remove_vminterface__mac_address.py diff --git a/docs/models/dcim/interface.md b/docs/models/dcim/interface.md index 3667dabd5..f148bbfbc 100644 --- a/docs/models/dcim/interface.md +++ b/docs/models/dcim/interface.md @@ -10,6 +10,9 @@ Interfaces in NetBox represent network interfaces used to exchange data with con ## Fields +!!! note + The MAC address of an interface (formerly a concrete database field) is available as a property, `mac_address`, which reflects the value of the primary linked [MAC address](./macaddress.md) object. + ### Device The device to which this interface belongs. @@ -45,10 +48,6 @@ The operation duplex (full, half, or auto). The [virtual routing and forwarding](../ipam/vrf.md) instance to which this interface is assigned. -### MAC Address - -The 48-bit MAC address (for Ethernet interfaces). - ### WWN The 64-bit world-wide name (for Fibre Channel interfaces). diff --git a/docs/models/dcim/macaddress.md b/docs/models/dcim/macaddress.md new file mode 100644 index 000000000..33ca1d9ff --- /dev/null +++ b/docs/models/dcim/macaddress.md @@ -0,0 +1,11 @@ +# 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. + +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. + +## Fields + +### MAC Address + +The 48-bit MAC address, in colon-hexadecimal notation (e.g. `aa:bb:cc:11:22:33`). diff --git a/docs/models/virtualization/vminterface.md b/docs/models/virtualization/vminterface.md index d923bdd5d..ffc734cfc 100644 --- a/docs/models/virtualization/vminterface.md +++ b/docs/models/virtualization/vminterface.md @@ -4,6 +4,9 @@ ## Fields +!!! note + The MAC address of an interface (formerly a concrete database field) is available as a property, `mac_address`, which reflects the value of the primary linked [MAC address](./macaddress.md) object. + ### Virtual Machine The [virtual machine](./virtualmachine.md) to which this interface is assigned. @@ -27,10 +30,6 @@ An interface on the same VM with which this interface is bridged. If not selected, this interface will be treated as disabled/inoperative. -### MAC Address - -The 48-bit MAC address (for Ethernet interfaces). - ### MTU The interface's configured maximum transmissible unit (MTU). diff --git a/netbox/dcim/api/serializers_/device_components.py b/netbox/dcim/api/serializers_/device_components.py index e285ce349..292cc4b1f 100644 --- a/netbox/dcim/api/serializers_/device_components.py +++ b/netbox/dcim/api/serializers_/device_components.py @@ -6,7 +6,7 @@ from dcim.choices import * from dcim.constants import * from dcim.models import ( ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PowerOutlet, PowerPort, - RearPort, VirtualDeviceContext, + RearPort, VirtualDeviceContext, MACAddress, ) from ipam.api.serializers_.vlans import VLANSerializer from ipam.api.serializers_.vrfs import VRFSerializer @@ -33,6 +33,7 @@ __all__ = ( 'FrontPortSerializer', 'InterfaceSerializer', 'InventoryItemSerializer', + 'MACAddressSerializer', 'ModuleBaySerializer', 'PowerOutletSerializer', 'PowerPortSerializer', @@ -363,3 +364,11 @@ class InventoryItemSerializer(NetBoxModelSerializer): serializer = get_serializer_for_model(obj.component) context = {'request': self.context['request']} return serializer(obj.component, nested=True, context=context).data + + +class MACAddressSerializer(NetBoxModelSerializer): + + class Meta: + model = MACAddress + fields = ['mac_address',] + brief_fields = ('mac_address',) diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index ba3e6464b..5dda116f7 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -123,3 +123,13 @@ COMPATIBLE_TERMINATION_TYPES = { 'powerport': ['poweroutlet', 'powerfeed'], 'rearport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport', 'circuittermination'], } + + +# +# MAC addresses +# + +MACADDRESS_ASSIGNMENT_MODELS = Q( + Q(app_label='dcim', model='interface') | + Q(app_label='virtualization', model='vminterface') +) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 3f43afa40..64d152e2d 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 +from virtualization.models import Cluster, ClusterGroup, VMInterface from vpn.models import L2VPN from wireless.choices import WirelessRoleChoices, WirelessChannelChoices from wireless.models import WirelessLAN, WirelessLink @@ -52,6 +52,7 @@ __all__ = ( 'InventoryItemRoleFilterSet', 'InventoryItemTemplateFilterSet', 'LocationFilterSet', + 'MACAddressFilterSet', 'ManufacturerFilterSet', 'ModuleBayFilterSet', 'ModuleBayTemplateFilterSet', @@ -1098,10 +1099,10 @@ class DeviceFilterSet( field_name='device_type__is_full_depth', label=_('Is full depth'), ) - mac_address = MultiValueMACAddressFilter( - field_name='interfaces___mac_address', - label=_('MAC address'), - ) + # mac_address = MultiValueMACAddressFilter( + # field_name='interfaces___mac_address', + # label=_('MAC address'), + # ) serial = MultiValueCharFilter( lookup_expr='iexact' ) @@ -1598,6 +1599,154 @@ class PowerOutletFilterSet( ) +class MACAddressFilterSet(NetBoxModelFilterSet): + mac_address = MultiValueMACAddressFilter() + interface = django_filters.ModelMultipleChoiceFilter( + field_name='interface__name', + queryset=Interface.objects.all(), + to_field_name='name', + label=_('Interface (name)'), + ) + interface_id = django_filters.ModelMultipleChoiceFilter( + field_name='interface', + queryset=Interface.objects.all(), + label=_('Interface (ID)'), + ) + vminterface = django_filters.ModelMultipleChoiceFilter( + field_name='vminterface__name', + queryset=VMInterface.objects.all(), + to_field_name='name', + label=_('VM interface (name)'), + ) + vminterface_id = django_filters.ModelMultipleChoiceFilter( + field_name='vminterface', + queryset=VMInterface.objects.all(), + label=_('VM interface (ID)'), + ) + + # assigned_to_interface = django_filters.BooleanFilter( + # method='_assigned_to_interface', + # label=_('Is assigned to an interface'), + # ) + # assigned = django_filters.BooleanFilter( + # method='_assigned', + # label=_('Is assigned'), + # ) + + class Meta: + model = MACAddress + fields = ('id', 'description', 'interface', 'assigned_object_type', 'assigned_object_id') + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = ( + Q(mac_address__icontains=value) | + Q(description__icontains=value) + ) + return queryset.filter(qs_filter) + + def search_by_parent(self, queryset, name, value): + if not value: + return queryset + q = Q() + for prefix in value: + try: + query = str(netaddr.IPNetwork(prefix.strip()).cidr) + q |= Q(address__net_host_contained=query) + except (AddrFormatError, ValueError): + return queryset.none() + return queryset.filter(q) + + def parse_inet_addresses(self, value): + ''' + Parse networks or IP addresses and cast to a format + acceptable by the Postgres inet type. + + Skips invalid values. + ''' + parsed = [] + for addr in value: + if netaddr.valid_ipv4(addr) or netaddr.valid_ipv6(addr): + parsed.append(addr) + continue + try: + network = netaddr.IPNetwork(addr) + parsed.append(str(network)) + except (AddrFormatError, ValueError): + continue + return parsed + + def filter_address(self, queryset, name, value): + # Let's first parse the addresses passed + # as argument. If they are all invalid, + # we return an empty queryset + value = self.parse_inet_addresses(value) + if (len(value) == 0): + return queryset.none() + + try: + return queryset.filter(address__net_in=value) + except ValidationError: + return queryset.none() + + @extend_schema_field(OpenApiTypes.STR) + def filter_present_in_vrf(self, queryset, name, vrf): + if vrf is None: + return queryset.none + return queryset.filter( + Q(vrf=vrf) | + Q(vrf__export_targets__in=vrf.import_targets.all()) + ).distinct() + + def filter_device(self, queryset, name, value): + devices = Device.objects.filter(**{'{}__in'.format(name): value}) + if not devices.exists(): + return queryset.none() + interface_ids = [] + for device in devices: + interface_ids.extend(device.vc_interfaces().values_list('id', flat=True)) + return queryset.filter( + interface__in=interface_ids + ) + + def filter_virtual_machine(self, queryset, name, value): + virtual_machines = VirtualMachine.objects.filter(**{'{}__in'.format(name): value}) + if not virtual_machines.exists(): + return queryset.none() + interface_ids = [] + for vm in virtual_machines: + interface_ids.extend(vm.interfaces.values_list('id', flat=True)) + return queryset.filter( + vminterface__in=interface_ids + ) + + def _assigned_to_interface(self, queryset, name, value): + content_types = ContentType.objects.get_for_models(Interface, VMInterface).values() + if value: + return queryset.filter( + assigned_object_type__in=content_types, + assigned_object_id__isnull=False + ) + else: + return queryset.exclude( + assigned_object_type__in=content_types, + assigned_object_id__isnull=False + ) + + def _assigned(self, queryset, name, value): + if value: + return queryset.exclude( + assigned_object_type__isnull=True, + assigned_object_id__isnull=True + ) + else: + return queryset.filter( + assigned_object_type__isnull=True, + assigned_object_id__isnull=True + ) + + class CommonInterfaceFilterSet(django_filters.FilterSet): vlan_id = django_filters.CharFilter( method='filter_vlan_id', diff --git a/netbox/dcim/forms/bulk_create.py b/netbox/dcim/forms/bulk_create.py index 0ea3aee63..d2d3127f5 100644 --- a/netbox/dcim/forms/bulk_create.py +++ b/netbox/dcim/forms/bulk_create.py @@ -2,6 +2,7 @@ from django import forms from django.utils.translation import gettext_lazy as _ from dcim.models import * +from dcim.fields import MACAddressField from extras.models import Tag from netbox.forms.mixins import CustomFieldsMixin from utilities.forms import form_from_model @@ -15,6 +16,7 @@ __all__ = ( # 'FrontPortBulkCreateForm', 'InterfaceBulkCreateForm', 'InventoryItemBulkCreateForm', + 'MACAddressBulkCreateForm', 'ModuleBayBulkCreateForm', 'PowerOutletBulkCreateForm', 'PowerPortBulkCreateForm', @@ -76,6 +78,12 @@ class PowerOutletBulkCreateForm( field_order = ('name', 'label', 'type', 'feed_leg', 'description', 'tags') +class MACAddressBulkCreateForm(forms.Form): + pattern = MACAddressField( + # label=_('Address pattern') + ) + + class InterfaceBulkCreateForm( form_from_model(Interface, [ 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected', 'poe_mode', 'poe_type', 'rf_role' diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 546d282be..98f491ba7 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -38,6 +38,7 @@ __all__ = ( 'InventoryItemRoleBulkEditForm', 'InventoryItemTemplateBulkEditForm', 'LocationBulkEditForm', + 'MACAddressBulkEditForm', 'ManufacturerBulkEditForm', 'ModuleBulkEditForm', 'ModuleBayBulkEditForm', @@ -1389,10 +1390,28 @@ class PowerOutletBulkEditForm( self.fields['power_port'].widget.attrs['disabled'] = True +class MACAddressBulkEditForm(NetBoxModelBulkEditForm): + description = forms.CharField( + label=_('Description'), + max_length=200, + required=False + ) + comments = CommentField() + + model = MACAddress + fieldsets = ( + FieldSet('description'), + # FieldSet('vrf', 'mask_length', 'dns_name', name=_('Addressing')), + ) + nullable_fields = ( + 'description', 'comments', + ) + + class InterfaceBulkEditForm( ComponentBulkEditForm, form_from_model(Interface, [ - 'label', 'type', 'parent', 'bridge', 'lag', 'speed', 'duplex', '_mac_address', 'wwn', 'mtu', 'mgmt_only', + 'label', 'type', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans' ]) diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 8be9a5c44..b126460ae 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -17,7 +17,7 @@ from utilities.forms.fields import ( CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, CSVTypedChoiceField, SlugField, ) -from virtualization.models import Cluster +from virtualization.models import Cluster, VMInterface, VirtualMachine from wireless.choices import WirelessRoleChoices from .common import ModuleCommonForm @@ -34,6 +34,7 @@ __all__ = ( 'InventoryItemImportForm', 'InventoryItemRoleImportForm', 'LocationImportForm', + 'MACAddressImportForm', 'ManufacturerImportForm', 'ModuleImportForm', 'ModuleBayImportForm', @@ -824,6 +825,98 @@ class PowerOutletImportForm(NetBoxModelImportForm): self.fields['power_port'].queryset = PowerPort.objects.none() +class MACAddressImportForm(NetBoxModelImportForm): + device = CSVModelChoiceField( + label=_('Device'), + queryset=Device.objects.all(), + required=False, + to_field_name='name', + help_text=_('Parent device of assigned interface (if any)') + ) + virtual_machine = CSVModelChoiceField( + label=_('Virtual machine'), + queryset=VirtualMachine.objects.all(), + required=False, + to_field_name='name', + help_text=_('Parent VM of assigned interface (if any)') + ) + interface = CSVModelChoiceField( + label=_('Interface'), + queryset=Interface.objects.none(), # Can also refer to VMInterface + required=False, + to_field_name='name', + help_text=_('Assigned interface') + ) + is_primary = forms.BooleanField( + label=_('Is primary'), + help_text=_('Make this the primary MAC for the assigned interface'), + required=False + ) + + class Meta: + model = MACAddress + fields = [ + 'mac_address', 'device', 'virtual_machine', 'interface', 'is_primary', + 'description', 'comments', 'tags', + ] + + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) + + if data: + + # Limit interface queryset by assigned device + if data.get('device'): + self.fields['interface'].queryset = Interface.objects.filter( + **{f"device__{self.fields['device'].to_field_name}": data['device']} + ) + + # Limit interface queryset by assigned device + elif data.get('virtual_machine'): + self.fields['interface'].queryset = VMInterface.objects.filter( + **{f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data['virtual_machine']} + ) + + def clean(self): + super().clean() + + 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 + # TODO: scope to interface rather than device/VM + if is_primary and not device and not virtual_machine: + raise forms.ValidationError({ + "is_primary": _("No device or virtual machine specified; cannot set as primary MAC") + }) + if is_primary and not interface: + raise forms.ValidationError({ + "is_primary": _("No interface specified; cannot set as primary MAC") + }) + + def save(self, *args, **kwargs): + + # Set interface assignment + if self.cleaned_data.get('interface'): + self.instance.assigned_object = self.cleaned_data['interface'] + + mac_address = super().save(*args, **kwargs) + + # Set as primary for device/VM + # TODO: set as primary for interface + # if self.cleaned_data.get('is_primary'): + # parent = self.cleaned_data.get('device') or self.cleaned_data.get('virtual_machine') + # if self.instance.address.version == 4: + # parent.primary_ip4 = ipaddress + # elif self.instance.address.version == 6: + # parent.primary_ip6 = ipaddress + # parent.save() + + return mac_address + + class InterfaceImportForm(NetBoxModelImportForm): device = CSVModelChoiceField( label=_('Device'), @@ -906,7 +999,7 @@ class InterfaceImportForm(NetBoxModelImportForm): model = Interface fields = ( 'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled', - 'mark_connected', '_mac_address', 'wwn', 'vdcs', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode', + 'mark_connected', 'wwn', 'vdcs', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode', 'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'tags' ) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 13478263e..cdb7e2a71 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -34,6 +34,7 @@ __all__ = ( 'InventoryItemFilterForm', 'InventoryItemRoleFilterForm', 'LocationFilterForm', + 'MACAddressFilterForm', 'ManufacturerFilterForm', 'ModuleFilterForm', 'ModuleBayFilterForm', @@ -1324,6 +1325,20 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): ) +class MACAddressFilterForm(NetBoxModelFilterSetForm): + model = MACAddress + fieldsets = ( + FieldSet('q', 'filter_id', 'tag'), + FieldSet('mac_address', name=_('Addressing')), + ) + selector_fields = ('filter_id', 'q', 'device_id') + mac_address = forms.CharField( + required=False, + label=_('MAC address') + ) + tag = TagFilterField(model) + + class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = Interface fieldsets = ( diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 095882d13..61b665d51 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -1,5 +1,6 @@ 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 @@ -17,7 +18,7 @@ from utilities.forms.fields import ( ) from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, NumberWithOptions, SelectWithPK -from virtualization.models import Cluster +from virtualization.models import Cluster, VMInterface from wireless.models import WirelessLAN, WirelessLANGroup from .common import InterfaceCommonForm, ModuleCommonForm @@ -41,6 +42,8 @@ __all__ = ( 'InventoryItemRoleForm', 'InventoryItemTemplateForm', 'LocationForm', + 'MACAddressForm', + 'MACAddressBulkAddForm', 'ManufacturerForm', 'ModuleForm', 'ModuleBayForm', @@ -1298,6 +1301,120 @@ class PowerOutletForm(ModularDeviceComponentForm): ] +class MACAddressForm(NetBoxModelForm): + mac_address = forms.CharField( + required=True, + label=_('MAC address') + ) + interface = DynamicModelChoiceField( + label=_('Interface'), + queryset=Interface.objects.all(), + required=False, + ) + vminterface = DynamicModelChoiceField( + label=_('VM Interface'), + queryset=VMInterface.objects.all(), + required=False, + ) + is_primary = forms.BooleanField( + required=False, + label=_('Primary for interface'), + ) + + fieldsets = ( + FieldSet( + 'mac_address', 'description', 'tags', + ), + FieldSet( + TabbedGroups( + 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', + ] + + def __init__(self, *args, **kwargs): + + # Initialize helper selectors + instance = kwargs.get('instance') + initial = kwargs.get('initial', {}).copy() + if instance: + if type(instance.assigned_object) is Interface: + initial['interface'] = instance.assigned_object + elif type(instance.assigned_object) is VMInterface: + initial['vminterface'] = instance.assigned_object + kwargs['initial'] = initial + + super().__init__(*args, **kwargs) + + # Initialize primary_for_parent if IP address is already assigned + if self.instance.pk and self.instance.assigned_object: + # parent = getattr(self.instance.assigned_object, 'parent_object', None) + # if parent and ( + # self.instance.address.version == 4 and parent.primary_ip4_id == self.instance.pk or + # self.instance.address.version == 6 and parent.primary_ip6_id == self.instance.pk + # ): + # self.initial['primary_for_parent'] = True + + if type(instance.assigned_object) is Interface: + self.fields['interface'].widget.add_query_params({ + 'device_id': instance.assigned_object.device.pk, + }) + elif type(instance.assigned_object) is VMInterface: + self.fields['vminterface'].widget.add_query_params({ + 'virtual_machine_id': instance.assigned_object.virtual_machine.pk, + }) + + # Disable object assignment fields if the IP address is designated as primary + if self.initial.get('primary_for_parent'): + self.fields['interface'].disabled = True + self.fields['vminterface'].disabled = True + + def clean(self): + super().clean() + + # Handle object assignment + selected_objects = [ + field for field in ('interface', 'vminterface') if self.cleaned_data[field] + ] + if len(selected_objects) > 1: + raise forms.ValidationError({ + 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.cleaned_data['is_primary'] and assigned_object != self.instance.assigned_object: + raise ValidationError( + _("Cannot reassign MAC address while it is designated as the primary MAC for the interface") + ) + self.instance.assigned_object = assigned_object + else: + self.instance.assigned_object = None + + # Primary MAC 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.") + ) + + +class MACAddressBulkAddForm(NetBoxModelForm): + + class Meta: + model = MACAddress + fields = [ + 'mac_address', 'description', 'tags', + ] + + class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): vdcs = DynamicModelMultipleChoiceField( queryset=VirtualDeviceContext.objects.all(), diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index d18c7ed14..31e367c3d 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -23,6 +23,7 @@ __all__ = ( 'InventoryItemCreateForm', 'InventoryItemTemplateCreateForm', 'ModuleBayCreateForm', + # 'MACAddressCreateForm', 'ModuleBayTemplateCreateForm', 'PowerOutletCreateForm', 'PowerOutletTemplateCreateForm', @@ -238,6 +239,12 @@ class PowerOutletCreateForm(ComponentCreateForm, model_forms.PowerOutletForm): exclude = ('name', 'label') +# class MACAddressCreateForm(ComponentCreateForm, model_forms.MACAddressForm): +# +# class Meta(model_forms.MACAddressForm.Meta): +# exclude = ('name', 'label') + + class InterfaceCreateForm(ComponentCreateForm, model_forms.InterfaceForm): class Meta(model_forms.InterfaceForm.Meta): diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 75c927ffd..e0f78eeb2 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -377,7 +377,7 @@ class FrontPortTemplateType(ModularComponentTemplateType): filters=InterfaceFilter ) class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, PathEndpointMixin): - _mac_address: str | None + # _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 diff --git a/netbox/dcim/migrations/0195_rename_mac_address_interface__mac_address_macaddress.py b/netbox/dcim/migrations/0195_rename_mac_address_interface__mac_address_macaddress.py index fb9f199f0..62758e27e 100644 --- a/netbox/dcim/migrations/0195_rename_mac_address_interface__mac_address_macaddress.py +++ b/netbox/dcim/migrations/0195_rename_mac_address_interface__mac_address_macaddress.py @@ -11,6 +11,9 @@ def populate_macaddress_objects(apps, schema_editor): Interface = apps.get_model('dcim', 'Interface') VMInterface = apps.get_model('virtualization', 'VMInterface') MACAddress = apps.get_model('dcim', 'MACAddress') + ContentType = apps.get_model('contenttypes', 'ContentType') + interface_ct = ContentType.objects.get_for_model(Interface) + vminterface_ct = ContentType.objects.get_for_model(VMInterface) mac_addresses = [] print() print('Converting MAC addresses...') @@ -18,14 +21,18 @@ def populate_macaddress_objects(apps, schema_editor): mac_addresses.append( MACAddress( mac_address=interface._mac_address, - interface=interface, + assigned_object_type=interface_ct, + assigned_object_id=interface.id, + # interface=interface, ) ) - for vm_interface in VMInterface.objects.filter(_mac_address__isnull=False): + for vminterface in VMInterface.objects.filter(_mac_address__isnull=False): mac_addresses.append( MACAddress( - mac_address=vm_interface._mac_address, - vm_interface=vm_interface, + mac_address=vminterface._mac_address, + assigned_object_type=vminterface_ct, + assigned_object_id=vminterface.id, + # vm_interface=vm_interface, ) ) MACAddress.objects.bulk_create(mac_addresses) @@ -57,9 +64,11 @@ class Migration(migrations.Migration): ('comments', models.TextField(blank=True)), ('mac_address', dcim.fields.MACAddressField(blank=True, null=True)), ('is_primary', models.BooleanField(default=False)), - ('interface', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='dcim.interface')), + # ('interface', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='dcim.interface')), + ('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')), - ('vm_interface', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='virtualization.vminterface')), + # ('vm_interface', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='virtualization.vminterface')), ], options={ 'abstract': False, diff --git a/netbox/dcim/migrations/0196_remove_interface__mac_address.py b/netbox/dcim/migrations/0196_remove_interface__mac_address.py new file mode 100644 index 000000000..2e0ffc466 --- /dev/null +++ b/netbox/dcim/migrations/0196_remove_interface__mac_address.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.2 on 2024-10-29 15:41 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0195_rename_mac_address_interface__mac_address_macaddress'), + ] + + operations = [ + migrations.RemoveField( + model_name='interface', + name='_mac_address', + ), + ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index d635219f6..08b81795b 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -510,11 +510,6 @@ class BaseInterface(models.Model): verbose_name=_('enabled'), default=True ) - _mac_address = MACAddressField( - null=True, - blank=True, - verbose_name=_('MAC address') - ) mtu = models.PositiveIntegerField( blank=True, null=True, @@ -578,7 +573,7 @@ class BaseInterface(models.Model): @property def mac_address(self): - if macaddress := self.macaddress_set.first(): + if macaddress := self.mac_addresses.order_by('-is_primary').first(): return macaddress.mac_address return None @@ -725,6 +720,12 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd object_id_field='assigned_object_id', related_query_name='interface' ) + mac_addresses = GenericRelation( + to='dcim.MACAddress', + content_type_field='assigned_object_type', + object_id_field='assigned_object_id', + related_query_name='interface' + ) fhrp_group_assignments = GenericRelation( to='ipam.FHRPGroupAssignment', content_type_field='interface_type', @@ -1338,21 +1339,44 @@ class MACAddress(PrimaryModel): blank=True, verbose_name=_('MAC address') ) - interface = models.ForeignKey( - to='dcim.Interface', + # interface = models.ForeignKey( + # to='dcim.Interface', + # on_delete=models.PROTECT, + # null=True, + # blank=True, + # verbose_name=_('Interface') + # ) + # vm_interface = models.ForeignKey( + # to='virtualization.VMInterface', + # on_delete=models.PROTECT, + # null=True, + # blank=True, + # verbose_name=_('VM Interface') + # ) + assigned_object_type = models.ForeignKey( + to='contenttypes.ContentType', + limit_choices_to=MACADDRESS_ASSIGNMENT_MODELS, on_delete=models.PROTECT, - null=True, + related_name='+', blank=True, - verbose_name=_('Interface') + null=True ) - vm_interface = models.ForeignKey( - to='virtualization.VMInterface', - on_delete=models.PROTECT, - null=True, + assigned_object_id = models.PositiveBigIntegerField( blank=True, - verbose_name=_('VM Interface') + null=True + ) + assigned_object = GenericForeignKey( + ct_field='assigned_object_type', + fk_field='assigned_object_id' ) is_primary = models.BooleanField( verbose_name=_('is primary for interface'), default=False ) + + class Meta: + verbose_name = _('MAC address') + verbose_name_plural = _('MAC addresses') + + def __str__(self): + return str(self.mac_address) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index b39a2b87f..8ecff63c9 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -29,6 +29,7 @@ __all__ = ( 'InterfaceTable', 'InventoryItemRoleTable', 'InventoryItemTable', + 'MACAddressTable', 'ModuleBayTable', 'PlatformTable', 'PowerOutletTable', @@ -593,6 +594,40 @@ class BaseInterfaceTable(NetBoxTable): return ",".join([str(obj) for obj in value.all()]) +class MACAddressTable(NetBoxTable): + mac_address = tables.Column( + verbose_name=_('MAC Address'), + linkify=True + ) + assigned_object = tables.Column( + linkify=True, + orderable=False, + verbose_name=_('Interface') + ) + is_primary = columns.BooleanColumn( + verbose_name=_('Primary MAC'), + false_mark=None + ) + # interface = tables.Column( + # verbose_name=_('Interface'), + # linkify=True + # ) + # vm_interface = tables.Column( + # verbose_name=_('VM Interface'), + # linkify=True + # ) + tags = columns.TagColumn( + url_name='dcim:macaddress_list' + ) + + class Meta(DeviceComponentTable.Meta): + model = models.MACAddress + fields = ( + 'pk', 'id', 'mac_address', 'assigned_object', 'created', 'last_updated', 'is_primary' + ) + default_columns = ('pk', 'mac_address', 'assigned_object', 'is_primary') + + class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpointTable): device = tables.Column( verbose_name=_('Device'), diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 627136bf9..d472b3d24 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -250,6 +250,16 @@ urlpatterns = [ path('power-outlets//', include(get_model_urls('dcim', 'poweroutlet'))), path('devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'), + # MAC addresses + path('mac-addresses/', views.MACAddressListView.as_view(), name='macaddress_list'), + path('mac-addresses/add/', views.MACAddressEditView.as_view(), name='macaddress_add'), + path('mac-addresses/bulk-add/', views.MACAddressBulkCreateView.as_view(), name='macaddress_bulk_add'), + path('mac-addresses/import/', views.MACAddressBulkImportView.as_view(), name='macaddress_import'), + path('mac-addresses/edit/', views.MACAddressBulkEditView.as_view(), name='macaddress_bulk_edit'), + path('mac-addresses/rename/', views.MACAddressBulkRenameView.as_view(), name='macaddress_bulk_rename'), + path('mac-addresses/delete/', views.MACAddressBulkDeleteView.as_view(), name='macaddress_bulk_delete'), + path('mac-addresses//', include(get_model_urls('dcim', 'macaddress'))), + # Interfaces path('interfaces/', views.InterfaceListView.as_view(), name='interface_list'), path('interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 98665a7a0..90fc15843 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2519,6 +2519,78 @@ class PowerOutletBulkDeleteView(generic.BulkDeleteView): register_model_view(PowerOutlet, 'trace', kwargs={'model': PowerOutlet})(PathTraceView) +# +# MAC addresses +# + +class MACAddressListView(generic.ObjectListView): + queryset = MACAddress.objects.all() + filterset = filtersets.MACAddressFilterSet + filterset_form = forms.MACAddressFilterForm + table = tables.MACAddressTable + template_name = 'dcim/component_list.html' + actions = { + **DEFAULT_ACTION_PERMISSIONS, + 'bulk_rename': {'change'}, + } + + +@register_model_view(MACAddress) +class MACAddressView(generic.ObjectView): + queryset = MACAddress.objects.all() + + +# class MACAddressCreateView(generic.ComponentCreateView): +# queryset = MACAddress.objects.all() +# form = forms.MACAddressForm +# model_form = forms.MACAddressForm + + +@register_model_view(MACAddress, 'edit') +class MACAddressEditView(generic.ObjectEditView): + queryset = MACAddress.objects.all() + form = forms.MACAddressForm + + +@register_model_view(MACAddress, 'delete') +class MACAddressDeleteView(generic.ObjectDeleteView): + queryset = MACAddress.objects.all() + + +class MACAddressBulkCreateView(generic.BulkCreateView): + queryset = MACAddress.objects.all() + form = forms.MACAddressBulkCreateForm + model_form = forms.MACAddressBulkAddForm + pattern_target = 'mac_address' + template_name = 'dcim/macaddress_bulk_add.html' + + +class MACAddressBulkImportView(generic.BulkImportView): + queryset = MACAddress.objects.all() + model_form = forms.MACAddressImportForm + + +class MACAddressBulkEditView(generic.BulkEditView): + queryset = MACAddress.objects.all() + filterset = filtersets.MACAddressFilterSet + table = tables.MACAddressTable + form = forms.MACAddressBulkEditForm + + +class MACAddressBulkRenameView(generic.BulkRenameView): + queryset = MACAddress.objects.all() + + +class MACAddressBulkDisconnectView(BulkDisconnectView): + queryset = MACAddress.objects.all() + + +class MACAddressBulkDeleteView(generic.BulkDeleteView): + queryset = MACAddress.objects.all() + filterset = filtersets.MACAddressFilterSet + table = tables.MACAddressTable + + # # Interfaces # @@ -2552,7 +2624,7 @@ class InterfaceView(generic.ObjectView): # Get bridge interfaces bridge_interfaces = Interface.objects.restrict(request.user, 'view').filter(bridge=instance) - bridge_interfaces_tables = tables.InterfaceTable( + bridge_interfaces_table = tables.InterfaceTable( bridge_interfaces, exclude=('device', 'parent'), orderable=False @@ -2560,12 +2632,19 @@ class InterfaceView(generic.ObjectView): # Get child interfaces child_interfaces = Interface.objects.restrict(request.user, 'view').filter(parent=instance) - child_interfaces_tables = tables.InterfaceTable( + child_interfaces_table = tables.InterfaceTable( child_interfaces, exclude=('device', 'parent'), orderable=False ) + # Get MAC addresses + mac_addresses_table = tables.MACAddressTable( + data=instance.mac_addresses, + exclude=('assigned_object',), + orderable=False + ) + # Get assigned VLANs and annotate whether each is tagged or untagged vlans = [] if instance.untagged_vlan is not None: @@ -2582,8 +2661,9 @@ class InterfaceView(generic.ObjectView): return { 'vdc_table': vdc_table, - 'bridge_interfaces_table': bridge_interfaces_tables, - 'child_interfaces_table': child_interfaces_tables, + 'bridge_interfaces_table': bridge_interfaces_table, + 'child_interfaces_table': child_interfaces_table, + 'mac_addresses_table': mac_addresses_table, 'vlan_table': vlan_table, } diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 9d8ffaaf8..76ca29909 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -91,6 +91,7 @@ DEVICES_MENU = Menu( MenuGroup( label=_('Device Components'), items=( + get_model_item('dcim', 'macaddress', _('MAC Addresses')), get_model_item('dcim', 'interface', _('Interfaces')), get_model_item('dcim', 'frontport', _('Front Ports')), get_model_item('dcim', 'rearport', _('Rear Ports')), diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 016a6c890..591563abb 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -346,7 +346,23 @@ {% endif %} {% htmx_table 'ipam:ipaddress_list' interface_id=object.pk %} - + + + +
+
+
+

+ {% trans "MAC Addresses" %} + {% if perms.ipam.add_macaddress %} + + {% endif %} +

+ {% htmx_table 'dcim:macaddress_list' interface_id=object.pk %}
diff --git a/netbox/templates/dcim/macaddress.html b/netbox/templates/dcim/macaddress.html new file mode 100644 index 000000000..b0cf1bb62 --- /dev/null +++ b/netbox/templates/dcim/macaddress.html @@ -0,0 +1,50 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load render_table from django_tables2 %} +{% load i18n %} + +{% block content %} +
+
+
+

{% trans "MAC Address" %}

+ + + + + + + + + + + + + +
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Assignment" %} + {% if object.assigned_object %} + {% if object.assigned_object.parent_object %} + {{ object.assigned_object.parent_object|linkify }} / + {% endif %} + {{ object.assigned_object|linkify }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
{% trans "Primary MAC for Interface" %}{% checkmark object.is_primary %}
+
+ {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% plugin_left_page object %} +
+
+ {% include 'inc/panels/comments.html' %} + {% plugin_right_page object %} +
+
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/templates/dcim/macaddress_bulk_add.html b/netbox/templates/dcim/macaddress_bulk_add.html new file mode 100644 index 000000000..d29c6e718 --- /dev/null +++ b/netbox/templates/dcim/macaddress_bulk_add.html @@ -0,0 +1,30 @@ +{% extends 'generic/object_edit.html' %} +{% load static %} +{% load form_helpers %} +{% load i18n %} + +{% block title %}{% trans "Bulk Add MAC Addresses" %}{% endblock %} + +{% block tabs %} + {% include 'ipam/inc/ipaddress_edit_header.html' with active_tab='bulk_add' %} +{% endblock %} + +{% block form %} +
+
+

{% trans "MAC Addresses" %}

+
+ {% render_field form.pattern %} + {% render_field model_form.description %} + {% render_field model_form.tags %} +
+ + {% if model_form.custom_fields %} +
+
+

{% trans "Custom Fields" %}

+
+ {% render_custom_fields model_form %} +
+ {% endif %} +{% endblock %} diff --git a/netbox/templates/virtualization/vminterface.html b/netbox/templates/virtualization/vminterface.html index 0d679680d..c52b0025d 100644 --- a/netbox/templates/virtualization/vminterface.html +++ b/netbox/templates/virtualization/vminterface.html @@ -95,6 +95,24 @@ +
+
+
+

+ {% trans "MAC Addresses" %} + {% if perms.ipam.add_macaddress %} + + {% endif %} +

+ {% htmx_table 'dcim:macaddress_list' vminterface_id=object.pk %} +
+
+
{% include 'inc/panel_table.html' with table=vlan_table heading="VLANs" %} diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index 699661c0c..17f574db9 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -9,7 +9,7 @@ from extras.models import ConfigTemplate from ipam.filtersets import PrimaryIPFilterSet from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet -from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter +from utilities.filters import MultiValueCharFilter, TreeNodeMultipleChoiceFilter from .choices import * from .models import * @@ -225,10 +225,10 @@ class VirtualMachineFilterSet( to_field_name='slug', label=_('Platform (slug)'), ) - mac_address = MultiValueMACAddressFilter( - field_name='interfaces___mac_address', - label=_('MAC address'), - ) + # mac_address = MultiValueMACAddressFilter( + # field_name='interfaces___mac_address', + # label=_('MAC address'), + # ) has_primary_ip = django_filters.BooleanFilter( method='_has_primary_ip', label=_('Has a primary IP'), @@ -297,9 +297,9 @@ class VMInterfaceFilterSet(NetBoxModelFilterSet, CommonInterfaceFilterSet): queryset=VMInterface.objects.all(), label=_('Bridged interface (ID)'), ) - _mac_address = MultiValueMACAddressFilter( - label=_('MAC address'), - ) + # _mac_address = MultiValueMACAddressFilter( + # label=_('MAC address'), + # ) class Meta: model = VMInterface diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index 68d41a645..bc68306f6 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -178,7 +178,7 @@ class VMInterfaceImportForm(NetBoxModelImportForm): class Meta: model = VMInterface fields = ( - 'virtual_machine', 'name', 'parent', 'bridge', 'enabled', '_mac_address', 'mtu', 'description', 'mode', + 'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mtu', 'description', 'mode', 'vrf', 'tags' ) diff --git a/netbox/virtualization/graphql/types.py b/netbox/virtualization/graphql/types.py index 663fa6bd4..2b8833ffe 100644 --- a/netbox/virtualization/graphql/types.py +++ b/netbox/virtualization/graphql/types.py @@ -95,7 +95,7 @@ class VirtualMachineType(ConfigContextMixin, ContactsMixin, NetBoxObjectType): filters=VMInterfaceFilter ) class VMInterfaceType(IPAddressesMixin, ComponentType): - _mac_address: str | None + # _mac_address: str | None parent: 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 diff --git a/netbox/virtualization/migrations/0043_remove_vminterface__mac_address.py b/netbox/virtualization/migrations/0043_remove_vminterface__mac_address.py new file mode 100644 index 000000000..39017104b --- /dev/null +++ b/netbox/virtualization/migrations/0043_remove_vminterface__mac_address.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.2 on 2024-10-29 15:41 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0042_rename_mac_address_vminterface__mac_address'), + ] + + operations = [ + migrations.RemoveField( + model_name='vminterface', + name='_mac_address', + ), + ] diff --git a/netbox/virtualization/models/virtualmachines.py b/netbox/virtualization/models/virtualmachines.py index 0767b2c13..3a35d5021 100644 --- a/netbox/virtualization/models/virtualmachines.py +++ b/netbox/virtualization/models/virtualmachines.py @@ -368,6 +368,12 @@ class VMInterface(ComponentModel, BaseInterface, TrackingModelMixin): object_id_field='assigned_object_id', related_query_name='vminterface', ) + mac_addresses = GenericRelation( + to='dcim.MACAddress', + content_type_field='assigned_object_type', + object_id_field='assigned_object_id', + related_query_name='vminterface' + ) class Meta(ComponentModel.Meta): verbose_name = _('interface')