Repository: nautobot/nautobot

Issue:
Nautobot 2.2 can't import DeviceType yaml or json files
<!--
    NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED.

    This form is only for reporting reproducible bugs. If you need assistance
    with Nautobot installation, or if you have a general question, please start a
    discussion instead: https://github.com/nautobot/nautobot/discussions

    Please describe the environment in which you are running Nautobot. Be sure
    that you are running an unmodified instance of the latest stable release
    before submitting a bug report, and that any plugins have been disabled.
-->
### Environment
* Nautobot version (Docker tag too if applicable): 2.2.0a1
* Python version: n/a
* Database platform, version: n/a
* Middleware(s): n/a

<!--
    Describe in detail the exact steps that someone else can take to reproduce
    this bug using the current stable release of Nautobot. Begin with the
    creation of any necessary database objects and call out every operation
    being performed explicitly. If reporting a bug in the REST API, be sure to
    reconstruct the raw HTTP request(s) being made: Don't rely on a client
    library such as pynautobot.
-->
### Steps to Reproduce
1. Go to `next.demo.nautobot.com`
2. Click on `Devices` -> `Device Types`
3. Click on the `Import` button

<!-- What did you expect to happen? -->
### Expected Behavior
I'm directed to the form at https://next.demo.nautobot.com/dcim/device-types/import/ or a job form that can accept YAML or JSON

<!-- What happened instead? -->
### Observed Behavior
I'm directed to the generic CSV job import form at https://next.demo.nautobot.com/extras/jobs/nautobot.core.jobs.ImportObjects/run/?content_type=110


Discussion:
As part of this issue we should also 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. 
IMO we should keep the default CSV import button for DeviceTypes and add a new "Import Single YAML/JSON" button to the list view that explicitly goes to the `/import/` URL.
Perhaps they could be separate tabs on the device type import view? That view already has tabs so we could add one more?

![image](https://github.com/nautobot/nautobot/assets/75227981/a05c4f28-3ede-4b5c-8930-964929f62ae2)

<img width="878" src="https://api.zenhub.com/attachedFiles/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBd0RJQXc9PSIsImV4cCI6bnVsbCwicHVyIjoiYmxvYl9pZCJ9fQ==--9e5745cf1bb34f38361041869518b874470bc347/Screenshot%202024-03-08%20at%202.43.59%20PM.png" alt="Screenshot 2024-03-08 at 2.43.59 PM.png" />

Let's use one of these on the device type list view where the yaml import is the default action.

Resolving patch:
diff --git a/nautobot/core/templatetags/buttons.py b/nautobot/core/templatetags/buttons.py
--- a/nautobot/core/templatetags/buttons.py
+++ b/nautobot/core/templatetags/buttons.py
@@ -109,14 +109,40 @@ def import_button(url):  # 3.0 TODO: remove, unused
     }
 
 
-@register.inclusion_tag("buttons/job_import.html")
-def job_import_button(content_type):
+@register.simple_tag
+def job_import_url(content_type):
+    """
+    URL to the run view for the CSV Import system job, prefilled with the given content-type.
+
+    Helper to `job_import_button` tag, but can be used separately if needed.
+    """
     try:
         import_url = reverse("extras:job_run_by_class_path", kwargs={"class_path": "nautobot.core.jobs.ImportObjects"})
         import_url += f"?content_type={content_type.id}"
     except NoReverseMatch:
         import_url = None
-    return {"import_url": import_url}
+    return import_url
+
+
+@register.inclusion_tag("buttons/job_import.html")
+def job_import_button(content_type):
+    return {"import_url": job_import_url(content_type)}
+
+
+@register.simple_tag
+def job_export_url():
+    """
+    URL to the run view for the Export Object List system job.
+
+    Helper to `export_button` tag, but can be used separately if needed.
+    """
+    try:
+        export_url = reverse(
+            "extras:job_run_by_class_path", kwargs={"class_path": "nautobot.core.jobs.ExportObjectList"}
+        )
+    except NoReverseMatch:
+        export_url = None
+    return export_url
 
 
 @register.inclusion_tag("buttons/export.html", takes_context=True)
@@ -124,12 +150,7 @@ def export_button(context, content_type=None):
     if content_type is not None:
         user = context["request"].user
         export_templates = models.ExportTemplate.objects.restrict(user, "view").filter(content_type=content_type)
