4867 multiple mac addresses (#17902)

* Create MACAddress model and migrations to convert existing .mac_address fields to standalone objects

* Add migrations

* All views/filtering working and documentation done; no unit tests yet

* Redo migrations following VLAN Translation

* Remove mac_address filter fields and add table columns for device/vm

* Remove unnecessary "bulk rename"

* Fix filterset tests for Device

* Fix filterset tests for Interface

* Fix tests on single-object forms

* Fix serializer tests

* Fix filterset tests for VMInterface

* Fix filterset tests for Device and VirtualMachine

* Move new field check into lookup_map iteration

* Fix general MACAddress filter tests

* Add GraphQL types/filters/schema

* Fix bulk edit/create tests (bulk editing Interfaces will be unsupported because of inheritance from ComponentBulkEditForm)

* Make mac_address read_only on InterfaceSerializer/VMInterfaceSerializer

* Undo unrelated work

* Cleanup unused IPAddress derived stuff

* API endpoints

* Add serializer objects to interface serializers

* Clean up unnecessary bulk create forms/views/routes

* Add SearchIndex and adjust indexable fields for Interface and VMInterface

* Reorganize MACAddress classes out of association with DeviceComponents

* Move MACAddressSerializer

* Enforce saving only a single is_primary MACAddress per interface/vminterface

* Perform is_primary validation on MACAddress model and just check if one already exists for the interface

* Remove form-level validation

* Fix check for current is_primary setting when reassigning

* Model cleanup

* Documentation notes and cleanup

* Simplify serializer and add ip_addresses

* Add to VMInterfaceSerializer too

* Style cleanup

* Standardize "MAC Address" instead of "MAC"

* Remove unused views

* Add is_primary field for bulk edit

* HTML cleanup and add copy-to-clipboard button

* Remove mac_address from Interface and VMInterface bulk-edit forms

* Add device and VM filtering

* Use combined assigned_object_parent in table to match structure of IPAddressTable

* Add GFK fields to MACAddressSerializer

* Reorganize "Addressing" sections to remove from proximity to "Device Components" and related groupings

* Clean up migrations

* Misc cleanup

* Add filterset test

* Remove mac_address field from interface forms

* Designate primary MAC address via a ForeignKey on the interface models

* Add serializer fields for primary_mac_address

* Update docs

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
This commit is contained in:
bctiemann 2024-11-18 15:11:24 -05:00 committed by GitHub
parent b4f15092db
commit 353214098b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 1156 additions and 173 deletions

View File

@ -45,9 +45,12 @@ The operation duplex (full, half, or auto).
The [virtual routing and forwarding](../ipam/vrf.md) instance to which this interface is assigned. The [virtual routing and forwarding](../ipam/vrf.md) instance to which this interface is assigned.
### MAC Address ### Primary MAC Address
The 48-bit MAC address (for Ethernet interfaces). The [MAC address](./macaddress.md) assigned to this interface which is designated as its primary.
!!! note "Changed in NetBox v4.2"
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.
### WWN ### WWN

View File

@ -0,0 +1,11 @@
# MAC Addresses
A MAC address object in NetBox comprises a single Ethernet link layer address, and represents a MAC address as reported by or assigned to a network interface. MAC addresses can be assigned to [device](../dcim/device.md) and [virtual machine](../virtualization/virtualmachine.md) interfaces. A MAC address can be specified as the primary MAC address for a given device or VM interface.
Most interfaces have only a single MAC address, hard-coded at the factory. However, on some devices (particularly virtual interfaces) it is possible to assign additional MAC addresses or change existing ones. For this reason NetBox allows multiple MACAddress objects to be assigned to a single interface.
## Fields
### MAC Address
The 48-bit MAC address, in colon-hexadecimal notation (e.g. `aa:bb:cc:11:22:33`).

View File

@ -27,9 +27,12 @@ An interface on the same VM with which this interface is bridged.
If not selected, this interface will be treated as disabled/inoperative. If not selected, this interface will be treated as disabled/inoperative.
### MAC Address ### Primary MAC Address
The 48-bit MAC address (for Ethernet interfaces). The [MAC address](./macaddress.md) assigned to this interface which is designated as its primary.
!!! note "Changed in NetBox v4.2"
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.
### MTU ### MTU

View File

@ -21,7 +21,7 @@ from wireless.choices import *
from wireless.models import WirelessLAN from wireless.models import WirelessLAN
from .base import ConnectedEndpointsSerializer from .base import ConnectedEndpointsSerializer
from .cables import CabledObjectSerializer from .cables import CabledObjectSerializer
from .devices import DeviceSerializer, ModuleSerializer, VirtualDeviceContextSerializer from .devices import DeviceSerializer, MACAddressSerializer, ModuleSerializer, VirtualDeviceContextSerializer
from .manufacturers import ManufacturerSerializer from .manufacturers import ManufacturerSerializer
from .nested import NestedInterfaceSerializer from .nested import NestedInterfaceSerializer
from .roles import InventoryItemRoleSerializer from .roles import InventoryItemRoleSerializer
@ -210,24 +210,23 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
) )
count_ipaddresses = serializers.IntegerField(read_only=True) count_ipaddresses = serializers.IntegerField(read_only=True)
count_fhrp_groups = serializers.IntegerField(read_only=True) count_fhrp_groups = serializers.IntegerField(read_only=True)
mac_address = serializers.CharField( # Maintains backward compatibility with NetBox <v4.2
required=False, mac_address = serializers.CharField(allow_null=True, read_only=True)
default=None, primary_mac_address = MACAddressSerializer(nested=True, required=False, allow_null=True)
allow_blank=True, mac_addresses = MACAddressSerializer(many=True, nested=True, read_only=True, allow_null=True)
allow_null=True
)
wwn = serializers.CharField(required=False, default=None, allow_blank=True, allow_null=True) wwn = serializers.CharField(required=False, default=None, allow_blank=True, allow_null=True)
class Meta: class Meta:
model = Interface model = Interface
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'enabled', 'id', 'url', 'display_url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'enabled',
'parent', 'bridge', 'lag', 'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'parent', 'bridge', 'lag', 'mtu', 'mac_address', 'primary_mac_address', 'mac_addresses', 'speed', 'duplex',
'mode', 'rf_role', 'rf_channel', 'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'poe_mode', 'poe_type',
'tx_power', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'mark_connected', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan',
'cable', 'cable_end', 'wireless_link', 'link_peers', 'link_peers_type', 'wireless_lans', 'vrf', 'vlan_translation_policy', 'mark_connected', 'cable', 'cable_end', 'wireless_link', 'link_peers',
'l2vpn_termination', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints',
'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
] ]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied') brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')

View File

@ -1,16 +1,19 @@
import decimal import decimal
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from drf_spectacular.utils import extend_schema_field from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers from rest_framework import serializers
from dcim.choices import * from dcim.choices import *
from dcim.models import Device, DeviceBay, Module, VirtualDeviceContext from dcim.constants import MACADDRESS_ASSIGNMENT_MODELS
from dcim.models import Device, DeviceBay, MACAddress, Module, VirtualDeviceContext
from extras.api.serializers_.configtemplates import ConfigTemplateSerializer from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
from ipam.api.serializers_.ip import IPAddressSerializer from ipam.api.serializers_.ip import IPAddressSerializer
from netbox.api.fields import ChoiceField, RelatedObjectCountField from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField
from netbox.api.serializers import NetBoxModelSerializer from netbox.api.serializers import NetBoxModelSerializer
from tenancy.api.serializers_.tenants import TenantSerializer from tenancy.api.serializers_.tenants import TenantSerializer
from utilities.api import get_serializer_for_model
from virtualization.api.serializers_.clusters import ClusterSerializer from virtualization.api.serializers_.clusters import ClusterSerializer
from .devicetypes import * from .devicetypes import *
from .platforms import PlatformSerializer from .platforms import PlatformSerializer
@ -23,6 +26,7 @@ from .virtualchassis import VirtualChassisSerializer
__all__ = ( __all__ = (
'DeviceSerializer', 'DeviceSerializer',
'DeviceWithConfigContextSerializer', 'DeviceWithConfigContextSerializer',
'MACAddressSerializer',
'ModuleSerializer', 'ModuleSerializer',
'VirtualDeviceContextSerializer', 'VirtualDeviceContextSerializer',
) )
@ -153,3 +157,28 @@ class ModuleSerializer(NetBoxModelSerializer):
'asset_tag', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'asset_tag', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'device', 'module_bay', 'module_type', 'description') brief_fields = ('id', 'url', 'display', 'device', 'module_bay', 'module_type', 'description')
class MACAddressSerializer(NetBoxModelSerializer):
assigned_object_type = ContentTypeField(
queryset=ContentType.objects.filter(MACADDRESS_ASSIGNMENT_MODELS),
required=False,
allow_null=True
)
assigned_object = serializers.SerializerMethodField(read_only=True)
class Meta:
model = MACAddress
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):
if obj.assigned_object is None:
return None
serializer = get_serializer_for_model(obj.assigned_object)
context = {'request': self.context['request']}
return serializer(obj.assigned_object, nested=True, context=context).data

