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

This commit is contained in:
Brian Tiemann 2024-10-30 16:06:10 -04:00
parent eed926ca73
commit b8572dc33f
29 changed files with 804 additions and 55 deletions

View File

@ -10,6 +10,9 @@ Interfaces in NetBox represent network interfaces used to exchange data with con
## Fields
!!! note
The MAC address of an interface (formerly a concrete database field) is available as a property, `mac_address`, which reflects the value of the primary linked [MAC address](./macaddress.md) object.
### Device
The device to which this interface belongs.
@ -45,10 +48,6 @@ The operation duplex (full, half, or auto).
The [virtual routing and forwarding](../ipam/vrf.md) instance to which this interface is assigned.
### MAC Address
The 48-bit MAC address (for Ethernet interfaces).
### WWN
The 64-bit world-wide name (for Fibre Channel interfaces).

View File

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

View File

@ -4,6 +4,9 @@
## Fields
!!! note
The MAC address of an interface (formerly a concrete database field) is available as a property, `mac_address`, which reflects the value of the primary linked [MAC address](./macaddress.md) object.
### Virtual Machine
The [virtual machine](./virtualmachine.md) to which this interface is assigned.
@ -27,10 +30,6 @@ An interface on the same VM with which this interface is bridged.
If not selected, this interface will be treated as disabled/inoperative.
### MAC Address
The 48-bit MAC address (for Ethernet interfaces).
### MTU
The interface's configured maximum transmissible unit (MTU).

View File

@ -6,7 +6,7 @@ from dcim.choices import *
from dcim.constants import *
from dcim.models import (
ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PowerOutlet, PowerPort,
RearPort, VirtualDeviceContext,
RearPort, VirtualDeviceContext, MACAddress,
)
from ipam.api.serializers_.vlans import VLANSerializer
from ipam.api.serializers_.vrfs import VRFSerializer
@ -33,6 +33,7 @@ __all__ = (
'FrontPortSerializer',
'InterfaceSerializer',
'InventoryItemSerializer',
'MACAddressSerializer',
'ModuleBaySerializer',
'PowerOutletSerializer',
'PowerPortSerializer',
@ -363,3 +364,11 @@ class InventoryItemSerializer(NetBoxModelSerializer):
serializer = get_serializer_for_model(obj.component)
context = {'request': self.context['request']}
return serializer(obj.component, nested=True, context=context).data
class MACAddressSerializer(NetBoxModelSerializer):
class Meta:
model = MACAddress
fields = ['mac_address',]
brief_fields = ('mac_address',)

View File

@ -123,3 +123,13 @@ COMPATIBLE_TERMINATION_TYPES = {
'powerport': ['poweroutlet', 'powerfeed'],
'rearport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport', 'circuittermination'],
}
#
# MAC addresses
#
MACADDRESS_ASSIGNMENT_MODELS = Q(
Q(app_label='dcim', model='interface') |
Q(app_label='virtualization', model='vminterface')
)

View File

