mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-14 01:41:22 -06:00
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:
parent
b4f15092db
commit
353214098b
@ -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.
|
||||
|
||||
### 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
|
||||
|
||||
|
11
docs/models/dcim/macaddress.md
Normal file
11
docs/models/dcim/macaddress.md
Normal 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`).
|
@ -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.
|
||||
|
||||
### 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
|
||||
|
||||
|
@ -21,7 +21,7 @@ from wireless.choices import *
|
||||
from wireless.models import WirelessLAN
|
||||
from .base import ConnectedEndpointsSerializer
|
||||
from .cables import CabledObjectSerializer
|
||||
from .devices import DeviceSerializer, ModuleSerializer, VirtualDeviceContextSerializer
|
||||
from .devices import DeviceSerializer, MACAddressSerializer, ModuleSerializer, VirtualDeviceContextSerializer
|
||||
from .manufacturers import ManufacturerSerializer
|
||||
from .nested import NestedInterfaceSerializer
|
||||
from .roles import InventoryItemRoleSerializer
|
||||
@ -210,24 +210,23 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
|
||||
)
|
||||
count_ipaddresses = serializers.IntegerField(read_only=True)
|
||||
count_fhrp_groups = serializers.IntegerField(read_only=True)
|
||||
mac_address = serializers.CharField(
|
||||
required=False,
|
||||
default=None,
|
||||
allow_blank=True,
|
||||
allow_null=True
|
||||
)
|
||||
# Maintains backward compatibility with NetBox <v4.2
|
||||
mac_address = serializers.CharField(allow_null=True, read_only=True)
|
||||
primary_mac_address = MACAddressSerializer(nested=True, required=False, allow_null=True)
|
||||
mac_addresses = MACAddressSerializer(many=True, nested=True, read_only=True, allow_null=True)
|
||||
wwn = serializers.CharField(required=False, default=None, allow_blank=True, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'enabled',
|
||||
'parent', 'bridge', 'lag', 'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description',
|
||||
'mode', 'rf_role', 'rf_channel', 'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width',
|
||||
'tx_power', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'mark_connected',
|
||||
'cable', 'cable_end', 'wireless_link', 'link_peers', 'link_peers_type', 'wireless_lans', 'vrf',
|
||||
'l2vpn_termination', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable',
|
||||
'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
|
||||
'parent', 'bridge', 'lag', 'mtu', 'mac_address', 'primary_mac_address', 'mac_addresses', 'speed', 'duplex',
|
||||
'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'poe_mode', 'poe_type',
|
||||
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan',
|
||||
'vlan_translation_policy', 'mark_connected', 'cable', 'cable_end', 'wireless_link', 'link_peers',
|
||||
'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints',
|
||||
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
|
||||
'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
|
||||
|
||||
|
@ -1,16 +1,19 @@
|
||||
import decimal
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.translation import gettext as _
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
|
||||
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 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 tenancy.api.serializers_.tenants import TenantSerializer
|
||||
from utilities.api import get_serializer_for_model
|
||||
from virtualization.api.serializers_.clusters import ClusterSerializer
|
||||
from .devicetypes import *
|
||||
from .platforms import PlatformSerializer
|
||||
@ -23,6 +26,7 @@ from .virtualchassis import VirtualChassisSerializer
|
||||
__all__ = (
|
||||
'DeviceSerializer',
|
||||
'DeviceWithConfigContextSerializer',
|
||||
'MACAddressSerializer',
|
||||
'ModuleSerializer',
|
||||
'VirtualDeviceContextSerializer',
|
||||
)
|
||||
@ -153,3 +157,28 @@ class ModuleSerializer(NetBoxModelSerializer):
|
||||
'asset_tag', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
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
|
||||
|
@ -56,6 +56,9 @@ router.register('inventory-items', views.InventoryItemViewSet)
|
||||
# Device component roles
|
||||
router.register('inventory-item-roles', views.InventoryItemRoleViewSet)
|
||||
|
||||
# Addressing
|
||||
router.register('mac-addresses', views.MACAddressViewSet)
|
||||
|
||||
# Cables
|
||||
router.register('cables', views.CableViewSet)
|
||||
router.register('cable-terminations', views.CableTerminationViewSet)
|
||||
|
@ -499,6 +499,16 @@ class InventoryItemRoleViewSet(NetBoxModelViewSet):
|
||||
filterset_class = filtersets.InventoryItemRoleFilterSet
|
||||
|
||||
|
||||
#
|
||||
# Addressing
|
||||
#
|
||||
|
||||
class MACAddressViewSet(NetBoxModelViewSet):
|
||||
queryset = MACAddress.objects.all()
|
||||
serializer_class = serializers.MACAddressSerializer
|
||||
filterset_class = filtersets.MACAddressFilterSet
|
||||
|
||||
|
||||
#
|
||||
# Cables
|
||||
#
|
||||
|
@ -128,3 +128,13 @@ COMPATIBLE_TERMINATION_TYPES = {
|
||||
LOCATION_SCOPE_TYPES = (
|
||||
'region', 'sitegroup', 'site', 'location',
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# MAC addresses
|
||||
#
|
||||
|
||||
MACADDRESS_ASSIGNMENT_MODELS = Q(
|
||||
Q(app_label='dcim', model='interface') |
|
||||
Q(app_label='virtualization', model='vminterface')
|
||||
)
|
||||
|
@ -20,7 +20,7 @@ from utilities.filters import (
|
||||
ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter,
|
||||
NumericArrayFilter, TreeNodeMultipleChoiceFilter,
|
||||
)
|
||||
from virtualization.models import Cluster, ClusterGroup
|
||||
from virtualization.models import Cluster, ClusterGroup, VMInterface, VirtualMachine
|
||||
from vpn.models import L2VPN
|
||||
from wireless.choices import WirelessRoleChoices, WirelessChannelChoices
|
||||
from wireless.models import WirelessLAN, WirelessLink
|
||||
@ -52,6 +52,7 @@ __all__ = (
|
||||
'InventoryItemRoleFilterSet',
|
||||
'InventoryItemTemplateFilterSet',
|
||||
'LocationFilterSet',
|
||||
'MACAddressFilterSet',
|
||||
'ManufacturerFilterSet',
|
||||
'ModuleBayFilterSet',
|
||||
'ModuleBayTemplateFilterSet',
|
||||
@ -1099,7 +1100,7 @@ class DeviceFilterSet(
|
||||
label=_('Is full depth'),
|
||||
)
|
||||
mac_address = MultiValueMACAddressFilter(
|
||||
field_name='interfaces__mac_address',
|
||||
field_name='interfaces__mac_addresses__mac_address',
|
||||
label=_('MAC address'),
|
||||
)
|
||||
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):
|
||||
vlan_id = django_filters.CharFilter(
|
||||
method='filter_vlan_id',
|
||||
@ -1702,7 +1784,21 @@ class InterfaceFilterSet(
|
||||
duplex = django_filters.MultipleChoiceFilter(
|
||||
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()
|
||||
poe_mode = django_filters.MultipleChoiceFilter(
|
||||
choices=InterfacePoEModeChoices
|
||||
|
@ -38,6 +38,7 @@ __all__ = (
|
||||
'InventoryItemRoleBulkEditForm',
|
||||
'InventoryItemTemplateBulkEditForm',
|
||||
'LocationBulkEditForm',
|
||||
'MACAddressBulkEditForm',
|
||||
'ManufacturerBulkEditForm',
|
||||
'ModuleBulkEditForm',
|
||||
'ModuleBayBulkEditForm',
|
||||
@ -1392,9 +1393,9 @@ class PowerOutletBulkEditForm(
|
||||
class InterfaceBulkEditForm(
|
||||
ComponentBulkEditForm,
|
||||
form_from_model(Interface, [
|
||||
'label', 'type', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'mgmt_only',
|
||||
'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width',
|
||||
'tx_power', 'wireless_lans'
|
||||
'label', 'type', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'mtu', 'mgmt_only', 'mark_connected',
|
||||
'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
|
||||
'wireless_lans'
|
||||
])
|
||||
):
|
||||
enabled = forms.NullBooleanField(
|
||||
@ -1506,7 +1507,7 @@ class InterfaceBulkEditForm(
|
||||
model = Interface
|
||||
fieldsets = (
|
||||
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('poe_mode', 'poe_type', name=_('PoE')),
|
||||
FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')),
|
||||
@ -1517,9 +1518,9 @@ class InterfaceBulkEditForm(
|
||||
),
|
||||
)
|
||||
nullable_fields = (
|
||||
'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'vdcs', 'mtu',
|
||||
'description', 'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width',
|
||||
'tx_power', 'untagged_vlan', 'tagged_vlans', 'vrf', 'wireless_lans'
|
||||
'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'vdcs', 'mtu', 'description',
|
||||
'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
|
||||
'untagged_vlan', 'tagged_vlans', 'vrf', 'wireless_lans'
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@ -1719,3 +1720,22 @@ class VirtualDeviceContextBulkEditForm(NetBoxModelBulkEditForm):
|
||||
FieldSet('device', 'status', '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')
|
||||
|
@ -17,7 +17,7 @@ from utilities.forms.fields import (
|
||||
CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, CSVTypedChoiceField,
|
||||
SlugField,
|
||||
)
|
||||
from virtualization.models import Cluster
|
||||
from virtualization.models import Cluster, VMInterface, VirtualMachine
|
||||
from wireless.choices import WirelessRoleChoices
|
||||
from .common import ModuleCommonForm
|
||||
|
||||
@ -34,6 +34,7 @@ __all__ = (
|
||||
'InventoryItemImportForm',
|
||||
'InventoryItemRoleImportForm',
|
||||
'LocationImportForm',
|
||||
'MACAddressImportForm',
|
||||
'ManufacturerImportForm',
|
||||
'ModuleImportForm',
|
||||
'ModuleBayImportForm',
|
||||
@ -906,7 +907,7 @@ class InterfaceImportForm(NetBoxModelImportForm):
|
||||
model = Interface
|
||||
fields = (
|
||||
'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled',
|
||||
'mark_connected', 'mac_address', 'wwn', 'vdcs', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode',
|
||||
'mark_connected', 'wwn', 'vdcs', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode',
|
||||
'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'tags'
|
||||
)
|
||||
|
||||
@ -1167,6 +1168,90 @@ class InventoryItemRoleImportForm(NetBoxModelImportForm):
|
||||
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
|
||||
#
|
||||
|
@ -3,7 +3,9 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.models import MACAddress
|
||||
from utilities.forms import get_field_value
|
||||
from utilities.forms.fields import DynamicModelChoiceField
|
||||
|
||||
__all__ = (
|
||||
'InterfaceCommonForm',
|
||||
@ -12,17 +14,17 @@ __all__ = (
|
||||
|
||||
|
||||
class InterfaceCommonForm(forms.Form):
|
||||
mac_address = forms.CharField(
|
||||
empty_value=None,
|
||||
required=False,
|
||||
label=_('MAC address')
|
||||
)
|
||||
mtu = forms.IntegerField(
|
||||
required=False,
|
||||
min_value=INTERFACE_MTU_MIN,
|
||||
max_value=INTERFACE_MTU_MAX,
|
||||
label=_('MTU')
|
||||
)
|
||||
primary_mac_address = DynamicModelChoiceField(
|
||||
queryset=MACAddress.objects.all(),
|
||||
label=_('Primary MAC address'),
|
||||
required=False
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@ -40,6 +42,10 @@ class InterfaceCommonForm(forms.Form):
|
||||
if interface_mode != InterfaceModeChoices.MODE_Q_IN_Q:
|
||||
del self.fields['qinq_svlan']
|
||||
|
||||
if self.instance and self.instance.pk:
|
||||
filter_name = f'{self._meta.model._meta.model_name}_id'
|
||||
self.fields['primary_mac_address'].widget.add_query_param(filter_name, self.instance.pk)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
|
@ -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.rendering import FieldSet
|
||||
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 wireless.choices import *
|
||||
|
||||
@ -34,6 +34,7 @@ __all__ = (
|
||||
'InventoryItemFilterForm',
|
||||
'InventoryItemRoleFilterForm',
|
||||
'LocationFilterForm',
|
||||
'MACAddressFilterForm',
|
||||
'ManufacturerFilterForm',
|
||||
'ModuleFilterForm',
|
||||
'ModuleBayFilterForm',
|
||||
@ -1574,6 +1575,34 @@ class InventoryItemRoleFilterForm(NetBoxModelFilterSetForm):
|
||||
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
|
||||
#
|
||||
|
@ -18,7 +18,7 @@ from utilities.forms.fields import (
|
||||
)
|
||||
from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups
|
||||
from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, NumberWithOptions, SelectWithPK
|
||||
from virtualization.models import Cluster
|
||||
from virtualization.models import Cluster, VMInterface
|
||||
from wireless.models import WirelessLAN, WirelessLANGroup
|
||||
from .common import InterfaceCommonForm, ModuleCommonForm
|
||||
|
||||
@ -42,6 +42,7 @@ __all__ = (
|
||||
'InventoryItemRoleForm',
|
||||
'InventoryItemTemplateForm',
|
||||
'LocationForm',
|
||||
'MACAddressForm',
|
||||
'ManufacturerForm',
|
||||
'ModuleForm',
|
||||
'ModuleBayForm',
|
||||
@ -1410,7 +1411,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
|
||||
FieldSet(
|
||||
'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('parent', 'bridge', 'lag', name=_('Related Interfaces')),
|
||||
FieldSet('poe_mode', 'poe_type', name=_('PoE')),
|
||||
@ -1427,10 +1428,11 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = [
|
||||
'device', 'module', 'vdcs', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag',
|
||||
'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'poe_mode', 'poe_type', 'mode',
|
||||
'device', 'module', 'vdcs', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge',
|
||||
'lag', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'poe_mode', 'poe_type', 'mode',
|
||||
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans',
|
||||
'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf', 'tags',
|
||||
'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf', 'primary_mac_address',
|
||||
'tags',
|
||||
]
|
||||
widgets = {
|
||||
'speed': NumberWithOptions(
|
||||
@ -1724,3 +1726,72 @@ class VirtualDeviceContextForm(TenancyForm, NetBoxModelForm):
|
||||
'device', 'name', 'status', 'identifier', 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant',
|
||||
'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
|
||||
|
@ -23,6 +23,7 @@ __all__ = (
|
||||
'InventoryItemFilter',
|
||||
'InventoryItemRoleFilter',
|
||||
'LocationFilter',
|
||||
'MACAddressFilter',
|
||||
'ManufacturerFilter',
|
||||
'ModuleFilter',
|
||||
'ModuleBayFilter',
|
||||
@ -133,6 +134,12 @@ class FrontPortTemplateFilter(BaseFilterMixin):
|
||||
pass
|
||||
|
||||
|
||||
@strawberry_django.filter(models.MACAddress, lookups=True)
|
||||
@autotype_decorator(filtersets.MACAddressFilterSet)
|
||||
class MACAddressFilter(BaseFilterMixin):
|
||||
pass
|
||||
|
||||
|
||||
@strawberry_django.filter(models.Interface, lookups=True)
|
||||
@autotype_decorator(filtersets.InterfaceFilterSet)
|
||||
class InterfaceFilter(BaseFilterMixin):
|
||||
|
@ -44,6 +44,9 @@ class DCIMQuery:
|
||||
front_port_template: 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_list: List[InterfaceType] = strawberry_django.field()
|
||||
|
||||
|
@ -34,6 +34,7 @@ __all__ = (
|
||||
'InventoryItemRoleType',
|
||||
'InventoryItemTemplateType',
|
||||
'LocationType',
|
||||
'MACAddressType',
|
||||
'ManufacturerType',
|
||||
'ModularComponentType',
|
||||
'ModuleType',
|
||||
@ -366,6 +367,22 @@ class FrontPortTemplateType(ModularComponentTemplateType):
|
||||
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(
|
||||
models.Interface,
|
||||
exclude=('_path',),
|
||||
@ -373,7 +390,6 @@ class FrontPortTemplateType(ModularComponentTemplateType):
|
||||
)
|
||||
class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, PathEndpointMixin):
|
||||
_name: str
|
||||
mac_address: str | None
|
||||
wwn: str | None
|
||||
parent: Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')] | None
|
||||
bridge: Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')] | None
|
||||
@ -381,6 +397,7 @@ class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, P
|
||||
wireless_link: Annotated["WirelessLinkType", strawberry.lazy('wireless.graphql.types')] | None
|
||||
untagged_vlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
|
||||
vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
|
||||
primary_mac_address: Annotated["MACAddressType", strawberry.lazy('dcim.graphql.types')] | None
|
||||
qinq_svlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
|
||||
vlan_translation_policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] | None
|
||||
|
||||
@ -390,6 +407,7 @@ class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, P
|
||||
wireless_lans: List[Annotated["WirelessLANType", strawberry.lazy('wireless.graphql.types')]]
|
||||
member_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(
|
||||
|
36
netbox/dcim/migrations/0199_macaddress.py
Normal file
36
netbox/dcim/migrations/0199_macaddress.py
Normal 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',)
|
||||
},
|
||||
),
|
||||
]
|
52
netbox/dcim/migrations/0200_populate_mac_addresses.py
Normal file
52
netbox/dcim/migrations/0200_populate_mac_addresses.py
Normal 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',
|
||||
),
|
||||
]
|
@ -10,7 +10,7 @@ from mptt.models import MPTTModel, TreeForeignKey
|
||||
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.fields import MACAddressField, WWNField
|
||||
from dcim.fields import WWNField
|
||||
from netbox.choices import ColorChoices
|
||||
from netbox.models import OrganizationalModel, NetBoxModel
|
||||
from utilities.fields import ColorField, NaturalOrderingField
|
||||
@ -505,11 +505,6 @@ class BaseInterface(models.Model):
|
||||
verbose_name=_('enabled'),
|
||||
default=True
|
||||
)
|
||||
mac_address = MACAddressField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_('MAC address')
|
||||
)
|
||||
mtu = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
@ -572,6 +567,14 @@ class BaseInterface(models.Model):
|
||||
blank=True,
|
||||
verbose_name=_('VLAN Translation Policy')
|
||||
)
|
||||
primary_mac_address = models.OneToOneField(
|
||||
to='dcim.MACAddress',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('primary MAC address')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
@ -585,6 +588,14 @@ class BaseInterface(models.Model):
|
||||
'qinq_svlan': _("Only Q-in-Q interfaces may specify a service VLAN.")
|
||||
})
|
||||
|
||||
# Check that the primary MAC address (if any) is assigned to this interface
|
||||
if self.primary_mac_address and self.primary_mac_address.assigned_object != self:
|
||||
raise ValidationError({
|
||||
'primary_mac_address': _("MAC address {mac_address} is not assigned to this interface.").format(
|
||||
mac_address=self.primary_mac_address
|
||||
)
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# Remove untagged VLAN assignment for non-802.1Q interfaces
|
||||
@ -609,6 +620,11 @@ class BaseInterface(models.Model):
|
||||
def count_fhrp_groups(self):
|
||||
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):
|
||||
"""
|
||||
@ -738,6 +754,12 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
|
||||
object_id_field='assigned_object_id',
|
||||
related_query_name='interface'
|
||||
)
|
||||
mac_addresses = GenericRelation(
|
||||
to='dcim.MACAddress',
|
||||
content_type_field='assigned_object_type',
|
||||
object_id_field='assigned_object_id',
|
||||
related_query_name='interface'
|
||||
)
|
||||
fhrp_group_assignments = GenericRelation(
|
||||
to='ipam.FHRPGroupAssignment',
|
||||
content_type_field='interface_type',
|
||||
|
@ -3,6 +3,7 @@ import yaml
|
||||
|
||||
from functools import cached_property
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.storage import default_storage
|
||||
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.constants import *
|
||||
from dcim.fields import MACAddressField
|
||||
from extras.models import ConfigContextModel, CustomField
|
||||
from extras.querysets import ConfigContextModelQuerySet
|
||||
from netbox.choices import ColorChoices
|
||||
@ -33,6 +35,7 @@ __all__ = (
|
||||
'Device',
|
||||
'DeviceRole',
|
||||
'DeviceType',
|
||||
'MACAddress',
|
||||
'Manufacturer',
|
||||
'Module',
|
||||
'ModuleType',
|
||||
@ -1470,3 +1473,37 @@ class VirtualDeviceContext(PrimaryModel):
|
||||
raise ValidationError({
|
||||
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)
|
||||
|
@ -98,19 +98,28 @@ class FrontPortIndex(SearchIndex):
|
||||
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
|
||||
class InterfaceIndex(SearchIndex):
|
||||
model = models.Interface
|
||||
fields = (
|
||||
('name', 100),
|
||||
('label', 200),
|
||||
('mac_address', 300),
|
||||
('wwn', 300),
|
||||
('description', 500),
|
||||
('mtu', 2000),
|
||||
('speed', 2000),
|
||||
)
|
||||
display_attrs = ('device', 'label', 'type', 'mac_address', 'wwn', 'description')
|
||||
display_attrs = ('device', 'label', 'type', 'wwn', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
|
@ -29,6 +29,7 @@ __all__ = (
|
||||
'InterfaceTable',
|
||||
'InventoryItemRoleTable',
|
||||
'InventoryItemTable',
|
||||
'MACAddressTable',
|
||||
'ModuleBayTable',
|
||||
'PlatformTable',
|
||||
'PowerOutletTable',
|
||||
@ -42,6 +43,16 @@ MODULEBAY_STATUS = """
|
||||
{% 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
|
||||
@ -588,6 +599,10 @@ class BaseInterfaceTable(NetBoxTable):
|
||||
verbose_name=_('Q-in-Q SVLAN'),
|
||||
linkify=True
|
||||
)
|
||||
primary_mac_address = tables.Column(
|
||||
verbose_name=_('MAC Address'),
|
||||
linkify=True
|
||||
)
|
||||
|
||||
def value_ip_addresses(self, value):
|
||||
return ",".join([str(obj.address) for obj in value.all()])
|
||||
@ -638,11 +653,11 @@ class InterfaceTable(BaseInterfaceTable, ModularDeviceComponentTable, PathEndpoi
|
||||
model = models.Interface
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu',
|
||||
'speed', 'speed_formatted', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role',
|
||||
'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected',
|
||||
'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf',
|
||||
'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan',
|
||||
'inventory_items', 'created', 'last_updated',
|
||||
'speed', 'speed_formatted', 'duplex', 'mode', 'primary_mac_address', 'wwn', 'poe_mode', 'poe_type',
|
||||
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description',
|
||||
'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection',
|
||||
'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans',
|
||||
'qinq_svlan', 'inventory_items', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
|
||||
|
||||
@ -1098,3 +1113,34 @@ class VirtualDeviceContextTable(TenancyColumnsMixin, NetBoxTable):
|
||||
default_columns = (
|
||||
'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')
|
||||
|
@ -314,6 +314,9 @@ INTERFACE_BUTTONS = """
|
||||
{% 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>
|
||||
{% 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 %}
|
||||
<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 %}
|
||||
|
@ -9,8 +9,8 @@ from ipam.models import ASN, IPAddress, RIR, VLAN, VLANTranslationPolicy, VRF
|
||||
from netbox.choices import ColorChoices, WeightUnitChoices
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from users.models import User
|
||||
from utilities.testing import ChangeLoggedFilterSetTests, create_test_device
|
||||
from virtualization.models import Cluster, ClusterType, ClusterGroup
|
||||
from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
|
||||
from virtualization.models import Cluster, ClusterType, ClusterGroup, VMInterface, VirtualMachine
|
||||
from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
|
||||
|
||||
|
||||
@ -2323,10 +2323,17 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
PowerOutlet(device=devices[1], name='Power Outlet 2'),
|
||||
))
|
||||
interfaces = (
|
||||
Interface(device=devices[0], name='Interface 1', mac_address='00-00-00-00-00-01'),
|
||||
Interface(device=devices[1], name='Interface 2', mac_address='00-00-00-00-00-02'),
|
||||
Interface(device=devices[0], name='Interface 1'),
|
||||
Interface(device=devices[1], name='Interface 2'),
|
||||
)
|
||||
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 = (
|
||||
RearPort(device=devices[0], name='Rear Port 1', 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)
|
||||
|
||||
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 = (
|
||||
VLAN(name='SVLAN 1', vid=1001, 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,
|
||||
mtu=100,
|
||||
mode=InterfaceModeChoices.MODE_ACCESS,
|
||||
mac_address='00-00-00-00-00-01',
|
||||
description='First',
|
||||
vrf=vrfs[0],
|
||||
speed=1000000,
|
||||
@ -3721,7 +3734,6 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
mgmt_only=True,
|
||||
mtu=200,
|
||||
mode=InterfaceModeChoices.MODE_TAGGED,
|
||||
mac_address='00-00-00-00-00-02',
|
||||
description='Second',
|
||||
vrf=vrfs[1],
|
||||
speed=1000000,
|
||||
@ -3740,7 +3752,6 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
mgmt_only=False,
|
||||
mtu=300,
|
||||
mode=InterfaceModeChoices.MODE_TAGGED_ALL,
|
||||
mac_address='00-00-00-00-00-03',
|
||||
description='Third',
|
||||
vrf=vrfs[2],
|
||||
speed=100000,
|
||||
@ -3814,6 +3825,10 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
interfaces[6].vdcs.set([vdcs[0]])
|
||||
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
|
||||
Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[5]]).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)
|
||||
params = {'primary_ip6_id': [addresses[2].pk]}
|
||||
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)
|
||||
|
@ -2508,7 +2508,6 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
'enabled': False,
|
||||
'bridge': interfaces[4].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),
|
||||
'mtu': 65000,
|
||||
'speed': 1000000,
|
||||
@ -2533,7 +2532,6 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
'enabled': False,
|
||||
'bridge': interfaces[4].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),
|
||||
'mtu': 2000,
|
||||
'speed': 100000,
|
||||
@ -2554,7 +2552,6 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
'type': InterfaceTypeChoices.TYPE_1GE_FIXED,
|
||||
'enabled': True,
|
||||
'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),
|
||||
'mtu': 2000,
|
||||
'speed': 1000000,
|
||||
|
@ -250,6 +250,14 @@ urlpatterns = [
|
||||
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'),
|
||||
|
||||
# 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
|
||||
path('interfaces/', views.InterfaceListView.as_view(), name='interface_list'),
|
||||
path('interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'),
|
||||
|
@ -2538,6 +2538,51 @@ class PowerOutletBulkDeleteView(generic.BulkDeleteView):
|
||||
register_model_view(PowerOutlet, 'trace', kwargs={'model': PowerOutlet})(PathTraceView)
|
||||
|
||||
|
||||
#
|
||||
# MAC addresses
|
||||
#
|
||||
|
||||
class MACAddressListView(generic.ObjectListView):
|
||||
queryset = MACAddress.objects.all()
|
||||
filterset = filtersets.MACAddressFilterSet
|
||||
filterset_form = forms.MACAddressFilterForm
|
||||
table = tables.MACAddressTable
|
||||
|
||||
|
||||
@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
|
||||
#
|
||||
@ -2571,7 +2616,7 @@ class InterfaceView(generic.ObjectView):
|
||||
|
||||
# Get bridge interfaces
|
||||
bridge_interfaces = Interface.objects.restrict(request.user, 'view').filter(bridge=instance)
|
||||
bridge_interfaces_tables = tables.InterfaceTable(
|
||||
bridge_interfaces_table = tables.InterfaceTable(
|
||||
bridge_interfaces,
|
||||
exclude=('device', 'parent'),
|
||||
orderable=False
|
||||
@ -2579,7 +2624,7 @@ class InterfaceView(generic.ObjectView):
|
||||
|
||||
# Get child interfaces
|
||||
child_interfaces = Interface.objects.restrict(request.user, 'view').filter(parent=instance)
|
||||
child_interfaces_tables = tables.InterfaceTable(
|
||||
child_interfaces_table = tables.InterfaceTable(
|
||||
child_interfaces,
|
||||
exclude=('device', 'parent'),
|
||||
orderable=False
|
||||
@ -2609,8 +2654,8 @@ class InterfaceView(generic.ObjectView):
|
||||
|
||||
return {
|
||||
'vdc_table': vdc_table,
|
||||
'bridge_interfaces_table': bridge_interfaces_tables,
|
||||
'child_interfaces_table': child_interfaces_tables,
|
||||
'bridge_interfaces_table': bridge_interfaces_table,
|
||||
'child_interfaces_table': child_interfaces_table,
|
||||
'vlan_table': vlan_table,
|
||||
'vlan_translation_table': vlan_translation_table,
|
||||
}
|
||||
|
@ -1135,6 +1135,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
'l2vpn',
|
||||
'l2vpntermination',
|
||||
'location',
|
||||
'macaddress',
|
||||
'manufacturer',
|
||||
'module',
|
||||
'modulebay',
|
||||
|
@ -179,6 +179,8 @@ class BaseFilterSet(django_filters.FilterSet):
|
||||
# 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
|
||||
# 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
|
||||
filter_cls = type(existing_filter)
|
||||
if lookup_expr == 'empty':
|
||||
|
@ -88,6 +88,12 @@ DEVICES_MENU = Menu(
|
||||
get_model_item('dcim', 'manufacturer', _('Manufacturers')),
|
||||
),
|
||||
),
|
||||
MenuGroup(
|
||||
label=_('Addressing'),
|
||||
items=(
|
||||
get_model_item('dcim', 'macaddress', _('MAC Addresses')),
|
||||
),
|
||||
),
|
||||
MenuGroup(
|
||||
label=_('Device Components'),
|
||||
items=(
|
||||
|
@ -123,11 +123,24 @@
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<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>
|
||||
<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>
|
||||
<th scope="row">{% trans "VRF" %}</th>
|
||||
@ -350,7 +363,23 @@
|
||||
{% endif %}
|
||||
</h2>
|
||||
{% 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>
|
||||
|
55
netbox/templates/dcim/macaddress.html
Normal file
55
netbox/templates/dcim/macaddress.html
Normal 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 %}
|
@ -14,73 +14,85 @@
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Interface" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Virtual Machine" %}</th>
|
||||
<td>{{ object.virtual_machine|linkify }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Name" %}</th>
|
||||
<td>{{ object.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Enabled" %}</th>
|
||||
<td>
|
||||
{% if object.enabled %}
|
||||
<span class="text-success"><i class="mdi mdi-check-bold"></i></span>
|
||||
{% else %}
|
||||
<span class="text-danger"><i class="mdi mdi-close"></i></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Parent" %}</th>
|
||||
<td>{{ object.parent|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Bridge" %}</th>
|
||||
<td>{{ object.bridge|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "VRF" %}</th>
|
||||
<td>{{ object.vrf|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }} </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "MTU" %}</th>
|
||||
<td>{{ object.mtu|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "MAC Address" %}</th>
|
||||
<td><span class="font-monospace">{{ object.mac_address|placeholder }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "802.1Q Mode" %}</th>
|
||||
<td>{{ object.get_mode_display|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Tunnel" %}</th>
|
||||
<td>{{ object.tunnel_termination.tunnel|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "VLAN Translation" %}</th>
|
||||
<td>{{ object.vlan_translation_policy|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% plugin_left_page object %}
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Interface" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Virtual Machine" %}</th>
|
||||
<td>{{ object.virtual_machine|linkify }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Name" %}</th>
|
||||
<td>{{ object.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Enabled" %}</th>
|
||||
<td>
|
||||
{% if object.enabled %}
|
||||
<span class="text-success"><i class="mdi mdi-check-bold"></i></span>
|
||||
{% else %}
|
||||
<span class="text-danger"><i class="mdi mdi-close"></i></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Parent" %}</th>
|
||||
<td>{{ object.parent|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Bridge" %}</th>
|
||||
<td>{{ object.bridge|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }} </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "MTU" %}</th>
|
||||
<td>{{ object.mtu|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "802.1Q Mode" %}</th>
|
||||
<td>{{ object.get_mode_display|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Tunnel" %}</th>
|
||||
<td>{{ object.tunnel_termination.tunnel|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% include 'ipam/inc/panels/fhrp_groups.html' %}
|
||||
{% plugin_right_page object %}
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<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>
|
||||
{% include 'ipam/inc/panels/fhrp_groups.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-12">
|
||||
@ -99,6 +111,24 @@
|
||||
</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="col col-md-12">
|
||||
{% include 'inc/panel_table.html' with table=vlan_table heading="VLANs" %}
|
||||
|
@ -9,7 +9,7 @@ from dcim.choices import *
|
||||
from dcim.fields import MACAddressField
|
||||
from dcim.filtersets import DeviceFilterSet, SiteFilterSet, InterfaceFilterSet
|
||||
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.models import TaggedItem
|
||||
@ -433,16 +433,33 @@ class DynamicFilterLookupExpressionTest(TestCase):
|
||||
)
|
||||
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 = (
|
||||
Interface(device=devices[0], name='Interface 1', mac_address='00-00-00-00-00-01'),
|
||||
Interface(device=devices[0], name='Interface 2', mac_address='aa-00-00-00-00-01'),
|
||||
Interface(device=devices[1], name='Interface 3', mac_address='00-00-00-00-00-02'),
|
||||
Interface(device=devices[1], name='Interface 4', mac_address='bb-00-00-00-00-02'),
|
||||
Interface(device=devices[2], name='Interface 5', mac_address='00-00-00-00-00-03'),
|
||||
Interface(device=devices[2], name='Interface 6', mac_address='cc-00-00-00-00-03', rf_role=WirelessRoleChoices.ROLE_AP),
|
||||
Interface(device=devices[0], name='Interface 1'),
|
||||
Interface(device=devices[0], name='Interface 2'),
|
||||
Interface(device=devices[1], name='Interface 3'),
|
||||
Interface(device=devices[1], name='Interface 4'),
|
||||
Interface(device=devices[2], name='Interface 5'),
|
||||
Interface(device=devices[2], name='Interface 6', rf_role=WirelessRoleChoices.ROLE_AP),
|
||||
)
|
||||
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):
|
||||
params = {'name__n': ['Site 1']}
|
||||
self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 2)
|
||||
|
@ -2,6 +2,7 @@ from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
|
||||
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_.roles import DeviceRoleSerializer
|
||||
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)
|
||||
count_ipaddresses = serializers.IntegerField(read_only=True)
|
||||
count_fhrp_groups = serializers.IntegerField(read_only=True)
|
||||
mac_address = serializers.CharField(
|
||||
required=False,
|
||||
default=None,
|
||||
allow_null=True
|
||||
)
|
||||
# Maintains backward compatibility with NetBox <v4.2
|
||||
mac_address = serializers.CharField(allow_null=True, read_only=True)
|
||||
primary_mac_address = MACAddressSerializer(nested=True, required=False, allow_null=True)
|
||||
mac_addresses = MACAddressSerializer(many=True, nested=True, read_only=True, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = VMInterface
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'bridge', 'mtu',
|
||||
'mac_address', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan',
|
||||
'vlan_translation_policy', 'vrf', 'l2vpn_termination', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
'count_ipaddresses', 'count_fhrp_groups',
|
||||
'mac_address', 'primary_mac_address', 'mac_addresses', 'description', 'mode', 'untagged_vlan',
|
||||
'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf', 'l2vpn_termination', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'virtual_machine', 'name', 'description')
|
||||
|
||||
|
@ -2,9 +2,10 @@ import django_filters
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from dcim.filtersets import CommonInterfaceFilterSet
|
||||
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 MACAddress
|
||||
from extras.filtersets import LocalConfigContextFilterSet
|
||||
from extras.models import ConfigTemplate
|
||||
from ipam.filtersets import PrimaryIPFilterSet
|
||||
@ -191,7 +192,7 @@ class VirtualMachineFilterSet(
|
||||
label=_('Platform (slug)'),
|
||||
)
|
||||
mac_address = MultiValueMACAddressFilter(
|
||||
field_name='interfaces__mac_address',
|
||||
field_name='interfaces__mac_addresses__mac_address',
|
||||
label=_('MAC address'),
|
||||
)
|
||||
has_primary_ip = django_filters.BooleanFilter(
|
||||
@ -263,8 +264,20 @@ class VMInterfaceFilterSet(NetBoxModelFilterSet, CommonInterfaceFilterSet):
|
||||
label=_('Bridged interface (ID)'),
|
||||
)
|
||||
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'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = VMInterface
|
||||
|
@ -182,7 +182,7 @@ class VMInterfaceImportForm(NetBoxModelImportForm):
|
||||
class Meta:
|
||||
model = VMInterface
|
||||
fields = (
|
||||
'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode',
|
||||
'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mtu', 'description', 'mode',
|
||||
'vrf', 'tags'
|
||||
)
|
||||
|
||||
|
@ -360,7 +360,7 @@ class VMInterfaceForm(InterfaceCommonForm, VMComponentForm):
|
||||
|
||||
fieldsets = (
|
||||
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('parent', 'bridge', name=_('Related Interfaces')),
|
||||
FieldSet(
|
||||
@ -372,8 +372,9 @@ class VMInterfaceForm(InterfaceCommonForm, VMComponentForm):
|
||||
class Meta:
|
||||
model = VMInterface
|
||||
fields = [
|
||||
'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode',
|
||||
'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf', 'tags',
|
||||
'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mtu', 'description', 'mode', 'vlan_group',
|
||||
'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf', 'primary_mac_address',
|
||||
'tags',
|
||||
]
|
||||
labels = {
|
||||
'mode': _('802.1Q Mode'),
|
||||
|
@ -106,12 +106,14 @@ class VMInterfaceType(IPAddressesMixin, ComponentType):
|
||||
bridge: Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')] | None
|
||||
untagged_vlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
|
||||
vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
|
||||
primary_mac_address: Annotated["MACAddressType", strawberry.lazy('dcim.graphql.types')] | None
|
||||
qinq_svlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
|
||||
vlan_translation_policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] | None
|
||||
|
||||
tagged_vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]]
|
||||
bridge_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(
|
||||
|
@ -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',
|
||||
),
|
||||
]
|
@ -348,6 +348,12 @@ class VMInterface(ComponentModel, BaseInterface, TrackingModelMixin):
|
||||
object_id_field='assigned_object_id',
|
||||
related_query_name='vminterface',
|
||||
)
|
||||
mac_addresses = GenericRelation(
|
||||
to='dcim.MACAddress',
|
||||
content_type_field='assigned_object_type',
|
||||
object_id_field='assigned_object_id',
|
||||
related_query_name='vminterface'
|
||||
)
|
||||
|
||||
class Meta(ComponentModel.Meta):
|
||||
verbose_name = _('interface')
|
||||
|
@ -52,11 +52,10 @@ class VMInterfaceIndex(SearchIndex):
|
||||
model = models.VMInterface
|
||||
fields = (
|
||||
('name', 100),
|
||||
('mac_address', 300),
|
||||
('description', 500),
|
||||
('mtu', 2000),
|
||||
)
|
||||
display_attrs = ('virtual_machine', 'mac_address', 'description')
|
||||
display_attrs = ('virtual_machine', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
|
@ -25,6 +25,9 @@ VMINTERFACE_BUTTONS = """
|
||||
{% 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>
|
||||
{% 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 %}
|
||||
<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 %}
|
||||
@ -150,8 +153,8 @@ class VMInterfaceTable(BaseInterfaceTable):
|
||||
model = VMInterface
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags',
|
||||
'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan',
|
||||
'created', 'last_updated',
|
||||
'vrf', 'primary_mac_address', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan',
|
||||
'tagged_vlans', 'qinq_svlan', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description')
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
from django.test import TestCase
|
||||
|
||||
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.models import IPAddress, VLAN, VLANTranslationPolicy, VRF
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
@ -366,13 +366,24 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
)
|
||||
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 = (
|
||||
VMInterface(virtual_machine=vms[0], name='Interface 1', mac_address='00-00-00-00-00-01'),
|
||||
VMInterface(virtual_machine=vms[1], name='Interface 2', mac_address='00-00-00-00-00-02'),
|
||||
VMInterface(virtual_machine=vms[2], name='Interface 3', mac_address='00-00-00-00-00-03'),
|
||||
VMInterface(virtual_machine=vms[0], name='Interface 1'),
|
||||
VMInterface(virtual_machine=vms[1], name='Interface 2'),
|
||||
VMInterface(virtual_machine=vms[2], name='Interface 3'),
|
||||
)
|
||||
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
|
||||
ipaddresses = (
|
||||
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)
|
||||
|
||||
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 = (
|
||||
VMInterface(
|
||||
virtual_machine=vms[0],
|
||||
name='Interface 1',
|
||||
enabled=True,
|
||||
mtu=100,
|
||||
mac_address='00-00-00-00-00-01',
|
||||
vrf=vrfs[0],
|
||||
description='foobar1',
|
||||
vlan_translation_policy=vlan_translation_policies[0],
|
||||
@ -595,7 +612,6 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
name='Interface 2',
|
||||
enabled=True,
|
||||
mtu=200,
|
||||
mac_address='00-00-00-00-00-02',
|
||||
vrf=vrfs[1],
|
||||
description='foobar2',
|
||||
vlan_translation_policy=vlan_translation_policies[0],
|
||||
@ -605,7 +621,6 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
name='Interface 3',
|
||||
enabled=False,
|
||||
mtu=300,
|
||||
mac_address='00-00-00-00-00-03',
|
||||
vrf=vrfs[2],
|
||||
description='foobar3',
|
||||
mode=InterfaceModeChoices.MODE_Q_IN_Q,
|
||||
@ -614,6 +629,10 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
)
|
||||
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):
|
||||
params = {'q': 'foobar1'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
@ -1,7 +1,6 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from netaddr import EUI
|
||||
|
||||
from dcim.choices import InterfaceModeChoices
|
||||
from dcim.models import DeviceRole, Platform, Site
|
||||
@ -331,7 +330,6 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
'name': 'Interface X',
|
||||
'enabled': False,
|
||||
'bridge': interfaces[1].pk,
|
||||
'mac_address': EUI('01-02-03-04-05-06'),
|
||||
'mtu': 65000,
|
||||
'description': 'New description',
|
||||
'mode': InterfaceModeChoices.MODE_TAGGED,
|
||||
@ -346,7 +344,6 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
'name': 'Interface [4-6]',
|
||||
'enabled': False,
|
||||
'bridge': interfaces[3].pk,
|
||||
'mac_address': EUI('01-02-03-04-05-06'),
|
||||
'mtu': 2000,
|
||||
'description': 'New description',
|
||||
'mode': InterfaceModeChoices.MODE_TAGGED,
|
||||
|
Loading…
Reference in New Issue
Block a user