Repository: nautobot/nautobot

Issue:
DeviceType import handle unknown component enums
Update the import logic to default any device component template CharField with defined choices to an `other` choice if we encounter a choice we don't recognize.

Add a debug log when we encounter such cases.


Discussion:

Resolving patch:
diff --git a/nautobot/core/views/generic.py b/nautobot/core/views/generic.py
--- a/nautobot/core/views/generic.py
+++ b/nautobot/core/views/generic.py
@@ -733,7 +733,7 @@ def post(self, request):
                             field_name,
                             related_object_form,
                         ) in self.related_object_forms.items():
-                            logger.debug("Processing form for related objects: {related_object_form}")
+                            logger.debug(f"Processing form for related objects: {related_object_form}")
 
                             related_obj_pks = []
                             for i, rel_obj_data in enumerate(data.get(field_name, [])):
@@ -765,7 +765,7 @@ def post(self, request):
                 except ObjectDoesNotExist:
                     msg = "Object creation failed due to object-level permissions violation"
                     logger.debug(msg)
-                    form.add_error(None, msg)
+                    model_form.add_error(None, msg)
 
             if not model_form.errors:
                 logger.info(f"Import object {obj} (PK: {obj.pk})")
diff --git a/nautobot/dcim/forms.py b/nautobot/dcim/forms.py
--- a/nautobot/dcim/forms.py
+++ b/nautobot/dcim/forms.py
@@ -1,3 +1,4 @@
+import logging
 import re
 
 from django import forms
@@ -126,6 +127,8 @@
     VirtualChassis,
 )
 
+logger = logging.getLogger(__name__)
+
 DEVICE_BY_PK_RE = r"{\d+\}"
 
 INTERFACE_MODE_HELP_TEXT = """
@@ -1338,6 +1341,11 @@ def __init__(self, device_type, data=None, *args, **kwargs):
 
         super().__init__(data, *args, **kwargs)
 
+        if "type" in self.fields:
+            # Since model.type has defined choices, the form field defaults to a ChoiceField which will reject unknowns.
+            # We want to instead be able to remap unknown values to "other" in clean_type() below, so we replace it:
+            self.fields["type"] = forms.CharField()
+
     def clean_device_type(self):
         data = self.cleaned_data["device_type"]
 
@@ -1348,6 +1356,29 @@ def clean_device_type(self):
 
         return data
 
+    def clean_type(self):
+        data = self.cleaned_data["type"]
+        choices = self.Meta.model._meta.get_field("type").choices
+
+        try:
+            if data not in choices.values():
+                logger.debug(
+                    'For %s "%s", the "type" value "%s" is unrecognized and will be replaced by "%s"',
+                    self.Meta.model.__name__,
+                    self.cleaned_data["name"],
+                    data,
+                    choices.TYPE_OTHER,
+                )
+                data = choices.TYPE_OTHER
+        except AttributeError:
+            logger.warning(
+                "Either %s.type.choices is not a ChoiceSet, or %s.type.choices.TYPE_OTHER is not defined",
+                self.Meta.model.__name__,
+                self.Meta.model.__name__,
+            )
+
+        return data
+
 
 class ConsolePortTemplateImportForm(ComponentTemplateImportForm):
     class Meta:
@@ -1413,8 +1444,6 @@ def is_valid(self):
 
 
 class InterfaceTemplateImportForm(ComponentTemplateImportForm):
-    type = forms.ChoiceField(choices=InterfaceTypeChoices.CHOICES)
-
     class Meta:
         model = InterfaceTemplate
         fields = [
@@ -1427,7 +1456,6 @@ class Meta:
 
 
 class FrontPortTemplateImportForm(ComponentTemplateImportForm):
-    type = forms.ChoiceField(choices=PortTypeChoices.CHOICES)
     rear_port_template = forms.ModelChoiceField(
         queryset=RearPortTemplate.objects.all(), to_field_name="name", required=False
     )
@@ -1455,8 +1483,6 @@ def is_valid(self):
 
 
 class RearPortTemplateImportForm(ComponentTemplateImportForm):
-    type = forms.ChoiceField(choices=PortTypeChoices.CHOICES)
-
     class Meta:
         model = RearPortTemplate
         fields = [


Test patch:
diff --git a/nautobot/dcim/tests/test_views.py b/nautobot/dcim/tests/test_views.py
--- a/nautobot/dcim/tests/test_views.py
+++ b/nautobot/dcim/tests/test_views.py
@@ -712,9 +712,6 @@ def test_import_objects(self):
   - name: Device Bay 3
 """
 