@ -20,7 +20,7 @@ from utilities.filters import (
ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter,
NumericArrayFilter, TreeNodeMultipleChoiceFilter,
)
from virtualization.models import Cluster, ClusterGroup
from virtualization.models import Cluster, ClusterGroup, VMInterface
from vpn.models import L2VPN
from wireless.choices import WirelessRoleChoices, WirelessChannelChoices
from wireless.models import WirelessLAN, WirelessLink
@ -52,6 +52,7 @@ __all__ = (
'InventoryItemRoleFilterSet',
'InventoryItemTemplateFilterSet',
'LocationFilterSet',
'MACAddressFilterSet',
'ManufacturerFilterSet',
'ModuleBayFilterSet',
'ModuleBayTemplateFilterSet',
@ -1098,10 +1099,10 @@ class DeviceFilterSet(
field_name='device_type__is_full_depth',
label=_('Is full depth'),
)
mac_address = MultiValueMACAddressFilter(
field_name='interfaces___mac_address',
label=_('MAC address'),
)
# mac_address = MultiValueMACAddressFilter(
# field_name='interfaces___mac_address',
# label=_('MAC address'),
# )
serial = MultiValueCharFilter(
lookup_expr='iexact'
)
@ -1598,6 +1599,154 @@ class PowerOutletFilterSet(
)
class MACAddressFilterSet(NetBoxModelFilterSet):
mac_address = MultiValueMACAddressFilter()
interface = django_filters.ModelMultipleChoiceFilter(
field_name='interface__name',
queryset=Interface.objects.all(),
to_field_name='name',
label=_('Interface (name)'),
)
interface_id = django_filters.ModelMultipleChoiceFilter(
field_name='interface',
queryset=Interface.objects.all(),
label=_('Interface (ID)'),
)
vminterface = django_filters.ModelMultipleChoiceFilter(
field_name='vminterface__name',
queryset=VMInterface.objects.all(),
to_field_name='name',
label=_('VM interface (name)'),
)
vminterface_id = django_filters.ModelMultipleChoiceFilter(
field_name='vminterface',
queryset=VMInterface.objects.all(),
label=_('VM interface (ID)'),
)
# assigned_to_interface = django_filters.BooleanFilter(
# method='_assigned_to_interface',
# label=_('Is assigned to an interface'),
# )
# assigned = django_filters.BooleanFilter(
# method='_assigned',
# label=_('Is assigned'),
# )
class Meta:
model = MACAddress
fields = ('id', 'description', 'interface', 'assigned_object_type', 'assigned_object_id')
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = (
Q(mac_address__icontains=value) |
Q(description__icontains=value)
)
return queryset.filter(qs_filter)
def search_by_parent(self, queryset, name, value):
if not value:
return queryset
q = Q()
for prefix in value:
try:
query = str(netaddr.IPNetwork(prefix.strip()).cidr)
q |= Q(address__net_host_contained=query)
except (AddrFormatError, ValueError):
return queryset.none()
return queryset.filter(q)
def parse_inet_addresses(self, value):
'''
Parse networks or IP addresses and cast to a format
acceptable by the Postgres inet type.
Skips invalid values.
'''
parsed = []
for addr in value:
if netaddr.valid_ipv4(addr) or netaddr.valid_ipv6(addr):
parsed.append(addr)
continue
try:
network = netaddr.IPNetwork(addr)
parsed.append(str(network))
except (AddrFormatError, ValueError):
continue
return parsed
def filter_address(self, queryset, name, value):
# Let's first parse the addresses passed
# as argument. If they are all invalid,
# we return an empty queryset
value = self.parse_inet_addresses(value)
if (len(value) == 0):
return queryset.none()
try:
return queryset.filter(address__net_in=value)
except ValidationError:
return queryset.none()
@extend_schema_field(OpenApiTypes.STR)
def filter_present_in_vrf(self, queryset, name, vrf):
if vrf is None:
return queryset.none
return queryset.filter(
Q(vrf=vrf) |
Q(vrf__export_targets__in=vrf.import_targets.all())
).distinct()
def filter_device(self, queryset, name, value):
devices = Device.objects.filter(**{'{}__in'.format(name): value})
if not devices.exists():
return queryset.none()
interface_ids = []
for device in devices:
interface_ids.extend(device.vc_interfaces().values_list('id', flat=True))
return queryset.filter(
interface__in=interface_ids
)
def filter_virtual_machine(self, queryset, name, value):
virtual_machines = VirtualMachine.objects.filter(**{'{}__in'.format(name): value})
if not virtual_machines.exists():
return queryset.none()
interface_ids = []
for vm in virtual_machines:
interface_ids.extend(vm.interfaces.values_list('id', flat=True))
return queryset.filter(
vminterface__in=interface_ids
)
def _assigned_to_interface(self, queryset, name, value):
content_types = ContentType.objects.get_for_models(Interface, VMInterface).values()
if value:
return queryset.filter(
assigned_object_type__in=content_types,
assigned_object_id__isnull=False
)
else:
return queryset.exclude(
assigned_object_type__in=content_types,
assigned_object_id__isnull=False
)
def _assigned(self, queryset, name, value):
if value:
return queryset.exclude(
assigned_object_type__isnull=True,
assigned_object_id__isnull=True
)
else:
return queryset.filter(
assigned_object_type__isnull=True,
assigned_object_id__isnull=True
)
class CommonInterfaceFilterSet(django_filters.FilterSet):
vlan_id = django_filters.CharFilter(
method='filter_vlan_id',

View File

@ -2,6 +2,7 @@ from django import forms
from django.utils.translation import gettext_lazy as _
from dcim.models import *
from dcim.fields import MACAddressField
from extras.models import Tag
from netbox.forms.mixins import CustomFieldsMixin
from utilities.forms import form_from_model
@ -15,6 +16,7 @@ __all__ = (
# 'FrontPortBulkCreateForm',
'InterfaceBulkCreateForm',
'InventoryItemBulkCreateForm',
'MACAddressBulkCreateForm',
'ModuleBayBulkCreateForm',
'PowerOutletBulkCreateForm',
'PowerPortBulkCreateForm',
@ -76,6 +78,12 @@ class PowerOutletBulkCreateForm(
field_order = ('name', 'label', 'type', 'feed_leg', 'description', 'tags')
class MACAddressBulkCreateForm(forms.Form):
pattern = MACAddressField(
# label=_('Address pattern')
)
class InterfaceBulkCreateForm(
form_from_model(Interface, [
'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected', 'poe_mode', 'poe_type', 'rf_role'

View File

@ -38,6 +38,7 @@ __all__ = (
'InventoryItemRoleBulkEditForm',
'InventoryItemTemplateBulkEditForm',
'LocationBulkEditForm',
'MACAddressBulkEditForm',
'ManufacturerBulkEditForm',
'ModuleBulkEditForm',
'ModuleBayBulkEditForm',
@ -1389,10 +1390,28 @@ class PowerOutletBulkEditForm(
self.fields['power_port'].widget.attrs['disabled'] = True
class MACAddressBulkEditForm(NetBoxModelBulkEditForm):
description = forms.CharField(
label=_('Description'),
max_length=200,
required=False
)
comments = CommentField()
model = MACAddress
fieldsets = (
FieldSet('description'),
# FieldSet('vrf', 'mask_length', 'dns_name', name=_('Addressing')),
)
nullable_fields = (
'description', 'comments',
)
class InterfaceBulkEditForm(
ComponentBulkEditForm,
form_from_model(Interface, [
'label', 'type', 'parent', 'bridge', 'lag', 'speed', 'duplex', '_mac_address', 'wwn', 'mtu', 'mgmt_only',
'label', 'type', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'mtu', 'mgmt_only',
'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width',
'tx_power', 'wireless_lans'
])

View File

@ -17,7 +17,7 @@ from utilities.forms.fields import (
CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, CSVTypedChoiceField,
SlugField,
)
from virtualization.models import Cluster
from virtualization.models import Cluster, VMInterface, VirtualMachine
from wireless.choices import WirelessRoleChoices
from .common import ModuleCommonForm
@ -34,6 +34,7 @@ __all__ = (
'InventoryItemImportForm',
'InventoryItemRoleImportForm',
'LocationImportForm',
'MACAddressImportForm',
'ManufacturerImportForm',
'ModuleImportForm',
'ModuleBayImportForm',
@ -824,6 +825,98 @@ class PowerOutletImportForm(NetBoxModelImportForm):
self.fields['power_port'].queryset = PowerPort.objects.none()
class MACAddressImportForm(NetBoxModelImportForm):
device = CSVModelChoiceField(
label=_('Device'),
queryset=Device.objects.all(),
required=False,
to_field_name='name',
help_text=_('Parent device of assigned interface (if any)')
)
virtual_machine = CSVModelChoiceField(
label=_('Virtual machine'),
queryset=VirtualMachine.objects.all(),
required=False,
to_field_name='name',
help_text=_('Parent VM of assigned interface (if any)')
)
interface = CSVModelChoiceField(
label=_('Interface'),
queryset=Interface.objects.none(), # Can also refer to VMInterface
required=False,
to_field_name='name',
help_text=_('Assigned interface')
)
is_primary = forms.BooleanField(
label=_('Is primary'),
help_text=_('Make this the primary MAC for the assigned interface'),
required=False
)
class Meta:
model = MACAddress
fields = [
'mac_address', 'device', 'virtual_machine', 'interface', 'is_primary',
'description', 'comments', 'tags',
]
def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
if data:
# Limit interface queryset by assigned device
if data.get('device'):
self.fields['interface'].queryset = Interface.objects.filter(
**{f"device__{self.fields['device'].to_field_name}": data['device']}
)
# Limit interface queryset by assigned device
elif data.get('virtual_machine'):
self.fields['interface'].queryset = VMInterface.objects.filter(
**{f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data['virtual_machine']}
)
def clean(self):
super().clean()
device = self.cleaned_data.get('device')
virtual_machine = self.cleaned_data.get('virtual_machine')
interface = self.cleaned_data.get('interface')
is_primary = self.cleaned_data.get('is_primary')
# Validate is_primary
# TODO: scope to interface rather than device/VM
if is_primary and not device and not virtual_machine:
raise forms.ValidationError({
"is_primary": _("No device or virtual machine specified; cannot set as primary MAC")
})
if is_primary and not interface:
raise forms.ValidationError({
"is_primary": _("No interface specified; cannot set as primary MAC")
})
def save(self, *args, **kwargs):
# Set interface assignment
if self.cleaned_data.get('interface'):
self.instance.assigned_object = self.cleaned_data['interface']
mac_address = super().save(*args, **kwargs)
# Set as primary for device/VM
# TODO: set as primary for interface
# if self.cleaned_data.get('is_primary'):
# parent = self.cleaned_data.get('device') or self.cleaned_data.get('virtual_machine')
# if self.instance.address.version == 4:
# parent.primary_ip4 = ipaddress
# elif self.instance.address.version == 6:
# parent.primary_ip6 = ipaddress
# parent.save()
return mac_address
class InterfaceImportForm(NetBoxModelImportForm):
device = CSVModelChoiceField(
label=_('Device'),
@ -906,7 +999,7 @@ class InterfaceImportForm(NetBoxModelImportForm):
model = Interface
fields = (
'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled',
'mark_connected', '_mac_address', 'wwn', 'vdcs', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode',
'mark_connected', 'wwn', 'vdcs', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode',
'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'tags'
)

View File

@ -34,6 +34,7 @@ __all__ = (
'InventoryItemFilterForm',
'InventoryItemRoleFilterForm',
'LocationFilterForm',
'MACAddressFilterForm',
'ManufacturerFilterForm',
'ModuleFilterForm',
'ModuleBayFilterForm',
@ -1324,6 +1325,20 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
)
class MACAddressFilterForm(NetBoxModelFilterSetForm):
model = MACAddress
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('mac_address', name=_('Addressing')),
)
selector_fields = ('filter_id', 'q', 'device_id')
mac_address = forms.CharField(
required=False,
label=_('MAC address')
)
tag = TagFilterField(model)
class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = Interface
fieldsets = (

View File

@ -1,5 +1,6 @@
from django import forms
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from timezone_field import TimeZoneFormField
@ -17,7 +18,7 @@ from utilities.forms.fields import (
)
from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups
from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, NumberWithOptions, SelectWithPK
from virtualization.models import Cluster
from virtualization.models import Cluster, VMInterface
from wireless.models import WirelessLAN, WirelessLANGroup
from .common import InterfaceCommonForm, ModuleCommonForm
@ -41,6 +42,8 @@ __all__ = (
'InventoryItemRoleForm',
'InventoryItemTemplateForm',
'LocationForm',
'MACAddressForm',
'MACAddressBulkAddForm',
'ManufacturerForm',
'ModuleForm',
'ModuleBayForm',
@ -1298,6 +1301,120 @@ class PowerOutletForm(ModularDeviceComponentForm):
]
class MACAddressForm(NetBoxModelForm):
mac_address = forms.CharField(
required=True,
label=_('MAC address')
)
interface = DynamicModelChoiceField(
label=_('Interface'),
queryset=Interface.objects.all(),
required=False,
)
vminterface = DynamicModelChoiceField(
label=_('VM Interface'),
queryset=VMInterface.objects.all(),
required=False,
)
is_primary = forms.BooleanField(
required=False,
label=_('Primary for interface'),
)
fieldsets = (
FieldSet(
'mac_address', 'description', 'tags',
),
FieldSet(
TabbedGroups(
FieldSet('interface', name=_('Device')),
FieldSet('vminterface', name=_('Virtual Machine')),
),
'is_primary', name=_('Assignment')
),
)
class Meta:
model = MACAddress
fields = [
'mac_address', 'interface', 'vminterface', 'is_primary', 'description', 'tags',
]
def __init__(self, *args, **kwargs):
# Initialize helper selectors
instance = kwargs.get('instance')
initial = kwargs.get('initial', {}).copy()
if instance:
if type(instance.assigned_object) is Interface:
initial['interface'] = instance.assigned_object
elif type(instance.assigned_object) is VMInterface:
initial['vminterface'] = instance.assigned_object
kwargs['initial'] = initial
super().__init__(*args, **kwargs)
# Initialize primary_for_parent if IP address is already assigned
if self.instance.pk and self.instance.assigned_object:
# parent = getattr(self.instance.assigned_object, 'parent_object', None)
# if parent and (
# self.instance.address.version == 4 and parent.primary_ip4_id == self.instance.pk or
# self.instance.address.version == 6 and parent.primary_ip6_id == self.instance.pk
# ):
# self.initial['primary_for_parent'] = True
if type(instance.assigned_object) is Interface:
self.fields['interface'].widget.add_query_params({
'device_id': instance.assigned_object.device.pk,
})
elif type(instance.assigned_object) is VMInterface:
self.fields['vminterface'].widget.add_query_params({
'virtual_machine_id': instance.assigned_object.virtual_machine.pk,
})
# Disable object assignment fields if the IP address is designated as primary
if self.initial.get('primary_for_parent'):
self.fields['interface'].disabled = True
self.fields['vminterface'].disabled = True
def clean(self):
super().clean()
# Handle object assignment
selected_objects = [
field for field in ('interface', 'vminterface') if self.cleaned_data[field]
]
if len(selected_objects) > 1:
raise forms.ValidationError({
selected_objects[1]: _("A MAC address can only be assigned to a single object.")
})
elif selected_objects:
assigned_object = self.cleaned_data[selected_objects[0]]
if self.instance.pk and self.instance.assigned_object and self.cleaned_data['is_primary'] and assigned_object != self.instance.assigned_object:
raise ValidationError(
_("Cannot reassign MAC address while it is designated as the primary MAC for the interface")
)
self.instance.assigned_object = assigned_object
else:
self.instance.assigned_object = None
# Primary MAC assignment is only available if an interface has been assigned.
interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')
if self.cleaned_data.get('is_primary') and not interface:
self.add_error(
'is_primary', _("Only IP addresses assigned to an interface can be designated as primary IPs.")
)
class MACAddressBulkAddForm(NetBoxModelForm):
class Meta:
model = MACAddress
fields = [
'mac_address', 'description', 'tags',
]
class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
vdcs = DynamicModelMultipleChoiceField(
queryset=VirtualDeviceContext.objects.all(),

View File

@ -23,6 +23,7 @@ __all__ = (
'InventoryItemCreateForm',
'InventoryItemTemplateCreateForm',
'ModuleBayCreateForm',
# 'MACAddressCreateForm',
'ModuleBayTemplateCreateForm',
'PowerOutletCreateForm',
'PowerOutletTemplateCreateForm',
@ -238,6 +239,12 @@ class PowerOutletCreateForm(ComponentCreateForm, model_forms.PowerOutletForm):
exclude = ('name', 'label')
# class MACAddressCreateForm(ComponentCreateForm, model_forms.MACAddressForm):
#
# class Meta(model_forms.MACAddressForm.Meta):
# exclude = ('name', 'label')
class InterfaceCreateForm(ComponentCreateForm, model_forms.InterfaceForm):
class Meta(model_forms.InterfaceForm.Meta):

View File

@ -377,7 +377,7 @@ class FrontPortTemplateType(ModularComponentTemplateType):
filters=InterfaceFilter
)
class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, PathEndpointMixin):
_mac_address: str | None
# _mac_address: str | None
wwn: str | None
parent: Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')] | None
bridge: Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')] | None

View File

@ -11,6 +11,9 @@ def populate_macaddress_objects(apps, schema_editor):
Interface = apps.get_model('dcim', 'Interface')
VMInterface = apps.get_model('virtualization', 'VMInterface')
MACAddress = apps.get_model('dcim', 'MACAddress')
ContentType = apps.get_model('contenttypes', 'ContentType')
interface_ct = ContentType.objects.get_for_model(Interface)
vminterface_ct = ContentType.objects.get_for_model(VMInterface)
mac_addresses = []
print()
print('Converting MAC addresses...')
@ -18,14 +21,18 @@ def populate_macaddress_objects(apps, schema_editor):
mac_addresses.append(
MACAddress(
mac_address=interface._mac_address,
interface=interface,
assigned_object_type=interface_ct,
assigned_object_id=interface.id,
# interface=interface,
)
)
for vm_interface in VMInterface.objects.filter(_mac_address__isnull=False):
for vminterface in VMInterface.objects.filter(_mac_address__isnull=False):
mac_addresses.append(
MACAddress(
mac_address=vm_interface._mac_address,
vm_interface=vm_interface,
mac_address=vminterface._mac_address,
assigned_object_type=vminterface_ct,
assigned_object_id=vminterface.id,
# vm_interface=vm_interface,
)
)
MACAddress.objects.bulk_create(mac_addresses)
@ -57,9 +64,11 @@ class Migration(migrations.Migration):
('comments', models.TextField(blank=True)),
('mac_address', dcim.fields.MACAddressField(blank=True, null=True)),
('is_primary', models.BooleanField(default=False)),
('interface', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='dcim.interface')),
# ('interface', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='dcim.interface')),
('assigned_object_id', models.PositiveBigIntegerField(blank=True, null=True)),
('assigned_object_type', models.ForeignKey(blank=True, limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'dcim'), ('model', 'interface')), models.Q(('app_label', 'virtualization'), ('model', 'vminterface')), _connector='OR')), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
('vm_interface', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='virtualization.vminterface')),
# ('vm_interface', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='virtualization.vminterface')),
],
options={
'abstract': False,

View File

@ -0,0 +1,17 @@
# Generated by Django 5.1.2 on 2024-10-29 15:41
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('dcim', '0195_rename_mac_address_interface__mac_address_macaddress'),
]
operations = [
migrations.RemoveField(
model_name='interface',
name='_mac_address',
),
]

View File

@ -510,11 +510,6 @@ class BaseInterface(models.Model):
verbose_name=_('enabled'),
default=True
)
_mac_address = MACAddressField(
null=True,
blank=True,
verbose_name=_('MAC address')
)
mtu = models.PositiveIntegerField(
blank=True,
null=True,
@ -578,7 +573,7 @@ class BaseInterface(models.Model):
@property
def mac_address(self):
if macaddress := self.macaddress_set.first():
if macaddress := self.mac_addresses.order_by('-is_primary').first():
return macaddress.mac_address
return None
@ -725,6 +720,12 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
object_id_field='assigned_object_id',
related_query_name='interface'
)
mac_addresses = GenericRelation(
to='dcim.MACAddress',
content_type_field='assigned_object_type',
object_id_field='assigned_object_id',
related_query_name='interface'
)
fhrp_group_assignments = GenericRelation(
to='ipam.FHRPGroupAssignment',
content_type_field='interface_type',
@ -1338,21 +1339,44 @@ class MACAddress(PrimaryModel):
blank=True,
verbose_name=_('MAC address')
)
interface = models.ForeignKey(
to='dcim.Interface',
# interface = models.ForeignKey(
# to='dcim.Interface',
# on_delete=models.PROTECT,
# null=True,
# blank=True,
# verbose_name=_('Interface')
# )
# vm_interface = models.ForeignKey(
# to='virtualization.VMInterface',
# on_delete=models.PROTECT,
# null=True,
# blank=True,
# verbose_name=_('VM Interface')
# )
assigned_object_type = models.ForeignKey(
to='contenttypes.ContentType',
limit_choices_to=MACADDRESS_ASSIGNMENT_MODELS,
on_delete=models.PROTECT,
null=True,
related_name='+',
blank=True,
verbose_name=_('Interface')
null=True
)
vm_interface = models.ForeignKey(
to='virtualization.VMInterface',
on_delete=models.PROTECT,
null=True,
assigned_object_id = models.PositiveBigIntegerField(
blank=True,
verbose_name=_('VM Interface')
null=True
)
assigned_object = GenericForeignKey(
ct_field='assigned_object_type',
fk_field='assigned_object_id'
)
is_primary = models.BooleanField(
verbose_name=_('is primary for interface'),
default=False
)
class Meta:
verbose_name = _('MAC address')
verbose_name_plural = _('MAC addresses')
def __str__(self):
return str(self.mac_address)

View File

@ -29,6 +29,7 @@ __all__ = (
'InterfaceTable',
'InventoryItemRoleTable',
'InventoryItemTable',
'MACAddressTable',
'ModuleBayTable',
'PlatformTable',
'PowerOutletTable',
@ -593,6 +594,40 @@ class BaseInterfaceTable(NetBoxTable):
return ",".join([str(obj) for obj in value.all()])
class MACAddressTable(NetBoxTable):
mac_address = tables.Column(
verbose_name=_('MAC Address'),
linkify=True
)
assigned_object = tables.Column(
linkify=True,
orderable=False,
verbose_name=_('Interface')
)
is_primary = columns.BooleanColumn(
verbose_name=_('Primary MAC'),
false_mark=None
)
# interface = tables.Column(
# verbose_name=_('Interface'),
# linkify=True
# )
# vm_interface = tables.Column(
# verbose_name=_('VM Interface'),
# linkify=True
# )
tags = columns.TagColumn(
url_name='dcim:macaddress_list'
)
class Meta(DeviceComponentTable.Meta):
model = models.MACAddress
fields = (
'pk', 'id', 'mac_address', 'assigned_object', 'created', 'last_updated', 'is_primary'
)
default_columns = ('pk', 'mac_address', 'assigned_object', 'is_primary')
class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpointTable):
device = tables.Column(
verbose_name=_('Device'),

View File

@ -250,6 +250,16 @@ 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/bulk-add/', views.MACAddressBulkCreateView.as_view(), name='macaddress_bulk_add'),
path('mac-addresses/import/', views.MACAddressBulkImportView.as_view(), name='macaddress_import'),
path('mac-addresses/edit/', views.MACAddressBulkEditView.as_view(), name='macaddress_bulk_edit'),
path('mac-addresses/rename/', views.MACAddressBulkRenameView.as_view(), name='macaddress_bulk_rename'),
path('mac-addresses/delete/', views.MACAddressBulkDeleteView.as_view(), name='macaddress_bulk_delete'),
path('mac-addresses/<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'),

View File

@ -2519,6 +2519,78 @@ class PowerOutletBulkDeleteView(generic.BulkDeleteView):
register_model_view(PowerOutlet, 'trace', kwargs={'model': PowerOutlet})(PathTraceView)
#
# MAC addresses
#
class MACAddressListView(generic.ObjectListView):
queryset = MACAddress.objects.all()
filterset = filtersets.MACAddressFilterSet
filterset_form = forms.MACAddressFilterForm
table = tables.MACAddressTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(MACAddress)
class MACAddressView(generic.ObjectView):
queryset = MACAddress.objects.all()
# class MACAddressCreateView(generic.ComponentCreateView):
# queryset = MACAddress.objects.all()
# form = forms.MACAddressForm
# model_form = forms.MACAddressForm
@register_model_view(MACAddress, 'edit')
class MACAddressEditView(generic.ObjectEditView):
queryset = MACAddress.objects.all()
form = forms.MACAddressForm
@register_model_view(MACAddress, 'delete')
class MACAddressDeleteView(generic.ObjectDeleteView):
queryset = MACAddress.objects.all()
class MACAddressBulkCreateView(generic.BulkCreateView):
queryset = MACAddress.objects.all()
form = forms.MACAddressBulkCreateForm
model_form = forms.MACAddressBulkAddForm
pattern_target = 'mac_address'
template_name = 'dcim/macaddress_bulk_add.html'
class MACAddressBulkImportView(generic.BulkImportView):
queryset = MACAddress.objects.all()
model_form = forms.MACAddressImportForm
class MACAddressBulkEditView(generic.BulkEditView):
queryset = MACAddress.objects.all()
filterset = filtersets.MACAddressFilterSet
table = tables.MACAddressTable
form = forms.MACAddressBulkEditForm
class MACAddressBulkRenameView(generic.BulkRenameView):
queryset = MACAddress.objects.all()
class MACAddressBulkDisconnectView(BulkDisconnectView):
queryset = MACAddress.objects.all()
class MACAddressBulkDeleteView(generic.BulkDeleteView):
queryset = MACAddress.objects.all()
filterset = filtersets.MACAddressFilterSet
table = tables.MACAddressTable
#
# Interfaces
#
@ -2552,7 +2624,7 @@ class InterfaceView(generic.ObjectView):
# Get bridge interfaces
bridge_interfaces = Interface.objects.restrict(request.user, 'view').filter(bridge=instance)
bridge_interfaces_tables = tables.InterfaceTable(
bridge_interfaces_table = tables.InterfaceTable(
bridge_interfaces,
exclude=('device', 'parent'),
orderable=False
@ -2560,12 +2632,19 @@ class InterfaceView(generic.ObjectView):
# Get child interfaces
child_interfaces = Interface.objects.restrict(request.user, 'view').filter(parent=instance)
child_interfaces_tables = tables.InterfaceTable(
child_interfaces_table = tables.InterfaceTable(
child_interfaces,
exclude=('device', 'parent'),
orderable=False
)
# Get MAC addresses
mac_addresses_table = tables.MACAddressTable(
data=instance.mac_addresses,
exclude=('assigned_object',),
orderable=False
)
# Get assigned VLANs and annotate whether each is tagged or untagged
vlans = []
if instance.untagged_vlan is not None:
@ -2582,8 +2661,9 @@ class InterfaceView(generic.ObjectView):
return {
'vdc_table': vdc_table,
'bridge_interfaces_table': bridge_interfaces_tables,
'child_interfaces_table': child_interfaces_tables,
'bridge_interfaces_table': bridge_interfaces_table,
'child_interfaces_table': child_interfaces_table,
'mac_addresses_table': mac_addresses_table,
'vlan_table': vlan_table,
}

View File

@ -91,6 +91,7 @@ DEVICES_MENU = Menu(
MenuGroup(
label=_('Device Components'),
items=(
get_model_item('dcim', 'macaddress', _('MAC Addresses')),
get_model_item('dcim', 'interface', _('Interfaces')),
get_model_item('dcim', 'frontport', _('Front Ports')),
get_model_item('dcim', 'rearport', _('Rear Ports')),

View File

@ -346,7 +346,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.ipam.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>

View File

@ -0,0 +1,50 @@
{% 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-4">
<div class="card">
<h2 class="card-header">{% trans "MAC Address" %}</h2>
<table class="table table-hover attr-table">
<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 %}
{% if object.assigned_object.parent_object %}
{{ object.assigned_object.parent_object|linkify }} /
{% endif %}
{{ object.assigned_object|linkify }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Primary MAC 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-8">
{% include 'inc/panels/comments.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,30 @@
{% extends 'generic/object_edit.html' %}
{% load static %}
{% load form_helpers %}
{% load i18n %}
{% block title %}{% trans "Bulk Add MAC Addresses" %}{% endblock %}
{% block tabs %}
{% include 'ipam/inc/ipaddress_edit_header.html' with active_tab='bulk_add' %}
{% endblock %}
{% block form %}
<div class="field-group my-5">
<div class="row">
<h2 class="col-9 offset-3">{% trans "MAC Addresses" %}</h2>
</div>
{% render_field form.pattern %}
{% render_field model_form.description %}
{% render_field model_form.tags %}
</div>
{% if model_form.custom_fields %}
<div class="field-group my-5">
<div class="row">
<h2 class="col-9 offset-3">{% trans "Custom Fields" %}</h2>
</div>
{% render_custom_fields model_form %}
</div>
{% endif %}
{% endblock %}

View File

@ -95,6 +95,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" %}

View File

@ -9,7 +9,7 @@ from extras.models import ConfigTemplate
from ipam.filtersets import PrimaryIPFilterSet
from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter
from utilities.filters import MultiValueCharFilter, TreeNodeMultipleChoiceFilter
from .choices import *
from .models import *
@ -225,10 +225,10 @@ class VirtualMachineFilterSet(
to_field_name='slug',
label=_('Platform (slug)'),
)
mac_address = MultiValueMACAddressFilter(
field_name='interfaces___mac_address',
label=_('MAC address'),
)
# mac_address = MultiValueMACAddressFilter(
# field_name='interfaces___mac_address',
# label=_('MAC address'),
# )
has_primary_ip = django_filters.BooleanFilter(
method='_has_primary_ip',
label=_('Has a primary IP'),
@ -297,9 +297,9 @@ class VMInterfaceFilterSet(NetBoxModelFilterSet, CommonInterfaceFilterSet):
queryset=VMInterface.objects.all(),
label=_('Bridged interface (ID)'),
)
_mac_address = MultiValueMACAddressFilter(
label=_('MAC address'),
)
# _mac_address = MultiValueMACAddressFilter(
# label=_('MAC address'),
# )
class Meta:
model = VMInterface

View File

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

View File

@ -95,7 +95,7 @@ class VirtualMachineType(ConfigContextMixin, ContactsMixin, NetBoxObjectType):
filters=VMInterfaceFilter
)
class VMInterfaceType(IPAddressesMixin, ComponentType):
_mac_address: str | None
# _mac_address: str | None
parent: Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')] | None
bridge: Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')] | None
untagged_vlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None

View File

@ -0,0 +1,17 @@
# Generated by Django 5.1.2 on 2024-10-29 15:41
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('virtualization', '0042_rename_mac_address_vminterface__mac_address'),
]
operations = [
migrations.RemoveField(
model_name='vminterface',
name='_mac_address',
),
]

View File

@ -368,6 +368,12 @@ class VMInterface(ComponentModel, BaseInterface, TrackingModelMixin):
object_id_field='assigned_object_id',
related_query_name='vminterface',
)
mac_addresses = GenericRelation(
to='dcim.MACAddress',
content_type_field='assigned_object_type',
object_id_field='assigned_object_id',
related_query_name='vminterface'
)
class Meta(ComponentModel.Meta):
verbose_name = _('interface')