|
@@ -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'])
|