-        try:
-            export_url = reverse(
-                "extras:job_run_by_class_path", kwargs={"class_path": "nautobot.core.jobs.ExportObjectList"}
-            )
-        except NoReverseMatch:
-            export_url = None
+        export_url = job_export_url()
         include_yaml_option = hasattr(content_type.model_class(), "to_yaml")
     else:
         export_templates = []
diff --git a/nautobot/dcim/views.py b/nautobot/dcim/views.py
--- a/nautobot/dcim/views.py
+++ b/nautobot/dcim/views.py
@@ -616,6 +616,7 @@ class DeviceTypeListView(generic.ObjectListView):
     filterset = filters.DeviceTypeFilterSet
     filterset_form = forms.DeviceTypeFilterForm
     table = tables.DeviceTypeTable
+    template_name = "dcim/devicetype_list.html"
     use_new_ui = True
 
 


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
@@ -13,6 +13,7 @@
 
 from nautobot.circuits.choices import CircuitTerminationSideChoices
 from nautobot.circuits.models import Circuit, CircuitTermination, CircuitType, Provider
+from nautobot.core.templatetags.buttons import job_export_url, job_import_url
 from nautobot.core.testing import extract_page_body, ModelViewTestCase, post_data, ViewTestCases
 from nautobot.core.testing.utils import generate_random_device_asset_tag_of_specified_size
 from nautobot.dcim.choices import (
@@ -613,23 +614,35 @@ def setUpTestData(cls):
             "is_full_depth": False,
         }
 
-    # Temporary FIXME(jathan): Literally just trying to get the tests running so
-    # we can keep moving on the fixture factories. This should be removed once
-    # we've cleaned up all the hard-coded object comparisons and are all in on
-    # factories.
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
-    def test_bulk_edit_objects_with_constrained_permission(self):
-        DeviceType.objects.exclude(model__startswith="Test Device Type").delete()
-        super().test_bulk_edit_objects_with_constrained_permission()
-
-    # Temporary FIXME(jathan): Literally just trying to get the tests running so
-    # we can keep moving on the fixture factories. This should be removed once
-    # we've cleaned up all the hard-coded object comparisons and are all in on
-    # factories.
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
-    def test_bulk_edit_objects_with_permission(self):
-        DeviceType.objects.exclude(model__startswith="Test Device Type").delete()
-        super().test_bulk_edit_objects_with_permission()
+    def test_list_has_correct_links(self):
+        """Assert that the DeviceType list view has import/export buttons for both CSV and YAML/JSON formats."""
+        self.add_permissions("dcim.add_devicetype", "dcim.view_devicetype")
+        response = self.client.get(reverse("dcim:devicetype_list"))
+        self.assertHttpStatus(response, 200)
+        content = extract_page_body(response.content.decode(response.charset))
+
+        yaml_import_url = reverse("dcim:devicetype_import")
+        csv_import_url = job_import_url(ContentType.objects.get_for_model(DeviceType))
+        # Main import button links to YAML/JSON import
+        self.assertInHTML(
+            f'<a id="import-button" type="button" class="btn btn-info" href="{yaml_import_url}">'
+            '<span class="mdi mdi-database-import" aria-hidden="true"></span> Import</a>',
+            content,
+        )
+        # Dropdown provides both YAML/JSON and CSV import as options
+        self.assertInHTML(f'<a href="{yaml_import_url}">JSON/YAML format (single record)</a>', content)
+        self.assertInHTML(f'<a href="{csv_import_url}">CSV format (multiple records)</a>', content)
+
+        export_url = job_export_url()
+        # Export is a little trickier to check since it's done as a form submission rather than an <a> element.
+        self.assertIn(f'<form action="{export_url}" method="post">', content)
+        self.assertInHTML(
+            f'<input type="hidden" name="content_type" value="{ContentType.objects.get_for_model(DeviceType).pk}">',
+            content,
+        )
+        self.assertInHTML('<input type="hidden" name="export_format" value="yaml">', content)
+        self.assertInHTML('<button type="submit" class="btn btn-link">YAML format</button>', content)
+        self.assertInHTML('<button type="submit" class="btn btn-link">CSV format</button>', content)
 
     @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
     def test_import_objects(self):