From 016a5335aeb1e9ab78bc41ca8f561f4fe97dd187 Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Tue, 5 Nov 2024 15:50:25 -0500 Subject: [PATCH] Reorganize MACAddress classes out of association with DeviceComponents --- netbox/dcim/forms/bulk_edit.py | 40 ++--- netbox/dcim/forms/bulk_import.py | 188 ++++++++++++------------ netbox/dcim/forms/filtersets.py | 32 ++-- netbox/dcim/forms/model_forms.py | 170 ++++++++++----------- netbox/dcim/forms/object_create.py | 7 - netbox/dcim/models/device_components.py | 41 +----- netbox/dcim/models/devices.py | 43 ++++++ netbox/netbox/navigation/menu.py | 7 +- 8 files changed, 274 insertions(+), 254 deletions(-) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index e0d7c05f2..c9b94ae71 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -1274,6 +1274,28 @@ class InventoryItemTemplateBulkEditForm(BulkEditForm): nullable_fields = ('label', 'role', 'manufacturer', 'part_id', 'description') +# +# Addressing +# + +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', + ) + + # # Device components # @@ -1390,24 +1412,6 @@ 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, [ diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index b126460ae..af4896435 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -696,6 +696,102 @@ class ModuleImportForm(ModuleCommonForm, NetBoxModelImportForm): return self.cleaned_data['replicate_components'] +# +# 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 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 + + # # Device components # @@ -825,98 +921,6 @@ 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'), diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index cdb7e2a71..5fb1b9012 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -1202,6 +1202,24 @@ class PowerFeedFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): tag = TagFilterField(model) +# +# Addressing +# + +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) + + # # Device components # @@ -1325,20 +1343,6 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): ) -class MACAddressFilterForm(NetBoxModelFilterSetForm): - model = MACAddress - fieldsets = ( - FieldSet('q', 'filter_id', 'tag'), - FieldSet('mac_address', name=_('Addressing')), - ) - selector_fields = ('filter_id', 'q', 'device_id') - mac_address = forms.CharField( - required=False, - label=_('MAC address') - ) - tag = TagFilterField(model) - - class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = Interface fieldsets = ( diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index e252183a6..d036507ba 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -866,6 +866,93 @@ class VCMemberSelectForm(forms.Form): return device +# +# 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, + ) + 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) + + + 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.") + ) + + # # Device component templates # @@ -1301,89 +1388,6 @@ 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) - - - 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 InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): vdcs = DynamicModelMultipleChoiceField( queryset=VirtualDeviceContext.objects.all(), diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index 31e367c3d..d18c7ed14 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -23,7 +23,6 @@ __all__ = ( 'InventoryItemCreateForm', 'InventoryItemTemplateCreateForm', 'ModuleBayCreateForm', - # 'MACAddressCreateForm', 'ModuleBayTemplateCreateForm', 'PowerOutletCreateForm', 'PowerOutletTemplateCreateForm', @@ -239,12 +238,6 @@ class PowerOutletCreateForm(ComponentCreateForm, model_forms.PowerOutletForm): exclude = ('name', 'label') -# class MACAddressCreateForm(ComponentCreateForm, model_forms.MACAddressForm): -# -# class Meta(model_forms.MACAddressForm.Meta): -# exclude = ('name', 'label') - - class InterfaceCreateForm(ComponentCreateForm, model_forms.InterfaceForm): class Meta(model_forms.InterfaceForm.Meta): diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index cd3136676..b2a7e3188 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -10,9 +10,9 @@ 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, PrimaryModel +from netbox.models import OrganizationalModel, NetBoxModel from utilities.fields import ColorField, NaturalOrderingField from utilities.mptt import TreeManager from utilities.ordering import naturalize_interface @@ -31,7 +31,6 @@ __all__ = ( 'Interface', 'InventoryItem', 'InventoryItemRole', - 'MACAddress', 'ModuleBay', 'PathEndpoint', 'PowerOutlet', @@ -1355,39 +1354,3 @@ class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin): def get_status_color(self): return InventoryItemStatusChoices.colors.get(self.status) - - -class MACAddress(PrimaryModel): - mac_address = MACAddressField( - null=True, - blank=True, - 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' - ) - is_primary = models.BooleanField( - verbose_name=_('is primary for interface'), - default=False - ) - - class Meta: - ordering = ('mac_address',) - verbose_name = _('MAC address') - verbose_name_plural = _('MAC addresses') - - def __str__(self): - return f'{str(self.mac_address)} {self.assigned_object}' diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 47f4ee6c9..f59bda8ff 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', @@ -1473,3 +1476,43 @@ 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( + null=True, + blank=True, + 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' + ) + is_primary = models.BooleanField( + verbose_name=_('is primary for interface'), + default=False + ) + + class Meta: + ordering = ('mac_address',) + verbose_name = _('MAC address') + verbose_name_plural = _('MAC addresses') + + def __str__(self): + return f'{str(self.mac_address)} {self.assigned_object}' diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 89d7702f3..1c4f9bf88 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -89,9 +89,14 @@ DEVICES_MENU = Menu( ), ), MenuGroup( - label=_('Device Components'), + label=_('Addressing'), items=( get_model_item('dcim', 'macaddress', _('MAC Addresses')), + ), + ), + MenuGroup( + label=_('Device Components'), + items=( get_model_item('dcim', 'interface', _('Interfaces')), get_model_item('dcim', 'frontport', _('Front Ports')), get_model_item('dcim', 'rearport', _('Rear Ports')),