View File

@ -56,6 +56,9 @@ router.register('inventory-items', views.InventoryItemViewSet)
# Device component roles # Device component roles
router.register('inventory-item-roles', views.InventoryItemRoleViewSet) router.register('inventory-item-roles', views.InventoryItemRoleViewSet)
# Addressing
router.register('mac-addresses', views.MACAddressViewSet)
# Cables # Cables
router.register('cables', views.CableViewSet) router.register('cables', views.CableViewSet)
router.register('cable-terminations', views.CableTerminationViewSet) router.register('cable-terminations', views.CableTerminationViewSet)

View File

@ -499,6 +499,16 @@ class InventoryItemRoleViewSet(NetBoxModelViewSet):
filterset_class = filtersets.InventoryItemRoleFilterSet filterset_class = filtersets.InventoryItemRoleFilterSet
#
# Addressing
#
class MACAddressViewSet(NetBoxModelViewSet):
queryset = MACAddress.objects.all()
serializer_class = serializers.MACAddressSerializer
filterset_class = filtersets.MACAddressFilterSet
# #
# Cables # Cables
# #

View File

@ -128,3 +128,13 @@ COMPATIBLE_TERMINATION_TYPES = {
LOCATION_SCOPE_TYPES = ( LOCATION_SCOPE_TYPES = (
'region', 'sitegroup', 'site', 'location', 'region', 'sitegroup', 'site', 'location',
) )
#
# MAC addresses
#
MACADDRESS_ASSIGNMENT_MODELS = Q(
Q(app_label='dcim', model='interface') |
Q(app_label='virtualization', model='vminterface')
)

View File

@ -20,7 +20,7 @@ from utilities.filters import (
ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter, ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter,
NumericArrayFilter, TreeNodeMultipleChoiceFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter,
) )
from virtualization.models import Cluster, ClusterGroup from virtualization.models import Cluster, ClusterGroup, VMInterface, VirtualMachine
from vpn.models import L2VPN from vpn.models import L2VPN
from wireless.choices import WirelessRoleChoices, WirelessChannelChoices from wireless.choices import WirelessRoleChoices, WirelessChannelChoices
from wireless.models import WirelessLAN, WirelessLink from wireless.models import WirelessLAN, WirelessLink
@ -52,6 +52,7 @@ __all__ = (
'InventoryItemRoleFilterSet', 'InventoryItemRoleFilterSet',
'InventoryItemTemplateFilterSet', 'InventoryItemTemplateFilterSet',
'LocationFilterSet', 'LocationFilterSet',
'MACAddressFilterSet',
'ManufacturerFilterSet', 'ManufacturerFilterSet',
'ModuleBayFilterSet', 'ModuleBayFilterSet',
'ModuleBayTemplateFilterSet', 'ModuleBayTemplateFilterSet',
@ -1099,7 +1100,7 @@ class DeviceFilterSet(
label=_('Is full depth'), label=_('Is full depth'),
) )
mac_address = MultiValueMACAddressFilter( mac_address = MultiValueMACAddressFilter(
field_name='interfaces__mac_address', field_name='interfaces__mac_addresses__mac_address',
label=_('MAC address'), label=_('MAC address'),
) )
serial = MultiValueCharFilter( serial = MultiValueCharFilter(
@ -1598,6 +1599,87 @@ class PowerOutletFilterSet(
) )
class MACAddressFilterSet(NetBoxModelFilterSet):
mac_address = MultiValueMACAddressFilter()
device = MultiValueCharFilter(
method='filter_device',
field_name='name',
label=_('Device (name)'),
)
device_id = MultiValueNumberFilter(
method='filter_device',
field_name='pk',
label=_('Device (ID)'),
)
virtual_machine = MultiValueCharFilter(
method='filter_virtual_machine',
field_name='name',
label=_('Virtual machine (name)'),
)
virtual_machine_id = MultiValueNumberFilter(
method='filter_virtual_machine',
field_name='pk',
label=_('Virtual machine (ID)'),
)
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)'),
)
class Meta:
model = MACAddress
fields = ('id', 'description', '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 filter_device(self, queryset, name, value):
devices = Device.objects.filter(**{f'{name}__in': 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(**{f'{name}__in': 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
)
class CommonInterfaceFilterSet(django_filters.FilterSet): class CommonInterfaceFilterSet(django_filters.FilterSet):
vlan_id = django_filters.CharFilter( vlan_id = django_filters.CharFilter(
method='filter_vlan_id', method='filter_vlan_id',
@ -1702,7 +1784,21 @@ class InterfaceFilterSet(
duplex = django_filters.MultipleChoiceFilter( duplex = django_filters.MultipleChoiceFilter(
choices=InterfaceDuplexChoices choices=InterfaceDuplexChoices
) )
mac_address = MultiValueMACAddressFilter() mac_address = MultiValueMACAddressFilter(
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() wwn = MultiValueWWNFilter()
poe_mode = django_filters.MultipleChoiceFilter( poe_mode = django_filters.MultipleChoiceFilter(
choices=InterfacePoEModeChoices choices=InterfacePoEModeChoices

View File

@ -38,6 +38,7 @@ __all__ = (
'InventoryItemRoleBulkEditForm', 'InventoryItemRoleBulkEditForm',
'InventoryItemTemplateBulkEditForm', 'InventoryItemTemplateBulkEditForm',
'LocationBulkEditForm', 'LocationBulkEditForm',
'MACAddressBulkEditForm',
'ManufacturerBulkEditForm', 'ManufacturerBulkEditForm',
'ModuleBulkEditForm', 'ModuleBulkEditForm',
'ModuleBayBulkEditForm', 'ModuleBayBulkEditForm',
@ -1392,9 +1393,9 @@ class PowerOutletBulkEditForm(
class InterfaceBulkEditForm( class InterfaceBulkEditForm(
ComponentBulkEditForm, ComponentBulkEditForm,
form_from_model(Interface, [ 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',
'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
'tx_power', 'wireless_lans' 'wireless_lans'
]) ])
): ):
enabled = forms.NullBooleanField( enabled = forms.NullBooleanField(
@ -1506,7 +1507,7 @@ class InterfaceBulkEditForm(
model = Interface model = Interface
fieldsets = ( fieldsets = (
FieldSet('module', 'type', 'label', 'speed', 'duplex', 'description'), FieldSet('module', 'type', 'label', 'speed', 'duplex', 'description'),
FieldSet('vrf', 'mac_address', 'wwn', name=_('Addressing')), FieldSet('vrf', '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('poe_mode', 'poe_type', name=_('PoE')), FieldSet('poe_mode', 'poe_type', name=_('PoE')),
FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')), FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')),
@ -1517,9 +1518,9 @@ class InterfaceBulkEditForm(
), ),
) )
nullable_fields = ( nullable_fields = (
'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'vdcs', 'mtu', 'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'vdcs', 'mtu', 'description',
'description', 'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
'tx_power', 'untagged_vlan', 'tagged_vlans', 'vrf', 'wireless_lans' 'untagged_vlan', 'tagged_vlans', 'vrf', 'wireless_lans'
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -1719,3 +1720,22 @@ class VirtualDeviceContextBulkEditForm(NetBoxModelBulkEditForm):
FieldSet('device', 'status', 'tenant'), FieldSet('device', 'status', 'tenant'),
) )
nullable_fields = ('device', 'tenant', ) nullable_fields = ('device', 'tenant', )
#
# Addressing
#
class MACAddressBulkEditForm(NetBoxModelBulkEditForm):
description = forms.CharField(
label=_('Description'),
max_length=200,
required=False
)
comments = CommentField()
model = MACAddress
fieldsets = (
FieldSet('description'),
)
nullable_fields = ('description', 'comments')

View File

@ -17,7 +17,7 @@ from utilities.forms.fields import (
CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, CSVTypedChoiceField, CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, CSVTypedChoiceField,
SlugField, SlugField,
) )
from virtualization.models import Cluster from virtualization.models import Cluster, VMInterface, VirtualMachine
from wireless.choices import WirelessRoleChoices from wireless.choices import WirelessRoleChoices
from .common import ModuleCommonForm from .common import ModuleCommonForm
@ -34,6 +34,7 @@ __all__ = (
'InventoryItemImportForm', 'InventoryItemImportForm',
'InventoryItemRoleImportForm', 'InventoryItemRoleImportForm',
'LocationImportForm', 'LocationImportForm',
'MACAddressImportForm',
'ManufacturerImportForm', 'ManufacturerImportForm',
'ModuleImportForm', 'ModuleImportForm',
'ModuleBayImportForm', 'ModuleBayImportForm',
@ -906,7 +907,7 @@ class InterfaceImportForm(NetBoxModelImportForm):
model = Interface model = Interface
fields = ( fields = (
'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled', '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' 'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'tags'
) )
@ -1167,6 +1168,90 @@ class InventoryItemRoleImportForm(NetBoxModelImportForm):
fields = ('name', 'slug', 'color', 'description') fields = ('name', 'slug', 'color', 'description')
#
# Addressing
#
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 address 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')
# 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")
})
def save(self, *args, **kwargs):
# Set interface assignment
if interface := self.cleaned_data.get('interface'):
self.instance.assigned_object = interface
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
# #
# Cables # Cables
# #

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',
@ -12,17 +14,17 @@ __all__ = (
class InterfaceCommonForm(forms.Form): class InterfaceCommonForm(forms.Form):
mac_address = forms.CharField(
empty_value=None,
required=False,
label=_('MAC address')
)
mtu = forms.IntegerField( mtu = forms.IntegerField(
required=False, required=False,
min_value=INTERFACE_MTU_MIN, min_value=INTERFACE_MTU_MIN,
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)
@ -40,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

@ -15,7 +15,7 @@ from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_ch
from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.rendering import FieldSet from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import NumberWithOptions from utilities.forms.widgets import NumberWithOptions
from virtualization.models import Cluster, ClusterGroup from virtualization.models import Cluster, ClusterGroup, VirtualMachine
from vpn.models import L2VPN from vpn.models import L2VPN
from wireless.choices import * from wireless.choices import *
@ -34,6 +34,7 @@ __all__ = (
'InventoryItemFilterForm', 'InventoryItemFilterForm',
'InventoryItemRoleFilterForm', 'InventoryItemRoleFilterForm',
'LocationFilterForm', 'LocationFilterForm',
'MACAddressFilterForm',
'ManufacturerFilterForm', 'ManufacturerFilterForm',
'ModuleFilterForm', 'ModuleFilterForm',
'ModuleBayFilterForm', 'ModuleBayFilterForm',
@ -1574,6 +1575,34 @@ class InventoryItemRoleFilterForm(NetBoxModelFilterSetForm):
tag = TagFilterField(model) tag = TagFilterField(model)
#
# Addressing
#
class MACAddressFilterForm(NetBoxModelFilterSetForm):
model = MACAddress
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
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')
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
required=False,
label=_('Assigned Device'),
)
virtual_machine_id = DynamicModelMultipleChoiceField(
queryset=VirtualMachine.objects.all(),
required=False,
label=_('Assigned VM'),
)
tag = TagFilterField(model)
# #
# Connections # Connections
# #

View File

@ -18,7 +18,7 @@ from utilities.forms.fields import (
) )
from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups
from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, NumberWithOptions, SelectWithPK 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 wireless.models import WirelessLAN, WirelessLANGroup
from .common import InterfaceCommonForm, ModuleCommonForm from .common import InterfaceCommonForm, ModuleCommonForm
@ -42,6 +42,7 @@ __all__ = (
'InventoryItemRoleForm', 'InventoryItemRoleForm',
'InventoryItemTemplateForm', 'InventoryItemTemplateForm',
'LocationForm', 'LocationForm',
'MACAddressForm',
'ManufacturerForm', 'ManufacturerForm',
'ModuleForm', 'ModuleForm',
'ModuleBayForm', 'ModuleBayForm',
@ -1410,7 +1411,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', 'mac_address', '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')),
@ -1427,10 +1428,11 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
class Meta: class Meta:
model = Interface model = Interface
fields = [ fields = [
'device', 'module', 'vdcs', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag', 'device', 'module', 'vdcs', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge',
'mac_address', '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(
@ -1724,3 +1726,72 @@ class VirtualDeviceContextForm(TenancyForm, NetBoxModelForm):
'device', 'name', 'status', 'identifier', 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', 'device', 'name', 'status', 'identifier', 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant',
'comments', 'tags' 'comments', 'tags'
] ]
#
# Addressing
#
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,
)
fieldsets = (
FieldSet(
'mac_address', 'description', 'tags',
),
FieldSet(
TabbedGroups(
FieldSet('interface', name=_('Device')),
FieldSet('vminterface', name=_('Virtual Machine')),
),
),
)
class Meta:
model = MACAddress
fields = [
'mac_address', 'interface', 'vminterface', '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)
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:
self.instance.assigned_object = self.cleaned_data[selected_objects[0]]
else:
self.instance.assigned_object = None

View File

@ -23,6 +23,7 @@ __all__ = (
'InventoryItemFilter', 'InventoryItemFilter',
'InventoryItemRoleFilter', 'InventoryItemRoleFilter',
'LocationFilter', 'LocationFilter',
'MACAddressFilter',
'ManufacturerFilter', 'ManufacturerFilter',
'ModuleFilter', 'ModuleFilter',
'ModuleBayFilter', 'ModuleBayFilter',
@ -133,6 +134,12 @@ class FrontPortTemplateFilter(BaseFilterMixin):
pass pass
@strawberry_django.filter(models.MACAddress, lookups=True)
@autotype_decorator(filtersets.MACAddressFilterSet)
class MACAddressFilter(BaseFilterMixin):
pass
@strawberry_django.filter(models.Interface, lookups=True) @strawberry_django.filter(models.Interface, lookups=True)
@autotype_decorator(filtersets.InterfaceFilterSet) @autotype_decorator(filtersets.InterfaceFilterSet)
class InterfaceFilter(BaseFilterMixin): class InterfaceFilter(BaseFilterMixin):

View File

@ -44,6 +44,9 @@ class DCIMQuery:
front_port_template: FrontPortTemplateType = strawberry_django.field() front_port_template: FrontPortTemplateType = strawberry_django.field()
front_port_template_list: List[FrontPortTemplateType] = strawberry_django.field() front_port_template_list: List[FrontPortTemplateType] = strawberry_django.field()
mac_address: MACAddressType = strawberry_django.field()
mac_address_list: List[MACAddressType] = strawberry_django.field()
interface: InterfaceType = strawberry_django.field() interface: InterfaceType = strawberry_django.field()
interface_list: List[InterfaceType] = strawberry_django.field() interface_list: List[InterfaceType] = strawberry_django.field()

View File

@ -34,6 +34,7 @@ __all__ = (
'InventoryItemRoleType', 'InventoryItemRoleType',
'InventoryItemTemplateType', 'InventoryItemTemplateType',
'LocationType', 'LocationType',
'MACAddressType',
'ManufacturerType', 'ManufacturerType',
'ModularComponentType', 'ModularComponentType',
'ModuleType', 'ModuleType',
@ -366,6 +367,22 @@ class FrontPortTemplateType(ModularComponentTemplateType):
rear_port: Annotated["RearPortTemplateType", strawberry.lazy('dcim.graphql.types')] rear_port: Annotated["RearPortTemplateType", strawberry.lazy('dcim.graphql.types')]
@strawberry_django.type(
models.MACAddress,
exclude=('assigned_object_type', 'assigned_object_id'),
filters=MACAddressFilter
)
class MACAddressType(NetBoxObjectType):
mac_address: str
@strawberry_django.field
def assigned_object(self) -> Annotated[Union[
Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')],
Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')],
], strawberry.union("MACAddressAssignmentType")] | None:
return self.assigned_object
@strawberry_django.type( @strawberry_django.type(
models.Interface, models.Interface,
exclude=('_path',), exclude=('_path',),
@ -373,7 +390,6 @@ class FrontPortTemplateType(ModularComponentTemplateType):
) )
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
@ -381,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
@ -390,6 +407,7 @@ class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, P
wireless_lans: List[Annotated["WirelessLANType", strawberry.lazy('wireless.graphql.types')]] wireless_lans: List[Annotated["WirelessLANType", strawberry.lazy('wireless.graphql.types')]]
member_interfaces: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]] member_interfaces: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]]
child_interfaces: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]] child_interfaces: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]]
mac_addresses: List[Annotated["MACAddressType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type( @strawberry_django.type(

View File

@ -0,0 +1,36 @@
import django.db.models.deletion
import taggit.managers
from django.db import migrations, models
import dcim.fields
import utilities.json
class Migration(migrations.Migration):
dependencies = [
('dcim', '0198_natural_ordering'),
('extras', '0122_charfield_null_choices'),
]
operations = [
migrations.CreateModel(
name='MACAddress',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
('description', models.CharField(blank=True, max_length=200)),
('comments', models.TextField(blank=True)),
('mac_address', dcim.fields.MACAddressField()),
('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')),
],
options={
'abstract': False,
'ordering': ('mac_address',)
},
),
]

View File

@ -0,0 +1,52 @@
import django.db.models.deletion
from django.db import migrations, models
def populate_mac_addresses(apps, schema_editor):
ContentType = apps.get_model('contenttypes', 'ContentType')
Interface = apps.get_model('dcim', 'Interface')
MACAddress = apps.get_model('dcim', 'MACAddress')
interface_ct = ContentType.objects.get_for_model(Interface)
mac_addresses = [
MACAddress(
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)
]
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):
dependencies = [
('dcim', '0199_macaddress'),
]
operations = [
migrations.AddField(
model_name='interface',
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,
reverse_code=migrations.RunPython.noop
),
migrations.RemoveField(
model_name='interface',
name='mac_address',
),
]

View File

@ -10,7 +10,7 @@ from mptt.models import MPTTModel, TreeForeignKey
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.fields import MACAddressField, WWNField from dcim.fields import WWNField
from netbox.choices import ColorChoices from netbox.choices import ColorChoices
from netbox.models import OrganizationalModel, NetBoxModel from netbox.models import OrganizationalModel, NetBoxModel
from utilities.fields import ColorField, NaturalOrderingField from utilities.fields import ColorField, NaturalOrderingField
@ -505,11 +505,6 @@ class BaseInterface(models.Model):
verbose_name=_('enabled'), verbose_name=_('enabled'),
default=True default=True
) )
mac_address = MACAddressField(
null=True,
blank=True,
verbose_name=_('MAC address')
)
mtu = models.PositiveIntegerField( mtu = models.PositiveIntegerField(
blank=True, blank=True,
null=True, null=True,
@ -572,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
@ -585,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
@ -609,6 +620,11 @@ class BaseInterface(models.Model):
def count_fhrp_groups(self): def count_fhrp_groups(self):
return self.fhrp_group_assignments.count() return self.fhrp_group_assignments.count()
@cached_property
def mac_address(self):
if self.primary_mac_address:
return self.primary_mac_address.mac_address
class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEndpoint, TrackingModelMixin): class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEndpoint, TrackingModelMixin):
""" """
@ -738,6 +754,12 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
object_id_field='assigned_object_id', object_id_field='assigned_object_id',
related_query_name='interface' 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( fhrp_group_assignments = GenericRelation(
to='ipam.FHRPGroupAssignment', to='ipam.FHRPGroupAssignment',
content_type_field='interface_type', content_type_field='interface_type',

View File

@ -3,6 +3,7 @@ import yaml
from functools import cached_property from functools import cached_property
from django.contrib.contenttypes.fields import GenericForeignKey
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage from django.core.files.storage import default_storage
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
@ -16,6 +17,7 @@ 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.fields import MACAddressField
from extras.models import ConfigContextModel, CustomField from extras.models import ConfigContextModel, CustomField
from extras.querysets import ConfigContextModelQuerySet from extras.querysets import ConfigContextModelQuerySet
from netbox.choices import ColorChoices from netbox.choices import ColorChoices
@ -33,6 +35,7 @@ __all__ = (
'Device', 'Device',
'DeviceRole', 'DeviceRole',
'DeviceType', 'DeviceType',
'MACAddress',
'Manufacturer', 'Manufacturer',
'Module', 'Module',
'ModuleType', 'ModuleType',
@ -1470,3 +1473,37 @@ class VirtualDeviceContext(PrimaryModel):
raise ValidationError({ raise ValidationError({
f'primary_ip{family}': _('Primary IP address must belong to an interface on the assigned device.') f'primary_ip{family}': _('Primary IP address must belong to an interface on the assigned device.')
}) })
#
# Addressing
#
class MACAddress(PrimaryModel):
mac_address = MACAddressField(
verbose_name=_('MAC address')
)
assigned_object_type = models.ForeignKey(
to='contenttypes.ContentType',
limit_choices_to=MACADDRESS_ASSIGNMENT_MODELS,
on_delete=models.PROTECT,
related_name='+',
blank=True,
null=True
)
assigned_object_id = models.PositiveBigIntegerField(
blank=True,
null=True
)
assigned_object = GenericForeignKey(
ct_field='assigned_object_type',
fk_field='assigned_object_id'
)
class Meta:
ordering = ('mac_address',)
verbose_name = _('MAC address')
verbose_name_plural = _('MAC addresses')
def __str__(self):
return str(self.mac_address)

View File

@ -98,19 +98,28 @@ class FrontPortIndex(SearchIndex):
display_attrs = ('device', 'label', 'type', 'description') display_attrs = ('device', 'label', 'type', 'description')
@register_search
class MACAddressIndex(SearchIndex):
model = models.MACAddress
fields = (
('mac_address', 100),
('description', 500),
)
display_attrs = ('mac_address', 'interface')
@register_search @register_search
class InterfaceIndex(SearchIndex): class InterfaceIndex(SearchIndex):
model = models.Interface model = models.Interface
fields = ( fields = (
('name', 100), ('name', 100),
('label', 200), ('label', 200),
('mac_address', 300),
('wwn', 300), ('wwn', 300),
('description', 500), ('description', 500),
('mtu', 2000), ('mtu', 2000),
('speed', 2000), ('speed', 2000),
) )
display_attrs = ('device', 'label', 'type', 'mac_address', 'wwn', 'description') display_attrs = ('device', 'label', 'type', 'wwn', 'description')
@register_search @register_search

View File

@ -29,6 +29,7 @@ __all__ = (
'InterfaceTable', 'InterfaceTable',
'InventoryItemRoleTable', 'InventoryItemRoleTable',
'InventoryItemTable', 'InventoryItemTable',
'MACAddressTable',
'ModuleBayTable', 'ModuleBayTable',
'PlatformTable', 'PlatformTable',
'PowerOutletTable', 'PowerOutletTable',
@ -42,6 +43,16 @@ MODULEBAY_STATUS = """
{% badge record.installed_module.get_status_display bg_color=record.installed_module.get_status_color %} {% badge record.installed_module.get_status_display bg_color=record.installed_module.get_status_color %}
""" """
MACADDRESS_LINK = """
{% if record.pk %}
<a href="{{ record.get_absolute_url }}" id="macaddress_{{ record.pk }}">{{ record.mac_address }}</a>
{% endif %}
"""
MACADDRESS_COPY_BUTTON = """
{% copy_content record.pk prefix="macaddress_" %}
"""
# #
# Device roles # Device roles
@ -588,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()])
@ -638,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')
@ -1098,3 +1113,34 @@ class VirtualDeviceContextTable(TenancyColumnsMixin, NetBoxTable):
default_columns = ( default_columns = (
'pk', 'name', 'identifier', 'status', 'tenant', 'primary_ip', 'pk', 'name', 'identifier', 'status', 'tenant', 'primary_ip',
) )
class MACAddressTable(NetBoxTable):
mac_address = tables.TemplateColumn(
template_code=MACADDRESS_LINK,
verbose_name=_('MAC Address')
)
assigned_object = tables.Column(
linkify=True,
orderable=False,
verbose_name=_('Interface')
)
assigned_object_parent = tables.Column(
accessor='assigned_object__parent_object',
linkify=True,
orderable=False,
verbose_name=_('Parent')
)
tags = columns.TagColumn(
url_name='dcim:macaddress_list'
)
actions = columns.ActionsColumn(
extra_buttons=MACADDRESS_COPY_BUTTON
)
class Meta(DeviceComponentTable.Meta):
model = models.MACAddress
fields = (
'pk', 'id', 'mac_address', 'assigned_object_parent', 'assigned_object', 'created', 'last_updated',
)
default_columns = ('pk', 'mac_address', 'assigned_object_parent', 'assigned_object')

View File

@ -314,6 +314,9 @@ INTERFACE_BUTTONS = """
{% if perms.ipam.add_ipaddress %} {% if perms.ipam.add_ipaddress %}
<li><a class="dropdown-item" href="{% url 'ipam:ipaddress_add' %}?interface={{ record.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">IP Address</a></li> <li><a class="dropdown-item" href="{% url 'ipam:ipaddress_add' %}?interface={{ record.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">IP Address</a></li>
{% endif %} {% endif %}
{% if perms.dcim.add_macaddress %}
<li><a class="dropdown-item" href="{% url 'dcim:macaddress_add' %}?interface={{ record.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">MAC Address</a></li>
{% endif %}
{% if perms.dcim.add_inventoryitem %} {% if perms.dcim.add_inventoryitem %}
<li><a class="dropdown-item" href="{% url 'dcim:inventoryitem_add' %}?device={{ record.device_id }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Inventory Item</a></li> <li><a class="dropdown-item" href="{% url 'dcim:inventoryitem_add' %}?device={{ record.device_id }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Inventory Item</a></li>
{% endif %} {% endif %}

View File

@ -9,8 +9,8 @@ from ipam.models import ASN, IPAddress, RIR, VLAN, VLANTranslationPolicy, VRF
from netbox.choices import ColorChoices, WeightUnitChoices from netbox.choices import ColorChoices, WeightUnitChoices
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from users.models import User from users.models import User
from utilities.testing import ChangeLoggedFilterSetTests, create_test_device from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
from virtualization.models import Cluster, ClusterType, ClusterGroup from virtualization.models import Cluster, ClusterType, ClusterGroup, VMInterface, VirtualMachine
from wireless.choices import WirelessChannelChoices, WirelessRoleChoices from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
@ -2323,10 +2323,17 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
PowerOutlet(device=devices[1], name='Power Outlet 2'), PowerOutlet(device=devices[1], name='Power Outlet 2'),
)) ))
interfaces = ( interfaces = (
Interface(device=devices[0], name='Interface 1', mac_address='00-00-00-00-00-01'), Interface(device=devices[0], name='Interface 1'),
Interface(device=devices[1], name='Interface 2', mac_address='00-00-00-00-00-02'), Interface(device=devices[1], name='Interface 2'),
) )
Interface.objects.bulk_create(interfaces) Interface.objects.bulk_create(interfaces)
mac_addresses = (
MACAddress(mac_address='00-00-00-00-00-01'),
MACAddress(mac_address='00-00-00-00-00-02'),
)
MACAddress.objects.bulk_create(mac_addresses)
interfaces[0].mac_addresses.set([mac_addresses[0]])
interfaces[1].mac_addresses.set([mac_addresses[1]])
rear_ports = ( rear_ports = (
RearPort(device=devices[0], name='Rear Port 1', type=PortTypeChoices.TYPE_8P8C), RearPort(device=devices[0], name='Rear Port 1', type=PortTypeChoices.TYPE_8P8C),
RearPort(device=devices[1], name='Rear Port 2', type=PortTypeChoices.TYPE_8P8C), RearPort(device=devices[1], name='Rear Port 2', type=PortTypeChoices.TYPE_8P8C),
@ -3670,6 +3677,13 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
) )
VirtualDeviceContext.objects.bulk_create(vdcs) VirtualDeviceContext.objects.bulk_create(vdcs)
mac_addresses = (
MACAddress(mac_address='00-00-00-00-00-01'),
MACAddress(mac_address='00-00-00-00-00-02'),
MACAddress(mac_address='00-00-00-00-00-03'),
)
MACAddress.objects.bulk_create(mac_addresses)
vlans = ( vlans = (
VLAN(name='SVLAN 1', vid=1001, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE), VLAN(name='SVLAN 1', vid=1001, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
VLAN(name='SVLAN 2', vid=1002, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE), VLAN(name='SVLAN 2', vid=1002, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
@ -3695,7 +3709,6 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
mgmt_only=True, mgmt_only=True,
mtu=100, mtu=100,
mode=InterfaceModeChoices.MODE_ACCESS, mode=InterfaceModeChoices.MODE_ACCESS,
mac_address='00-00-00-00-00-01',
description='First', description='First',
vrf=vrfs[0], vrf=vrfs[0],
speed=1000000, speed=1000000,
@ -3721,7 +3734,6 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
mgmt_only=True, mgmt_only=True,
mtu=200, mtu=200,
mode=InterfaceModeChoices.MODE_TAGGED, mode=InterfaceModeChoices.MODE_TAGGED,
mac_address='00-00-00-00-00-02',
description='Second', description='Second',
vrf=vrfs[1], vrf=vrfs[1],
speed=1000000, speed=1000000,
@ -3740,7 +3752,6 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
mgmt_only=False, mgmt_only=False,
mtu=300, mtu=300,
mode=InterfaceModeChoices.MODE_TAGGED_ALL, mode=InterfaceModeChoices.MODE_TAGGED_ALL,
mac_address='00-00-00-00-00-03',
description='Third', description='Third',
vrf=vrfs[2], vrf=vrfs[2],
speed=100000, speed=100000,
@ -3814,6 +3825,10 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
interfaces[6].vdcs.set([vdcs[0]]) interfaces[6].vdcs.set([vdcs[0]])
interfaces[7].vdcs.set([vdcs[1]]) interfaces[7].vdcs.set([vdcs[1]])
interfaces[0].mac_addresses.set([mac_addresses[0]])
interfaces[2].mac_addresses.set([mac_addresses[1]])
interfaces[3].mac_addresses.set([mac_addresses[2]])
# Cables # Cables
Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[5]]).save() Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[5]]).save()
Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[6]]).save() Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[6]]).save()
@ -5842,3 +5857,80 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'primary_ip6_id': [addresses[2].pk]} params = {'primary_ip6_id': [addresses[2].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
class MACAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = MACAddress.objects.all()
filterset = MACAddressFilterSet
@classmethod
def setUpTestData(cls):
devices = (
create_test_device('Device 1'),
create_test_device('Device 2'),
create_test_device('Device 3'),
)
interfaces = (
Interface(device=devices[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[1], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[2], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
)
Interface.objects.bulk_create(interfaces)
virtual_machines = (
create_test_virtualmachine('Virtual Machine 1'),
create_test_virtualmachine('Virtual Machine 2'),
create_test_virtualmachine('Virtual Machine 3'),
)
vm_interfaces = (
VMInterface(virtual_machine=virtual_machines[0], name='Interface 1'),
VMInterface(virtual_machine=virtual_machines[1], name='Interface 2'),
VMInterface(virtual_machine=virtual_machines[2], name='Interface 3'),
)
VMInterface.objects.bulk_create(vm_interfaces)
mac_addresses = (
# Device MACs
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]),
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)
def test_mac_address(self):
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_device(self):
devices = Device.objects.all()[:2]
params = {'device_id': [devices[0].pk, devices[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'device': [devices[0].name, devices[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_virtual_machine(self):
virtual_machines = VirtualMachine.objects.all()[:2]
params = {'virtual_machine_id': [virtual_machines[0].pk, virtual_machines[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'virtual_machine': [virtual_machines[0].name, virtual_machines[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_interface(self):
interfaces = Interface.objects.all()[:2]
params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'interface': [interfaces[0].name, interfaces[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_vminterface(self):
vm_interfaces = VMInterface.objects.all()[:2]
params = {'vminterface_id': [vm_interfaces[0].pk, vm_interfaces[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'vminterface': [vm_interfaces[0].name, vm_interfaces[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@ -2508,7 +2508,6 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
'enabled': False, 'enabled': False,
'bridge': interfaces[4].pk, 'bridge': interfaces[4].pk,
'lag': interfaces[3].pk, 'lag': interfaces[3].pk,
'mac_address': EUI('01:02:03:04:05:06'),
'wwn': EUI('01:02:03:04:05:06:07:08', version=64), 'wwn': EUI('01:02:03:04:05:06:07:08', version=64),
'mtu': 65000, 'mtu': 65000,
'speed': 1000000, 'speed': 1000000,
@ -2533,7 +2532,6 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
'enabled': False, 'enabled': False,
'bridge': interfaces[4].pk, 'bridge': interfaces[4].pk,
'lag': interfaces[3].pk, 'lag': interfaces[3].pk,
'mac_address': EUI('01:02:03:04:05:06'),
'wwn': EUI('01:02:03:04:05:06:07:08', version=64), 'wwn': EUI('01:02:03:04:05:06:07:08', version=64),
'mtu': 2000, 'mtu': 2000,
'speed': 100000, 'speed': 100000,
@ -2554,7 +2552,6 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
'type': InterfaceTypeChoices.TYPE_1GE_FIXED, 'type': InterfaceTypeChoices.TYPE_1GE_FIXED,
'enabled': True, 'enabled': True,
'lag': interfaces[3].pk, 'lag': interfaces[3].pk,
'mac_address': EUI('01:02:03:04:05:06'),
'wwn': EUI('01:02:03:04:05:06:07:08', version=64), 'wwn': EUI('01:02:03:04:05:06:07:08', version=64),
'mtu': 2000, 'mtu': 2000,
'speed': 1000000, 'speed': 1000000,

View File

@ -250,6 +250,14 @@ urlpatterns = [
path('power-outlets/<int:pk>/', include(get_model_urls('dcim', 'poweroutlet'))), path('power-outlets/<int:pk>/', include(get_model_urls('dcim', 'poweroutlet'))),
path('devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_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/import/', views.MACAddressBulkImportView.as_view(), name='macaddress_import'),
path('mac-addresses/edit/', views.MACAddressBulkEditView.as_view(), name='macaddress_bulk_edit'),
path('mac-addresses/delete/', views.MACAddressBulkDeleteView.as_view(), name='macaddress_bulk_delete'),
path('mac-addresses/<int:pk>/', include(get_model_urls('dcim', 'macaddress'))),
# Interfaces # Interfaces
path('interfaces/', views.InterfaceListView.as_view(), name='interface_list'), path('interfaces/', views.InterfaceListView.as_view(), name='interface_list'),
path('interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'), path('interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'),

View File

@ -2538,6 +2538,51 @@ class PowerOutletBulkDeleteView(generic.BulkDeleteView):
register_model_view(PowerOutlet, 'trace', kwargs={'model': PowerOutlet})(PathTraceView) 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
@register_model_view(MACAddress)
class MACAddressView(generic.ObjectView):
queryset = MACAddress.objects.all()
@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 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 MACAddressBulkDeleteView(generic.BulkDeleteView):
queryset = MACAddress.objects.all()
filterset = filtersets.MACAddressFilterSet
table = tables.MACAddressTable
# #
# Interfaces # Interfaces
# #
@ -2571,7 +2616,7 @@ class InterfaceView(generic.ObjectView):
# Get bridge interfaces # Get bridge interfaces
bridge_interfaces = Interface.objects.restrict(request.user, 'view').filter(bridge=instance) bridge_interfaces = Interface.objects.restrict(request.user, 'view').filter(bridge=instance)
bridge_interfaces_tables = tables.InterfaceTable( bridge_interfaces_table = tables.InterfaceTable(
bridge_interfaces, bridge_interfaces,
exclude=('device', 'parent'), exclude=('device', 'parent'),
orderable=False orderable=False
@ -2579,7 +2624,7 @@ class InterfaceView(generic.ObjectView):
# Get child interfaces # Get child interfaces
child_interfaces = Interface.objects.restrict(request.user, 'view').filter(parent=instance) child_interfaces = Interface.objects.restrict(request.user, 'view').filter(parent=instance)
child_interfaces_tables = tables.InterfaceTable( child_interfaces_table = tables.InterfaceTable(
child_interfaces, child_interfaces,
exclude=('device', 'parent'), exclude=('device', 'parent'),
orderable=False orderable=False
@ -2609,8 +2654,8 @@ class InterfaceView(generic.ObjectView):
return { return {
'vdc_table': vdc_table, 'vdc_table': vdc_table,
'bridge_interfaces_table': bridge_interfaces_tables, 'bridge_interfaces_table': bridge_interfaces_table,
'child_interfaces_table': child_interfaces_tables, 'child_interfaces_table': child_interfaces_table,
'vlan_table': vlan_table, 'vlan_table': vlan_table,
'vlan_translation_table': vlan_translation_table, 'vlan_translation_table': vlan_translation_table,
} }

View File

@ -1135,6 +1135,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
'l2vpn', 'l2vpn',
'l2vpntermination', 'l2vpntermination',
'location', 'location',
'macaddress',
'manufacturer', 'manufacturer',
'module', 'module',
'modulebay', 'modulebay',

View File

@ -179,6 +179,8 @@ class BaseFilterSet(django_filters.FilterSet):
# The filter field has been explicitly defined on the filterset class so we must manually # The filter field has been explicitly defined on the filterset class so we must manually
# create the new filter with the same type because there is no guarantee the defined type # create the new filter with the same type because there is no guarantee the defined type
# is the same as the default type for the field # is the same as the default type for the field
if field is None:
raise ValueError('Invalid field name/lookup on {}: {}'.format(existing_filter_name, field_name))
resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid
filter_cls = type(existing_filter) filter_cls = type(existing_filter)
if lookup_expr == 'empty': if lookup_expr == 'empty':

View File

@ -88,6 +88,12 @@ DEVICES_MENU = Menu(
get_model_item('dcim', 'manufacturer', _('Manufacturers')), get_model_item('dcim', 'manufacturer', _('Manufacturers')),
), ),
), ),
MenuGroup(
label=_('Addressing'),
items=(
get_model_item('dcim', 'macaddress', _('MAC Addresses')),
),
),
MenuGroup( MenuGroup(
label=_('Device Components'), label=_('Device Components'),
items=( items=(

View File

@ -123,11 +123,24 @@
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
<tr> <tr>
<th scope="row">{% trans "MAC Address" %}</th> <th scope="row">{% trans "MAC Address" %}</th>
<td><span class="font-monospace">{{ object.mac_address|placeholder }}</span></td> <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>
<tr> <tr>
<th scope="row">{% trans "WWN" %}</th> <th scope="row">{% trans "WWN" %}</th>
<td><span class="font-monospace">{{ object.wwn|placeholder }}</span></td> <td>
{% if object.wwn %}
<span class="font-monospace">{{ object.wwn }}</span>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "VRF" %}</th> <th scope="row">{% trans "VRF" %}</th>
@ -350,7 +363,23 @@
{% endif %} {% endif %}
</h2> </h2>
{% htmx_table 'ipam:ipaddress_list' interface_id=object.pk %} {% htmx_table 'ipam:ipaddress_list' interface_id=object.pk %}
</div>
</div>
</div>
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">
{% trans "MAC Addresses" %}
{% if perms.dcim.add_macaddress %}
<div class="card-actions">
<a href="{% url 'dcim:macaddress_add' %}?device={{ object.device.pk }}&interface={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add MAC Address" %}
</a>
</div>
{% endif %}
</h2>
{% htmx_table 'dcim:macaddress_list' interface_id=object.pk %}
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,55 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-md-6">
<div class="card">
<h2 class="card-header">{% trans "MAC Address" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "MAC Address" %}</th>
<td>
<span id="macaddress_{{ object.pk }}">{{ object.mac_address|placeholder }}</span>
{% copy_content object.pk prefix="macaddress_" %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Assignment" %}</th>
<td>
{% if object.assigned_object %}
{{ object.assigned_object.parent_object|linkify }} /
{{ object.assigned_object|linkify }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Primary for interface" %}</th>
<td>{% checkmark object.is_primary %}</td>
</tr>
</table>
</div>
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
{% include 'inc/panels/comments.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@ -14,73 +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><span class="font-monospace">{{ object.mac_address|placeholder }}</span></td> <td>{{ object.tunnel_termination.tunnel|linkify|placeholder }}</td>
</tr> </tr>
<tr> </table>
<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">
@ -99,6 +111,24 @@
</div> </div>
</div> </div>
</div> </div>
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">
{% trans "MAC Addresses" %}
{% if perms.ipam.add_macaddress %}
<div class="card-actions">
<a href="{% url 'dcim:macaddress_add' %}?virtual_machine={{ object.device.pk }}&vminterface={{ object.pk }}&return_url={{ object.get_absolute_url }}"
class="btn btn-ghost-primary btn-sm">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add MAC Address" %}
</a>
</div>
{% endif %}
</h2>
{% htmx_table 'dcim:macaddress_list' vminterface_id=object.pk %}
</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">
{% include 'inc/panel_table.html' with table=vlan_table heading="VLANs" %} {% include 'inc/panel_table.html' with table=vlan_table heading="VLANs" %}

View File

@ -9,7 +9,7 @@ from dcim.choices import *
from dcim.fields import MACAddressField from dcim.fields import MACAddressField
from dcim.filtersets import DeviceFilterSet, SiteFilterSet, InterfaceFilterSet from dcim.filtersets import DeviceFilterSet, SiteFilterSet, InterfaceFilterSet
from dcim.models import ( from dcim.models import (
Device, DeviceRole, DeviceType, Interface, Manufacturer, Platform, Rack, Region, Site Device, DeviceRole, DeviceType, Interface, MACAddress, Manufacturer, Platform, Rack, Region, Site
) )
from extras.filters import TagFilter from extras.filters import TagFilter
from extras.models import TaggedItem from extras.models import TaggedItem
@ -433,16 +433,33 @@ class DynamicFilterLookupExpressionTest(TestCase):
) )
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
mac_addresses = (
MACAddress(mac_address='00-00-00-00-00-01'),
MACAddress(mac_address='aa-00-00-00-00-01'),
MACAddress(mac_address='00-00-00-00-00-02'),
MACAddress(mac_address='bb-00-00-00-00-02'),
MACAddress(mac_address='00-00-00-00-00-03'),
MACAddress(mac_address='cc-00-00-00-00-03'),
)
MACAddress.objects.bulk_create(mac_addresses)
interfaces = ( interfaces = (
Interface(device=devices[0], name='Interface 1', mac_address='00-00-00-00-00-01'), Interface(device=devices[0], name='Interface 1'),
Interface(device=devices[0], name='Interface 2', mac_address='aa-00-00-00-00-01'), Interface(device=devices[0], name='Interface 2'),
Interface(device=devices[1], name='Interface 3', mac_address='00-00-00-00-00-02'), Interface(device=devices[1], name='Interface 3'),
Interface(device=devices[1], name='Interface 4', mac_address='bb-00-00-00-00-02'), Interface(device=devices[1], name='Interface 4'),
Interface(device=devices[2], name='Interface 5', mac_address='00-00-00-00-00-03'), Interface(device=devices[2], name='Interface 5'),
Interface(device=devices[2], name='Interface 6', mac_address='cc-00-00-00-00-03', rf_role=WirelessRoleChoices.ROLE_AP), Interface(device=devices[2], name='Interface 6', rf_role=WirelessRoleChoices.ROLE_AP),
) )
Interface.objects.bulk_create(interfaces) Interface.objects.bulk_create(interfaces)
interfaces[0].mac_addresses.set([mac_addresses[0]])
interfaces[1].mac_addresses.set([mac_addresses[1]])
interfaces[2].mac_addresses.set([mac_addresses[2]])
interfaces[3].mac_addresses.set([mac_addresses[3]])
interfaces[4].mac_addresses.set([mac_addresses[4]])
interfaces[5].mac_addresses.set([mac_addresses[5]])
def test_site_name_negation(self): def test_site_name_negation(self):
params = {'name__n': ['Site 1']} params = {'name__n': ['Site 1']}
self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 2) self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 2)

View File

@ -2,6 +2,7 @@ from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers from rest_framework import serializers
from dcim.api.serializers_.devices import DeviceSerializer from dcim.api.serializers_.devices import DeviceSerializer
from dcim.api.serializers_.device_components import MACAddressSerializer
from dcim.api.serializers_.platforms import PlatformSerializer from dcim.api.serializers_.platforms import PlatformSerializer
from dcim.api.serializers_.roles import DeviceRoleSerializer from dcim.api.serializers_.roles import DeviceRoleSerializer
from dcim.api.serializers_.sites import SiteSerializer from dcim.api.serializers_.sites import SiteSerializer
@ -95,19 +96,18 @@ class VMInterfaceSerializer(NetBoxModelSerializer):
l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True) l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True)
count_ipaddresses = serializers.IntegerField(read_only=True) count_ipaddresses = serializers.IntegerField(read_only=True)
count_fhrp_groups = serializers.IntegerField(read_only=True) count_fhrp_groups = serializers.IntegerField(read_only=True)
mac_address = serializers.CharField( # Maintains backward compatibility with NetBox <v4.2
required=False, mac_address = serializers.CharField(allow_null=True, read_only=True)
default=None, primary_mac_address = MACAddressSerializer(nested=True, required=False, allow_null=True)
allow_null=True mac_addresses = MACAddressSerializer(many=True, nested=True, read_only=True, allow_null=True)
)
class Meta: class Meta:
model = VMInterface model = VMInterface
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'bridge', 'mtu', 'id', 'url', 'display_url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'bridge', 'mtu',
'mac_address', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'mac_address', 'primary_mac_address', 'mac_addresses', 'description', 'mode', 'untagged_vlan',
'vlan_translation_policy', 'vrf', 'l2vpn_termination', 'tags', 'custom_fields', 'created', 'last_updated', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf', 'l2vpn_termination', 'tags',
'count_ipaddresses', 'count_fhrp_groups', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups',
] ]
brief_fields = ('id', 'url', 'display', 'virtual_machine', 'name', 'description') brief_fields = ('id', 'url', 'display', 'virtual_machine', 'name', 'description')

View File

@ -2,9 +2,10 @@ import django_filters
from django.db.models import Q 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
from dcim.base_filtersets import ScopedFilterSet from dcim.base_filtersets import ScopedFilterSet
from dcim.filtersets import CommonInterfaceFilterSet
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
from dcim.models import MACAddress
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
@ -191,7 +192,7 @@ class VirtualMachineFilterSet(
label=_('Platform (slug)'), label=_('Platform (slug)'),
) )
mac_address = MultiValueMACAddressFilter( mac_address = MultiValueMACAddressFilter(
field_name='interfaces__mac_address', field_name='interfaces__mac_addresses__mac_address',
label=_('MAC address'), label=_('MAC address'),
) )
has_primary_ip = django_filters.BooleanFilter( has_primary_ip = django_filters.BooleanFilter(
@ -263,8 +264,20 @@ class VMInterfaceFilterSet(NetBoxModelFilterSet, CommonInterfaceFilterSet):
label=_('Bridged interface (ID)'), label=_('Bridged interface (ID)'),
) )
mac_address = MultiValueMACAddressFilter( mac_address = MultiValueMACAddressFilter(
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

@ -182,7 +182,7 @@ class VMInterfaceImportForm(NetBoxModelImportForm):
class Meta: class Meta:
model = VMInterface model = VMInterface
fields = ( fields = (
'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode', 'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mtu', 'description', 'mode',
'vrf', 'tags' 'vrf', 'tags'
) )

View File

@ -360,7 +360,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', 'mac_address', 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(
@ -372,8 +372,9 @@ class VMInterfaceForm(InterfaceCommonForm, VMComponentForm):
class Meta: class Meta:
model = VMInterface model = VMInterface
fields = [ fields = [
'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode', 'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mtu', 'description', 'mode', 'vlan_group',
'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,12 +106,14 @@ 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
tagged_vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]] tagged_vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]]
bridge_interfaces: List[Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')]] bridge_interfaces: List[Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')]]
child_interfaces: List[Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')]] child_interfaces: List[Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')]]
mac_addresses: List[Annotated["MACAddressType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type( @strawberry_django.type(

View File

@ -0,0 +1,53 @@
import django.db.models.deletion
from django.db import migrations, models
def populate_mac_addresses(apps, schema_editor):
ContentType = apps.get_model('contenttypes', 'ContentType')
VMInterface = apps.get_model('virtualization', 'VMInterface')
MACAddress = apps.get_model('dcim', 'MACAddress')
vminterface_ct = ContentType.objects.get_for_model(VMInterface)
mac_addresses = [
MACAddress(
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)
]
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):
dependencies = [
('dcim', '0199_macaddress'),
('virtualization', '0047_natural_ordering'),
]
operations = [
migrations.AddField(
model_name='vminterface',
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,
reverse_code=migrations.RunPython.noop
),
migrations.RemoveField(
model_name='vminterface',
name='mac_address',
),
]

View File

@ -348,6 +348,12 @@ class VMInterface(ComponentModel, BaseInterface, TrackingModelMixin):
object_id_field='assigned_object_id', object_id_field='assigned_object_id',
related_query_name='vminterface', 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): class Meta(ComponentModel.Meta):
verbose_name = _('interface') verbose_name = _('interface')

View File

@ -52,11 +52,10 @@ class VMInterfaceIndex(SearchIndex):
model = models.VMInterface model = models.VMInterface
fields = ( fields = (
('name', 100), ('name', 100),
('mac_address', 300),
('description', 500), ('description', 500),
('mtu', 2000), ('mtu', 2000),
) )
display_attrs = ('virtual_machine', 'mac_address', 'description') display_attrs = ('virtual_machine', 'description')
@register_search @register_search

View File

@ -25,6 +25,9 @@ VMINTERFACE_BUTTONS = """
{% if perms.ipam.add_ipaddress %} {% if perms.ipam.add_ipaddress %}
<li><a class="dropdown-item" href="{% url 'ipam:ipaddress_add' %}?vminterface={{ record.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}">IP Address</a></li> <li><a class="dropdown-item" href="{% url 'ipam:ipaddress_add' %}?vminterface={{ record.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}">IP Address</a></li>
{% endif %} {% endif %}
{% if perms.dcim.add_macaddress %}
<li><a class="dropdown-item" href="{% url 'dcim:macaddress_add' %}?vminterface={{ record.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}">MAC Address</a></li>
{% endif %}
{% if perms.vpn.add_l2vpntermination %} {% if perms.vpn.add_l2vpntermination %}
<li><a class="dropdown-item" href="{% url 'vpn:l2vpntermination_add' %}?virtual_machine={{ object.pk }}&vminterface={{ record.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}">L2VPN Termination</a></li> <li><a class="dropdown-item" href="{% url 'vpn:l2vpntermination_add' %}?virtual_machine={{ object.pk }}&vminterface={{ record.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}">L2VPN Termination</a></li>
{% endif %} {% endif %}
@ -150,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')

View File

@ -1,7 +1,7 @@
from django.test import TestCase from django.test import TestCase
from dcim.choices import InterfaceModeChoices from dcim.choices import InterfaceModeChoices
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from dcim.models import Device, DeviceRole, MACAddress, Platform, Region, Site, SiteGroup
from ipam.choices import VLANQinQRoleChoices from ipam.choices import VLANQinQRoleChoices
from ipam.models import IPAddress, VLAN, VLANTranslationPolicy, VRF from ipam.models import IPAddress, VLAN, VLANTranslationPolicy, VRF
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
@ -366,13 +366,24 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
) )
VirtualMachine.objects.bulk_create(vms) VirtualMachine.objects.bulk_create(vms)
mac_addresses = (
MACAddress(mac_address='00-00-00-00-00-01'),
MACAddress(mac_address='00-00-00-00-00-02'),
MACAddress(mac_address='00-00-00-00-00-03'),
)
MACAddress.objects.bulk_create(mac_addresses)
interfaces = ( interfaces = (
VMInterface(virtual_machine=vms[0], name='Interface 1', mac_address='00-00-00-00-00-01'), VMInterface(virtual_machine=vms[0], name='Interface 1'),
VMInterface(virtual_machine=vms[1], name='Interface 2', mac_address='00-00-00-00-00-02'), VMInterface(virtual_machine=vms[1], name='Interface 2'),
VMInterface(virtual_machine=vms[2], name='Interface 3', mac_address='00-00-00-00-00-03'), VMInterface(virtual_machine=vms[2], name='Interface 3'),
) )
VMInterface.objects.bulk_create(interfaces) VMInterface.objects.bulk_create(interfaces)
interfaces[0].mac_addresses.set([mac_addresses[0]])
interfaces[1].mac_addresses.set([mac_addresses[1]])
interfaces[2].mac_addresses.set([mac_addresses[2]])
# Assign primary IPs for filtering # Assign primary IPs for filtering
ipaddresses = ( ipaddresses = (
IPAddress(address='192.0.2.1/24', assigned_object=interfaces[0]), IPAddress(address='192.0.2.1/24', assigned_object=interfaces[0]),
@ -579,13 +590,19 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
) )
VLANTranslationPolicy.objects.bulk_create(vlan_translation_policies) VLANTranslationPolicy.objects.bulk_create(vlan_translation_policies)
mac_addresses = (
MACAddress(mac_address='00-00-00-00-00-01'),
MACAddress(mac_address='00-00-00-00-00-02'),
MACAddress(mac_address='00-00-00-00-00-03'),
)
MACAddress.objects.bulk_create(mac_addresses)
interfaces = ( interfaces = (
VMInterface( VMInterface(
virtual_machine=vms[0], virtual_machine=vms[0],
name='Interface 1', name='Interface 1',
enabled=True, enabled=True,
mtu=100, mtu=100,
mac_address='00-00-00-00-00-01',
vrf=vrfs[0], vrf=vrfs[0],
description='foobar1', description='foobar1',
vlan_translation_policy=vlan_translation_policies[0], vlan_translation_policy=vlan_translation_policies[0],
@ -595,7 +612,6 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
name='Interface 2', name='Interface 2',
enabled=True, enabled=True,
mtu=200, mtu=200,
mac_address='00-00-00-00-00-02',
vrf=vrfs[1], vrf=vrfs[1],
description='foobar2', description='foobar2',
vlan_translation_policy=vlan_translation_policies[0], vlan_translation_policy=vlan_translation_policies[0],
@ -605,7 +621,6 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
name='Interface 3', name='Interface 3',
enabled=False, enabled=False,
mtu=300, mtu=300,
mac_address='00-00-00-00-00-03',
vrf=vrfs[2], vrf=vrfs[2],
description='foobar3', description='foobar3',
mode=InterfaceModeChoices.MODE_Q_IN_Q, mode=InterfaceModeChoices.MODE_Q_IN_Q,
@ -614,6 +629,10 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
) )
VMInterface.objects.bulk_create(interfaces) VMInterface.objects.bulk_create(interfaces)
interfaces[0].mac_addresses.set([mac_addresses[0]])
interfaces[1].mac_addresses.set([mac_addresses[1]])
interfaces[2].mac_addresses.set([mac_addresses[2]])
def test_q(self): def test_q(self):
params = {'q': 'foobar1'} params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)

View File

@ -1,7 +1,6 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.test import override_settings from django.test import override_settings
from django.urls import reverse from django.urls import reverse
from netaddr import EUI
from dcim.choices import InterfaceModeChoices from dcim.choices import InterfaceModeChoices
from dcim.models import DeviceRole, Platform, Site from dcim.models import DeviceRole, Platform, Site
@ -331,7 +330,6 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
'name': 'Interface X', 'name': 'Interface X',
'enabled': False, 'enabled': False,
'bridge': interfaces[1].pk, 'bridge': interfaces[1].pk,
'mac_address': EUI('01-02-03-04-05-06'),
'mtu': 65000, 'mtu': 65000,
'description': 'New description', 'description': 'New description',
'mode': InterfaceModeChoices.MODE_TAGGED, 'mode': InterfaceModeChoices.MODE_TAGGED,
@ -346,7 +344,6 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
'name': 'Interface [4-6]', 'name': 'Interface [4-6]',
'enabled': False, 'enabled': False,
'bridge': interfaces[3].pk, 'bridge': interfaces[3].pk,
'mac_address': EUI('01-02-03-04-05-06'),
'mtu': 2000, 'mtu': 2000,
'description': 'New description', 'description': 'New description',
'mode': InterfaceModeChoices.MODE_TAGGED, 'mode': InterfaceModeChoices.MODE_TAGGED,