-        # Create the manufacturer
-        Manufacturer.objects.first()
-
         # Add all required permissions to the test user
         self.add_permissions(
             "dcim.view_devicetype",
@@ -778,6 +775,106 @@ def test_import_objects(self):
         db1 = DeviceBayTemplate.objects.first()
         self.assertEqual(db1.name, "Device Bay 1")
 
+    def test_import_objects_unknown_type_enums(self):
+        """
+        YAML import of data with `type` values that we don't recognize should remap those to "other" rather than fail.
+        """
+        manufacturer = Manufacturer.objects.first()
+        IMPORT_DATA = f"""
+manufacturer: {manufacturer.name}
+model: TEST-2000
+u_height: 0
+subdevice_role: parent
+comments: "test comment"
+console-ports:
+  - name: Console Port Alpha-Beta
+    type: alpha-beta
+console-server-ports:
+  - name: Console Server Port Pineapple
+    type: pineapple
+power-ports:
+  - name: Power Port Fred
+    type: frederick
+power-outlets:
+  - name: Power Outlet Rick
+    type: frederick
+    power_port_template: Power Port Fred
+interfaces:
+  - name: Interface North
+    type: northern
+rear-ports:
+  - name: Rear Port Foosball
+    type: foosball
+front-ports:
+  - name: Front Port Pickleball
+    type: pickleball
+    rear_port_template: Rear Port Foosball
+device-bays:
+  - name: Device Bay of Uncertain Type
+    type: unknown  # should be ignored
+  - name: Device Bay of Unspecified Type
+"""
+        # Add all required permissions to the test user
+        self.add_permissions(
+            "dcim.view_devicetype",
+            "dcim.view_manufacturer",
+            "dcim.add_devicetype",
+            "dcim.add_consoleporttemplate",
+            "dcim.add_consoleserverporttemplate",
+            "dcim.add_powerporttemplate",
+            "dcim.add_poweroutlettemplate",
+            "dcim.add_interfacetemplate",
+            "dcim.add_frontporttemplate",
+            "dcim.add_rearporttemplate",
+            "dcim.add_devicebaytemplate",
+        )
+
+        form_data = {"data": IMPORT_DATA, "format": "yaml"}
+        response = self.client.post(reverse("dcim:devicetype_import"), data=form_data, follow=True)
+        self.assertHttpStatus(response, 200)
+        dt = DeviceType.objects.get(model="TEST-2000")
+        self.assertEqual(dt.comments, "test comment")
+
+        # Verify all of the components were created with appropriate "other" types
+        self.assertEqual(dt.console_port_templates.count(), 1)
+        cpt = ConsolePortTemplate.objects.filter(device_type=dt).first()
+        self.assertEqual(cpt.name, "Console Port Alpha-Beta")
+        self.assertEqual(cpt.type, ConsolePortTypeChoices.TYPE_OTHER)
+
+        self.assertEqual(dt.console_server_port_templates.count(), 1)
+        cspt = ConsoleServerPortTemplate.objects.filter(device_type=dt).first()
+        self.assertEqual(cspt.name, "Console Server Port Pineapple")
+        self.assertEqual(cspt.type, ConsolePortTypeChoices.TYPE_OTHER)
+
+        self.assertEqual(dt.power_port_templates.count(), 1)
+        ppt = PowerPortTemplate.objects.filter(device_type=dt).first()
+        self.assertEqual(ppt.name, "Power Port Fred")
+        self.assertEqual(ppt.type, PowerPortTypeChoices.TYPE_OTHER)
+
+        self.assertEqual(dt.power_outlet_templates.count(), 1)
+        pot = PowerOutletTemplate.objects.filter(device_type=dt).first()
+        self.assertEqual(pot.name, "Power Outlet Rick")
+        self.assertEqual(pot.type, PowerOutletTypeChoices.TYPE_OTHER)
+        self.assertEqual(pot.power_port_template, ppt)
+
+        self.assertEqual(dt.interface_templates.count(), 1)
+        it = InterfaceTemplate.objects.filter(device_type=dt).first()
+        self.assertEqual(it.name, "Interface North")
+        self.assertEqual(it.type, InterfaceTypeChoices.TYPE_OTHER)
+
+        self.assertEqual(dt.rear_port_templates.count(), 1)
+        rpt = RearPortTemplate.objects.filter(device_type=dt).first()
+        self.assertEqual(rpt.name, "Rear Port Foosball")
+        self.assertEqual(rpt.type, PortTypeChoices.TYPE_OTHER)
+
+        self.assertEqual(dt.front_port_templates.count(), 1)
+        fpt = FrontPortTemplate.objects.filter(device_type=dt).first()
+        self.assertEqual(fpt.name, "Front Port Pickleball")
+        self.assertEqual(fpt.type, PortTypeChoices.TYPE_OTHER)
+
+        self.assertEqual(dt.device_bay_templates.count(), 2)
+        # DeviceBayTemplate doesn't have a type field.
+
     def test_devicetype_export(self):
         url = reverse("dcim:devicetype_list")
         self.add_permissions("dcim.view_devicetype")