From 353214098bce2bfbf2e2d7e01ae39438999ff0be Mon Sep 17 00:00:00 2001 From: bctiemann Date: Mon, 18 Nov 2024 15:11:24 -0500 Subject: [PATCH] 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 --- docs/models/dcim/interface.md | 7 +- docs/models/dcim/macaddress.md | 11 ++ docs/models/virtualization/vminterface.md | 7 +- .../api/serializers_/device_components.py | 25 ++- netbox/dcim/api/serializers_/devices.py | 33 +++- netbox/dcim/api/urls.py | 3 + netbox/dcim/api/views.py | 10 ++ netbox/dcim/constants.py | 10 ++ netbox/dcim/filtersets.py | 102 ++++++++++- netbox/dcim/forms/bulk_edit.py | 34 +++- netbox/dcim/forms/bulk_import.py | 89 +++++++++- netbox/dcim/forms/common.py | 16 +- netbox/dcim/forms/filtersets.py | 31 +++- netbox/dcim/forms/model_forms.py | 81 ++++++++- netbox/dcim/graphql/filters.py | 7 + netbox/dcim/graphql/schema.py | 3 + netbox/dcim/graphql/types.py | 20 ++- netbox/dcim/migrations/0199_macaddress.py | 36 ++++ .../migrations/0200_populate_mac_addresses.py | 52 ++++++ netbox/dcim/models/device_components.py | 34 +++- netbox/dcim/models/devices.py | 37 ++++ netbox/dcim/search.py | 13 +- netbox/dcim/tables/devices.py | 56 +++++- netbox/dcim/tables/template_code.py | 3 + netbox/dcim/tests/test_filtersets.py | 106 +++++++++++- netbox/dcim/tests/test_views.py | 3 - netbox/dcim/urls.py | 8 + netbox/dcim/views.py | 53 +++++- netbox/extras/tests/test_filtersets.py | 1 + netbox/netbox/filtersets.py | 2 + netbox/netbox/navigation/menu.py | 6 + netbox/templates/dcim/interface.html | 35 +++- netbox/templates/dcim/macaddress.html | 55 ++++++ .../templates/virtualization/vminterface.html | 160 +++++++++++------- netbox/utilities/tests/test_filters.py | 31 +++- .../api/serializers_/virtualmachines.py | 16 +- netbox/virtualization/filtersets.py | 17 +- netbox/virtualization/forms/bulk_import.py | 2 +- netbox/virtualization/forms/model_forms.py | 7 +- netbox/virtualization/graphql/types.py | 2 + .../migrations/0048_populate_mac_addresses.py | 53 ++++++ .../virtualization/models/virtualmachines.py | 6 + netbox/virtualization/search.py | 3 +- .../virtualization/tables/virtualmachines.py | 7 +- .../virtualization/tests/test_filtersets.py | 33 +++- netbox/virtualization/tests/test_views.py | 3 - 46 files changed, 1156 insertions(+), 173 deletions(-) create mode 100644 docs/models/dcim/macaddress.md create mode 100644 netbox/dcim/migrations/0199_macaddress.py create mode 100644 netbox/dcim/migrations/0200_populate_mac_addresses.py create mode 100644 netbox/templates/dcim/macaddress.html create mode 100644 netbox/virtualization/migrations/0048_populate_mac_addresses.py diff --git a/docs/models/dcim/interface.md b/docs/models/dcim/interface.md index fb7198682..f2af1a2ad 100644 --- a/docs/models/dcim/interface.md +++ b/docs/models/dcim/interface.md @@ -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 diff --git a/docs/models/dcim/macaddress.md b/docs/models/dcim/macaddress.md new file mode 100644 index 000000000..fe3d1f0e3 --- /dev/null +++ b/docs/models/dcim/macaddress.md @@ -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`). diff --git a/docs/models/virtualization/vminterface.md b/docs/models/virtualization/vminterface.md index 4a0c474f9..6617b5e59 100644 --- a/docs/models/virtualization/vminterface.md +++ b/docs/models/virtualization/vminterface.md @@ -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 diff --git a/netbox/dcim/api/serializers_/device_components.py b/netbox/dcim/api/serializers_/device_components.py index 57111c2af..12133ec65 100644 --- a/netbox/dcim/api/serializers_/device_components.py +++ b/netbox/dcim/api/serializers_/device_components.py @@ -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 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 diff --git a/netbox/dcim/graphql/filters.py b/netbox/dcim/graphql/filters.py index 8c256aecb..94f2c6d38 100644 --- a/netbox/dcim/graphql/filters.py +++ b/netbox/dcim/graphql/filters.py @@ -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): diff --git a/netbox/dcim/graphql/schema.py b/netbox/dcim/graphql/schema.py index 65818fb20..011a2b58b 100644 --- a/netbox/dcim/graphql/schema.py +++ b/netbox/dcim/graphql/schema.py @@ -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() diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index cc1bcac0f..7aa4ef8a0 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -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( diff --git a/netbox/dcim/migrations/0199_macaddress.py b/netbox/dcim/migrations/0199_macaddress.py new file mode 100644 index 000000000..8068c7436 --- /dev/null +++ b/netbox/dcim/migrations/0199_macaddress.py @@ -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',) + }, + ), + ] diff --git a/netbox/dcim/migrations/0200_populate_mac_addresses.py b/netbox/dcim/migrations/0200_populate_mac_addresses.py new file mode 100644 index 000000000..1f3c5dee9 --- /dev/null +++ b/netbox/dcim/migrations/0200_populate_mac_addresses.py @@ -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', + ), + ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 31278a13c..9b108bcca 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -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', diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index a836c5d37..b49d680a3 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -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) diff --git a/netbox/dcim/search.py b/netbox/dcim/search.py index 45431cb05..6b03d8b43 100644 --- a/netbox/dcim/search.py +++ b/netbox/dcim/search.py @@ -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 diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index b7634626d..d226c7335 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -29,6 +29,7 @@ __all__ = ( 'InterfaceTable', 'InventoryItemRoleTable', 'InventoryItemTable', + 'MACAddressTable', 'ModuleBayTable', 'PlatformTable', 'PowerOutletTable', @@ -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 %} + {{ record.mac_address }} +{% 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') diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 96ab803e6..dc9724e3c 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -314,6 +314,9 @@ INTERFACE_BUTTONS = """ {% if perms.ipam.add_ipaddress %}
  • IP Address
  • {% endif %} + {% if perms.dcim.add_macaddress %} +
  • MAC Address
  • + {% endif %} {% if perms.dcim.add_inventoryitem %}
  • Inventory Item
  • {% endif %} diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 993c2fa4e..897612b84 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -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) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index dc3e74ae1..9850081d1 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -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, diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 627136bf9..1a6a2f77d 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -250,6 +250,14 @@ urlpatterns = [ path('power-outlets//', include(get_model_urls('dcim', 'poweroutlet'))), path('devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'), + # MAC addresses + path('mac-addresses/', views.MACAddressListView.as_view(), name='macaddress_list'), + path('mac-addresses/add/', views.MACAddressEditView.as_view(), name='macaddress_add'), + path('mac-addresses/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//', include(get_model_urls('dcim', 'macaddress'))), + # Interfaces path('interfaces/', views.InterfaceListView.as_view(), name='interface_list'), path('interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 7a5a771a9..4bd0ea877 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -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, } diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index c9eaa3e0e..b3d576cb9 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -1135,6 +1135,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests): 'l2vpn', 'l2vpntermination', 'location', + 'macaddress', 'manufacturer', 'module', 'modulebay', diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index 637a40bf1..657f2f71a 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -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': diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 737e399a5..1c4f9bf88 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -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=( diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index b18a6380b..f364b12bc 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -123,11 +123,24 @@ - + - + @@ -350,7 +363,23 @@ {% endif %} {% htmx_table 'ipam:ipaddress_list' interface_id=object.pk %} - + + + +
    +
    +
    +

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

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

    {% trans "MAC Address" %}

    +
    {% trans "MAC Address" %}{{ object.mac_address|placeholder }} + {% if object.mac_address %} + {{ object.mac_address }} + {% trans "Primary" %} + {% else %} + {{ ''|placeholder }} + {% endif %} +
    {% trans "WWN" %}{{ object.wwn|placeholder }} + {% if object.wwn %} + {{ object.wwn }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
    {% trans "VRF" %}
    + + + + + + + + + + + + + + + + +
    {% trans "MAC Address" %} + {{ object.mac_address|placeholder }} + {% copy_content object.pk prefix="macaddress_" %} +
    {% trans "Description" %}{{ object.description|placeholder }}
    {% trans "Assignment" %} + {% if object.assigned_object %} + {{ object.assigned_object.parent_object|linkify }} / + {{ object.assigned_object|linkify }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
    {% trans "Primary for interface" %}{% checkmark object.is_primary %}
    + + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% plugin_left_page object %} + +
    + {% include 'inc/panels/comments.html' %} + {% plugin_right_page object %} +
    + +
    +
    + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/virtualization/vminterface.html b/netbox/templates/virtualization/vminterface.html index 13cc8aa2f..88c9379cf 100644 --- a/netbox/templates/virtualization/vminterface.html +++ b/netbox/templates/virtualization/vminterface.html @@ -14,73 +14,85 @@ {% block content %}
    -
    -

    {% trans "Interface" %}

    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    {% trans "Virtual Machine" %}{{ object.virtual_machine|linkify }}
    {% trans "Name" %}{{ object.name }}
    {% trans "Enabled" %} - {% if object.enabled %} - - {% else %} - - {% endif %} -
    {% trans "Parent" %}{{ object.parent|linkify|placeholder }}
    {% trans "Bridge" %}{{ object.bridge|linkify|placeholder }}
    {% trans "VRF" %}{{ object.vrf|linkify|placeholder }}
    {% trans "Description" %}{{ object.description|placeholder }}
    {% trans "MTU" %}{{ object.mtu|placeholder }}
    {% trans "MAC Address" %}{{ object.mac_address|placeholder }}
    {% trans "802.1Q Mode" %}{{ object.get_mode_display|placeholder }}
    {% trans "Tunnel" %}{{ object.tunnel_termination.tunnel|linkify|placeholder }}
    {% trans "VLAN Translation" %}{{ object.vlan_translation_policy|linkify|placeholder }}
    -
    - {% include 'inc/panels/tags.html' %} - {% plugin_left_page object %} +
    +

    {% trans "Interface" %}

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    {% trans "Virtual Machine" %}{{ object.virtual_machine|linkify }}
    {% trans "Name" %}{{ object.name }}
    {% trans "Enabled" %} + {% if object.enabled %} + + {% else %} + + {% endif %} +
    {% trans "Parent" %}{{ object.parent|linkify|placeholder }}
    {% trans "Bridge" %}{{ object.bridge|linkify|placeholder }}
    {% trans "Description" %}{{ object.description|placeholder }}
    {% trans "MTU" %}{{ object.mtu|placeholder }}
    {% trans "802.1Q Mode" %}{{ object.get_mode_display|placeholder }}
    {% trans "Tunnel" %}{{ object.tunnel_termination.tunnel|linkify|placeholder }}
    -
    - {% include 'inc/panels/custom_fields.html' %} - {% include 'ipam/inc/panels/fhrp_groups.html' %} - {% plugin_right_page object %} + {% include 'inc/panels/tags.html' %} + {% plugin_left_page object %} +
    +
    + {% include 'inc/panels/custom_fields.html' %} +
    +

    {% trans "Addressing" %}

    + + + + + + + + + + + + + +
    {% trans "MAC Address" %} + {% if object.mac_address %} + {{ object.mac_address }} + {% trans "Primary" %} + {% else %} + {{ ''|placeholder }} + {% endif %} +
    {% trans "VRF" %}{{ object.vrf|linkify|placeholder }}
    {% trans "VLAN Translation" %}{{ object.vlan_translation_policy|linkify|placeholder }}
    + {% include 'ipam/inc/panels/fhrp_groups.html' %} + {% plugin_right_page object %} +
    @@ -99,6 +111,24 @@
    +
    +
    +
    +

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

    + {% htmx_table 'dcim:macaddress_list' vminterface_id=object.pk %} +
    +
    +
    {% include 'inc/panel_table.html' with table=vlan_table heading="VLANs" %} diff --git a/netbox/utilities/tests/test_filters.py b/netbox/utilities/tests/test_filters.py index 53e6eb985..031f31a12 100644 --- a/netbox/utilities/tests/test_filters.py +++ b/netbox/utilities/tests/test_filters.py @@ -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) diff --git a/netbox/virtualization/api/serializers_/virtualmachines.py b/netbox/virtualization/api/serializers_/virtualmachines.py index 9b7000def..dfc205b7c 100644 --- a/netbox/virtualization/api/serializers_/virtualmachines.py +++ b/netbox/virtualization/api/serializers_/virtualmachines.py @@ -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 IP Address {% endif %} + {% if perms.dcim.add_macaddress %} +
  • MAC Address
  • + {% endif %} {% if perms.vpn.add_l2vpntermination %}
  • L2VPN Termination
  • {% 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') diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py index 5a5bf2325..eef5d6b52 100644 --- a/netbox/virtualization/tests/test_filtersets.py +++ b/netbox/virtualization/tests/test_filtersets.py @@ -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) diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index b9cb7b437..dfd7e041c 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -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,