mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-09 01:49:35 -06:00
Closes #20647: add cleanup for interface import (#20702)
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
Co-authored-by: Robin Gruyters <2082795+rgruyters@users.noreply.github.com> Co-authored-by: Martin Hauser <git@pheus.dev>
This commit is contained in:
parent
0b61d69e05
commit
df96f7dd0f
@ -2,6 +2,7 @@ import json
|
|||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.db import models
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
@ -99,6 +100,35 @@ class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm):
|
|||||||
def _get_form_field(self, customfield):
|
def _get_form_field(self, customfield):
|
||||||
return customfield.to_form_field(for_csv_import=True)
|
return customfield.to_form_field(for_csv_import=True)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
"""
|
||||||
|
Cleans data in a form, ensuring proper handling of model fields with `null=True`.
|
||||||
|
Overrides the `clean` method from the parent form to process and sanitize cleaned
|
||||||
|
data for defined fields in the associated model.
|
||||||
|
"""
|
||||||
|
super().clean()
|
||||||
|
cleaned = self.cleaned_data
|
||||||
|
|
||||||
|
model = getattr(self._meta, "model", None)
|
||||||
|
if not model:
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
for f in model._meta.get_fields():
|
||||||
|
# Only forward, DB-backed fields (skip M2M & reverse relations)
|
||||||
|
if not isinstance(f, models.Field) or not f.concrete or f.many_to_many:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if getattr(f, "null", False):
|
||||||
|
name = f.name
|
||||||
|
if name not in cleaned:
|
||||||
|
continue
|
||||||
|
val = cleaned[name]
|
||||||
|
# Only coerce empty strings; leave other types alone
|
||||||
|
if isinstance(val, str) and val.strip() == "":
|
||||||
|
cleaned[name] = None
|
||||||
|
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
class NetBoxModelBulkEditForm(ChangelogMessageMixin, CustomFieldsMixin, BulkEditForm):
|
class NetBoxModelBulkEditForm(ChangelogMessageMixin, CustomFieldsMixin, BulkEditForm):
|
||||||
"""
|
"""
|
||||||
|
|||||||
303
netbox/netbox/tests/test_forms.py
Normal file
303
netbox/netbox/tests/test_forms.py
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from dcim.choices import InterfaceTypeChoices
|
||||||
|
from dcim.forms import InterfaceImportForm
|
||||||
|
from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site
|
||||||
|
|
||||||
|
|
||||||
|
class NetBoxModelImportFormCleanTest(TestCase):
|
||||||
|
"""
|
||||||
|
Test the clean() method of NetBoxModelImportForm to ensure it properly converts
|
||||||
|
empty strings to None for nullable fields during CSV import.
|
||||||
|
Uses InterfaceImportForm as the concrete implementation to test.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
# Create minimal test fixtures for Interface
|
||||||
|
cls.site = Site.objects.create(name='Test Site', slug='test-site')
|
||||||
|
cls.manufacturer = Manufacturer.objects.create(name='Test Manufacturer', slug='test-manufacturer')
|
||||||
|
cls.device_type = DeviceType.objects.create(
|
||||||
|
manufacturer=cls.manufacturer, model='Test Device Type', slug='test-device-type'
|
||||||
|
)
|
||||||
|
cls.device_role = DeviceRole.objects.create(name='Test Role', slug='test-role', color='ff0000')
|
||||||
|
cls.device = Device.objects.create(
|
||||||
|
name='Test Device', device_type=cls.device_type, role=cls.device_role, site=cls.site
|
||||||
|
)
|
||||||
|
# Create parent interfaces for ForeignKey testing
|
||||||
|
cls.parent_interface = Interface.objects.create(
|
||||||
|
device=cls.device, name='Parent Interface', type=InterfaceTypeChoices.TYPE_1GE_GBIC
|
||||||
|
)
|
||||||
|
cls.lag_interface = Interface.objects.create(
|
||||||
|
device=cls.device, name='LAG Interface', type=InterfaceTypeChoices.TYPE_LAG
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_empty_string_to_none_nullable_charfield(self):
|
||||||
|
"""Empty strings should convert to None for nullable CharField"""
|
||||||
|
form = InterfaceImportForm(
|
||||||
|
data={
|
||||||
|
'device': self.device,
|
||||||
|
'name': 'Interface 1',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
'duplex': '', # nullable CharField
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
|
||||||
|
self.assertIsNone(form.cleaned_data['duplex'])
|
||||||
|
|
||||||
|
def test_empty_string_to_none_nullable_integerfield(self):
|
||||||
|
"""Empty strings should convert to None for nullable PositiveIntegerField"""
|
||||||
|
form = InterfaceImportForm(
|
||||||
|
data={
|
||||||
|
'device': self.device,
|
||||||
|
'name': 'Interface 2',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
'speed': '', # nullable PositiveIntegerField
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
|
||||||
|
self.assertIsNone(form.cleaned_data['speed'])
|
||||||
|
|
||||||
|
def test_empty_string_to_none_nullable_smallintegerfield(self):
|
||||||
|
"""Empty strings should convert to None for nullable SmallIntegerField"""
|
||||||
|
form = InterfaceImportForm(
|
||||||
|
data={
|
||||||
|
'device': self.device,
|
||||||
|
'name': 'Interface 3',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
'tx_power': '', # nullable SmallIntegerField
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
|
||||||
|
self.assertIsNone(form.cleaned_data['tx_power'])
|
||||||
|
|
||||||
|
def test_empty_string_to_none_nullable_decimalfield(self):
|
||||||
|
"""Empty strings should convert to None for nullable DecimalField"""
|
||||||
|
form = InterfaceImportForm(
|
||||||
|
data={
|
||||||
|
'device': self.device,
|
||||||
|
'name': 'Interface 4',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
'rf_channel_frequency': '', # nullable DecimalField
|
||||||
|
'rf_channel_width': '', # nullable DecimalField
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
|
||||||
|
self.assertIsNone(form.cleaned_data['rf_channel_frequency'])
|
||||||
|
self.assertIsNone(form.cleaned_data['rf_channel_width'])
|
||||||
|
|
||||||
|
def test_empty_string_to_none_nullable_foreignkey(self):
|
||||||
|
"""Empty strings should convert to None for nullable ForeignKey"""
|
||||||
|
form = InterfaceImportForm(
|
||||||
|
data={
|
||||||
|
'device': self.device,
|
||||||
|
'name': 'Interface 5',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
'lag': '', # nullable ForeignKey
|
||||||
|
'parent': '', # nullable ForeignKey
|
||||||
|
'bridge': '', # nullable ForeignKey
|
||||||
|
'vrf': '', # nullable ForeignKey
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
|
||||||
|
self.assertIsNone(form.cleaned_data['lag'])
|
||||||
|
self.assertIsNone(form.cleaned_data['parent'])
|
||||||
|
self.assertIsNone(form.cleaned_data['bridge'])
|
||||||
|
self.assertIsNone(form.cleaned_data['vrf'])
|
||||||
|
|
||||||
|
def test_empty_string_preserved_non_nullable_charfield(self):
|
||||||
|
"""Empty strings should be preserved for non-nullable CharField (blank=True only)"""
|
||||||
|
form = InterfaceImportForm(
|
||||||
|
data={
|
||||||
|
'device': self.device,
|
||||||
|
'name': 'Interface 6',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
'label': '', # CharField with blank=True (not null=True)
|
||||||
|
'description': '', # CharField with blank=True (not null=True)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
|
||||||
|
# label and description are NOT nullable in the model, so empty string remains
|
||||||
|
self.assertEqual(form.cleaned_data['label'], '')
|
||||||
|
self.assertEqual(form.cleaned_data['description'], '')
|
||||||
|
|
||||||
|
def test_empty_string_not_converted_for_required_fields(self):
|
||||||
|
"""Empty strings should NOT be converted for required fields"""
|
||||||
|
form = InterfaceImportForm(
|
||||||
|
data={
|
||||||
|
'device': self.device,
|
||||||
|
'name': '', # required field, empty string should remain and cause error
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# Form should be invalid because name is required
|
||||||
|
self.assertFalse(form.is_valid())
|
||||||
|
if form.errors:
|
||||||
|
self.assertIn('name', form.errors)
|
||||||
|
|
||||||
|
def test_non_string_none_value_preserved(self):
|
||||||
|
"""None values should be preserved (not modified)"""
|
||||||
|
form = InterfaceImportForm(
|
||||||
|
data={
|
||||||
|
'device': self.device,
|
||||||
|
'name': 'Interface 7',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
'speed': None, # Already None
|
||||||
|
'tx_power': None, # Already None
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
|
||||||
|
self.assertIsNone(form.cleaned_data['speed'])
|
||||||
|
self.assertIsNone(form.cleaned_data['tx_power'])
|
||||||
|
|
||||||
|
def test_non_string_numeric_values_preserved(self):
|
||||||
|
"""Numeric values (including 0) should not be modified"""
|
||||||
|
form = InterfaceImportForm(
|
||||||
|
data={
|
||||||
|
'device': self.device,
|
||||||
|
'name': 'Interface 8',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
'speed': 0, # nullable PositiveIntegerField with value 0
|
||||||
|
'tx_power': 0, # nullable SmallIntegerField with value 0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
|
||||||
|
self.assertEqual(form.cleaned_data['speed'], 0)
|
||||||
|
self.assertEqual(form.cleaned_data['tx_power'], 0)
|
||||||
|
|
||||||
|
def test_manytomany_fields_skipped(self):
|
||||||
|
"""ManyToMany fields should be skipped and not cause errors"""
|
||||||
|
# Interface has 'vdcs' and 'wireless_lans' as M2M fields
|
||||||
|
form = InterfaceImportForm(
|
||||||
|
data={
|
||||||
|
'device': self.device,
|
||||||
|
'name': 'Interface 9',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
# vdcs and wireless_lans fields are M2M, handled by parent class
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
|
||||||
|
|
||||||
|
def test_fields_not_in_cleaned_data_skipped(self):
|
||||||
|
"""Fields not present in cleaned_data should be skipped gracefully"""
|
||||||
|
# Create minimal form data - some nullable fields won't be in cleaned_data
|
||||||
|
form = InterfaceImportForm(
|
||||||
|
data={
|
||||||
|
'device': self.device,
|
||||||
|
'name': 'Interface 10',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
# lag, parent, bridge, vrf, speed, etc. not provided
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# Should not raise KeyError when checking fields not in form data
|
||||||
|
self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
|
||||||
|
|
||||||
|
def test_valid_string_values_preserved(self):
|
||||||
|
"""Non-empty string values should be properly converted to their target types"""
|
||||||
|
form = InterfaceImportForm(
|
||||||
|
data={
|
||||||
|
'device': self.device,
|
||||||
|
'name': 'Interface 11',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
'speed': '1000000', # Valid speed value (string will be converted to int)
|
||||||
|
'mtu': '1500', # Valid mtu value (string will be converted to int)
|
||||||
|
'description': 'Test description',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
|
||||||
|
# speed and mtu are converted to int
|
||||||
|
self.assertEqual(form.cleaned_data['speed'], 1000000)
|
||||||
|
self.assertEqual(form.cleaned_data['mtu'], 1500)
|
||||||
|
self.assertEqual(form.cleaned_data['description'], 'Test description')
|
||||||
|
|
||||||
|
def test_multiple_nullable_fields_with_empty_strings(self):
|
||||||
|
"""Multiple nullable fields with empty strings should all convert to None"""
|
||||||
|
form = InterfaceImportForm(
|
||||||
|
data={
|
||||||
|
'device': self.device,
|
||||||
|
'name': 'Interface 12',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
'speed': '', # nullable
|
||||||
|
'duplex': '', # nullable
|
||||||
|
'tx_power': '', # nullable
|
||||||
|
'vrf': '', # nullable ForeignKey
|
||||||
|
'poe_mode': '', # nullable
|
||||||
|
'poe_type': '', # nullable
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
|
||||||
|
# All nullable fields should convert to None
|
||||||
|
self.assertIsNone(form.cleaned_data['speed'])
|
||||||
|
self.assertIsNone(form.cleaned_data['duplex'])
|
||||||
|
self.assertIsNone(form.cleaned_data['tx_power'])
|
||||||
|
self.assertIsNone(form.cleaned_data['vrf'])
|
||||||
|
self.assertIsNone(form.cleaned_data['poe_mode'])
|
||||||
|
self.assertIsNone(form.cleaned_data['poe_type'])
|
||||||
|
|
||||||
|
def test_mixed_nullable_and_non_nullable_empty_strings(self):
|
||||||
|
"""Combination of nullable and non-nullable fields with empty strings"""
|
||||||
|
form = InterfaceImportForm(
|
||||||
|
data={
|
||||||
|
'device': self.device,
|
||||||
|
'name': 'Interface 13',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
'speed': '', # nullable, should become None
|
||||||
|
'label': '', # NOT nullable (blank=True only), should remain empty string
|
||||||
|
'duplex': '', # nullable, should become None
|
||||||
|
'description': '', # NOT nullable (blank=True only), should remain empty string
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
|
||||||
|
# Nullable fields convert to None
|
||||||
|
self.assertIsNone(form.cleaned_data['speed'])
|
||||||
|
self.assertIsNone(form.cleaned_data['duplex'])
|
||||||
|
# Non-nullable fields remain empty strings
|
||||||
|
self.assertEqual(form.cleaned_data['label'], '')
|
||||||
|
self.assertEqual(form.cleaned_data['description'], '')
|
||||||
|
|
||||||
|
def test_wireless_fields_nullable(self):
|
||||||
|
"""Wireless-specific nullable fields should convert empty strings to None"""
|
||||||
|
form = InterfaceImportForm(
|
||||||
|
data={
|
||||||
|
'device': self.device,
|
||||||
|
'name': 'Interface 14',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
'rf_role': '', # nullable CharField
|
||||||
|
'rf_channel': '', # nullable CharField
|
||||||
|
'rf_channel_frequency': '', # nullable DecimalField
|
||||||
|
'rf_channel_width': '', # nullable DecimalField
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
|
||||||
|
self.assertIsNone(form.cleaned_data['rf_role'])
|
||||||
|
self.assertIsNone(form.cleaned_data['rf_channel'])
|
||||||
|
self.assertIsNone(form.cleaned_data['rf_channel_frequency'])
|
||||||
|
self.assertIsNone(form.cleaned_data['rf_channel_width'])
|
||||||
|
|
||||||
|
def test_poe_fields_nullable(self):
|
||||||
|
"""PoE-specific nullable fields should convert empty strings to None"""
|
||||||
|
form = InterfaceImportForm(
|
||||||
|
data={
|
||||||
|
'device': self.device,
|
||||||
|
'name': 'Interface 15',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
'poe_mode': '', # nullable CharField
|
||||||
|
'poe_type': '', # nullable CharField
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
|
||||||
|
self.assertIsNone(form.cleaned_data['poe_mode'])
|
||||||
|
self.assertIsNone(form.cleaned_data['poe_type'])
|
||||||
|
|
||||||
|
def test_wwn_field_nullable(self):
|
||||||
|
"""WWN field (special field type) should convert empty string to None"""
|
||||||
|
form = InterfaceImportForm(
|
||||||
|
data={
|
||||||
|
'device': self.device,
|
||||||
|
'name': 'Interface 16',
|
||||||
|
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
'wwn': '', # nullable WWNField
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
|
||||||
|
self.assertIsNone(form.cleaned_data['wwn'])
|
||||||
Loading…
Reference in New Issue
Block a user