Repository: django-import-export/django-import-export

Issue:
Default to read-only field on import if a single Resource or file format is defined
**Describe the bug**

Export allows for single elements to be pre-selected (added in #1671):

![image](https://github.com/django-import-export/django-import-export/assets/6249838/f1d4005a-27e6-43dc-b623-18d6c4bb0223)

However this behaviour is not supported on the import page:

![image](https://github.com/django-import-export/django-import-export/assets/6249838/87b4a7cd-3a82-4dc5-bbba-082d5237c8c3)

**To Reproduce**

Import / Export specified model

**Versions (please complete the following information):**
 - Django Import Export: 4.0
 - Python 3.11
 - Django 4.2


Perhaps if there is only ever one resource and file format, this page is redundant and can be skipped?

This doesn't apply because user still has to choose file to import.




Discussion:

Resolving patch:
diff --git a/import_export/admin.py b/import_export/admin.py
--- a/import_export/admin.py
+++ b/import_export/admin.py
@@ -149,9 +149,9 @@ def process_import(self, request, *args, **kwargs):
         confirm_form = self.create_confirm_form(request)
         if confirm_form.is_valid():
             import_formats = self.get_import_formats()
-            input_format = import_formats[
-                int(confirm_form.cleaned_data["input_format"])
-            ](encoding=self.from_encoding)
+            input_format = import_formats[int(confirm_form.cleaned_data["format"])](
+                encoding=self.from_encoding
+            )
             encoding = None if input_format.is_binary() else self.from_encoding
             tmp_storage_cls = self.get_tmp_storage_class()
             tmp_storage = tmp_storage_cls(
@@ -275,9 +275,7 @@ def create_import_form(self, request):
         form_class = self.get_import_form_class(request)
         kwargs = self.get_import_form_kwargs(request)
 
-        return form_class(
-            formats, resources=self.get_import_resource_classes(), **kwargs
-        )
+        return form_class(formats, self.get_import_resource_classes(), **kwargs)
 
     def get_import_form_class(self, request):
         """
@@ -376,7 +374,7 @@ def get_confirm_form_initial(self, request, import_form):
                 "import_file"
             ].tmp_storage_name,
             "original_file_name": import_form.cleaned_data["import_file"].name,
-            "input_format": import_form.cleaned_data["input_format"],
+            "format": import_form.cleaned_data["format"],
             "resource": import_form.cleaned_data.get("resource", ""),
         }
 
@@ -430,9 +428,7 @@ def import_action(self, request, *args, **kwargs):
         import_form = self.create_import_form(request)
         resources = list()
         if request.POST and import_form.is_valid():
-            input_format = import_formats[
-                int(import_form.cleaned_data["input_format"])
-            ]()
+            input_format = import_formats[int(import_form.cleaned_data["format"])]()
             if not input_format.is_binary():
                 input_format.encoding = self.from_encoding
             import_file = import_form.cleaned_data["import_file"]
@@ -663,7 +659,6 @@ def get_export_data(self, file_format, queryset, *args, **kwargs):
         data = self.get_data_for_export(
             request,
             queryset,
-            *args,
             **kwargs,
         )
         export_data = file_format.export_data(data)
@@ -691,7 +686,9 @@ def export_action(self, request):
         form_type = self.get_export_form_class()
         formats = self.get_export_formats()
         form = form_type(
-            formats, request.POST or None, resources=self.get_export_resource_classes()
+            formats,
+            self.get_export_resource_classes(),
+            data=request.POST or None,
         )
         form.fields["export_items"] = MultipleChoiceField(
             widget=MultipleHiddenInput,
@@ -699,7 +696,7 @@ def export_action(self, request):
             choices=[(o.id, o.id) for o in self.model.objects.all()],
         )
         if form.is_valid():
-            file_format = formats[int(form.cleaned_data["file_format"])]()
+            file_format = formats[int(form.cleaned_data["format"])]()
 
             if "export_items" in form.changed_data:
                 # this request has arisen from an Admin UI action
@@ -831,7 +828,7 @@ def export_admin_action(self, request, queryset):
         form_type = self.get_export_form_class()
         formats = self.get_export_formats()
         form = form_type(
-            formats,
+            formats=formats,
             resources=self.get_export_resource_classes(),
             initial={"export_items": list(queryset.values_list("id", flat=True))},
         )
diff --git a/import_export/forms.py b/import_export/forms.py
--- a/import_export/forms.py
+++ b/import_export/forms.py
@@ -11,38 +11,57 @@ class ImportExportFormBase(forms.Form):
         choices=(),
         required=False,
     )
+    format = forms.ChoiceField(
+        label=_("Format"),
+        choices=(),
+    )
 
-    def __init__(self, *args, resources=None, **kwargs):
+    def __init__(self, formats, resources, *args, **kwargs):
         super().__init__(*args, **kwargs)
+        self._init_resources(resources)
+        self._init_formats(formats)
+
+    def _init_resources(self, resources):
         if not resources:
             raise ValueError("no defined resources")
+        if len(resources) == 1:
+            self.fields["resource"].value = resources[0].get_display_name()
+            self.fields["resource"].widget.attrs["readonly"] = True
         if len(resources) > 1:
             resource_choices = []
             for i, resource in enumerate(resources):
                 resource_choices.append((i, resource.get_display_name()))
             self.fields["resource"].choices = resource_choices
-        else:
-            self.fields["resource"].value = resources[0].get_display_name()
-            self.fields["resource"].widget.attrs["readonly"] = True
+
+    def _init_formats(self, formats):
+        if not formats:
+            raise ValueError("invalid formats list")
+
+        choices = [(str(i), f().get_title()) for i, f in enumerate(formats)]
+        if len(formats) == 1:
+            field = self.fields["format"]
+            field.value = formats[0]().get_title()
+            field.initial = 0
+            field.widget.attrs["readonly"] = True
+        if len(formats) > 1:
+            choices.insert(0, ("", "---"))
+
+        self.fields["format"].choices = choices
 
 
 class ImportForm(ImportExportFormBase):
     import_file = forms.FileField(label=_("File to import"))
-    input_format = forms.ChoiceField(
-        label=_("Format"),
-        choices=(),
-    )
 
-    def __init__(self, import_formats, *args, **kwargs):
-        resources = kwargs.pop("resources", None)
-        super().__init__(*args, resources=resources, **kwargs)
-        choices = [(str(i), f().get_title()) for i, f in enumerate(import_formats)]
-        if len(import_formats) > 1:
-            choices.insert(0, ("", "---"))
-            self.fields["import_file"].widget.attrs["class"] = "guess_format"
-            self.fields["input_format"].widget.attrs["class"] = "guess_format"
+    # field ordered for usability:
+    # ensure that the 'file' select appears before 'format'
+    # so that the 'guess_format' js logic makes sense
+    field_order = ["resource", "import_file", "format"]
 
-        self.fields["input_format"].choices = choices
+    def __init__(self, formats, resources, *args, **kwargs):
+        super().__init__(formats, resources, *args, **kwargs)
+        if len(formats) > 1:
+            self.fields["import_file"].widget.attrs["class"] = "guess_format"
+            self.fields["format"].widget.attrs["class"] = "guess_format"
 
     @property
     def media(self):
@@ -59,7 +78,7 @@ def media(self):
 class ConfirmImportForm(forms.Form):
     import_file_name = forms.CharField(widget=forms.HiddenInput())
     original_file_name = forms.CharField(widget=forms.HiddenInput())
-    input_format = forms.CharField(widget=forms.HiddenInput())
+    format = forms.CharField(widget=forms.HiddenInput())
     resource = forms.CharField(widget=forms.HiddenInput(), required=False)
 
     def clean_import_file_name(self):
@@ -69,33 +88,6 @@ def clean_import_file_name(self):
 
 
 class ExportForm(ImportExportFormBase):
-    file_format = forms.ChoiceField(
-        label=_("Format"),
-        choices=(),
-    )
     export_items = forms.MultipleChoiceField(
         widget=forms.MultipleHiddenInput(), required=False
     )
-
-    def __init__(self, formats, *args, **kwargs):
-        resources = kwargs.pop("resources", None)
-        super().__init__(*args, resources=resources, **kwargs)
-        choices = []
-        for i, f in enumerate(formats):
-            choices.append(
-                (
-                    str(i),
-                    f().get_title(),
-                )
-            )
-        if len(formats) > 1:
-            choices.insert(0, ("", "---"))
-        elif len(formats) == 1:
-            field = self.fields["file_format"]
-            field.value = formats[0]().get_title()
-            field.initial = 0
-            field.widget.attrs["readonly"] = True
-        else:
-            raise ValueError("invalid export formats list")
-
-        self.fields["file_format"].choices = choices
diff --git a/import_export/mixins.py b/import_export/mixins.py
--- a/import_export/mixins.py
+++ b/import_export/mixins.py
@@ -151,7 +151,7 @@ def form_valid(self, form):
             stacklevel=2,
         )
         formats = self.get_export_formats()
-        file_format = formats[int(form.cleaned_data["file_format"])]()
+        file_format = formats[int(form.cleaned_data["format"])]()
         if hasattr(self, "get_filterset"):
             queryset = self.get_filterset(self.get_filterset_class()).qs
         else:


Test patch:
diff --git a/tests/core/exports/authors.csv b/tests/core/exports/authors.csv
new file mode 100644
--- /dev/null
+++ b/tests/core/exports/authors.csv
@@ -0,0 +1,2 @@
+id,name
+1,J. R. R. Tolkien
diff --git a/tests/core/tests/admin_integration/mixins.py b/tests/core/tests/admin_integration/mixins.py
--- a/tests/core/tests/admin_integration/mixins.py
+++ b/tests/core/tests/admin_integration/mixins.py
@@ -36,7 +36,7 @@ def _do_import_post(
         )
         with open(filename, "rb") as f:
             data = {
-                "input_format": str(input_format),
+                "format": str(input_format),
                 "import_file": f,
             }
             if encoding:
diff --git a/tests/core/tests/admin_integration/test_action.py b/tests/core/tests/admin_integration/test_action.py
--- a/tests/core/tests/admin_integration/test_action.py
+++ b/tests/core/tests/admin_integration/test_action.py
@@ -78,7 +78,7 @@ def test_export_displays_ui_select_page_multiple_items(self):
     @ignore_widget_deprecation_warning
     def test_export_post(self):
         # create a POST request with data selected from the 'action' export
-        data = {"file_format": "0", "export_items": [str(self.cat1.id)]}
+        data = {"format": "0", "export_items": [str(self.cat1.id)]}
         date_str = datetime.now().strftime("%Y-%m-%d")
         response = self.client.post("/admin/core/category/export/", data)
         self.assertEqual(response.status_code, 200)
diff --git a/tests/core/tests/admin_integration/test_export.py b/tests/core/tests/admin_integration/test_export.py
--- a/tests/core/tests/admin_integration/test_export.py
+++ b/tests/core/tests/admin_integration/test_export.py
@@ -28,7 +28,7 @@ def test_export(self):
         self.assertEqual(2, len(form.fields["resource"].choices))
 
         data = {
-            "file_format": "0",
+            "format": "0",
         }
         date_str = datetime.now().strftime("%Y-%m-%d")
         response = self.client.post("/admin/core/book/export/", data)
@@ -61,7 +61,7 @@ def test_export_second_resource(self):
         self.assertContains(response, "Export/Import only book names")
 
         data = {
-            "file_format": "0",
+            "format": "0",
             "resource": 1,
         }
         date_str = datetime.now().strftime("%Y-%m-%d")
@@ -104,14 +104,14 @@ def test_export_displays_resources_fields(self):
     def test_get_export_form_single_format(self):
         response = self.client.get("/admin/core/category/export/")
         form = response.context["form"]
-        self.assertEqual(1, len(form.fields["file_format"].choices))
-        self.assertTrue(form.fields["file_format"].widget.attrs["readonly"])
+        self.assertEqual(1, len(form.fields["format"].choices))
+        self.assertTrue(form.fields["format"].widget.attrs["readonly"])
         self.assertIn("xlsx", str(response.content))
-        self.assertNotIn('select name="file_format"', str(response.content))
+        self.assertNotIn('select name="format"', str(response.content))
 
     @override_settings(EXPORT_FORMATS=[])
     def test_export_empty_export_formats(self):
-        with self.assertRaisesRegex(ValueError, "invalid export formats list"):
+        with self.assertRaisesRegex(ValueError, "invalid formats list"):
             self.client.get("/admin/core/category/export/")
 
     def test_returns_xlsx_export(self):
@@ -119,7 +119,7 @@ def test_returns_xlsx_export(self):
         self.assertEqual(response.status_code, 200)
 
         xlsx_index = self._get_input_format_index("xlsx")
-        data = {"file_format": str(xlsx_index)}
+        data = {"format": str(xlsx_index)}
         response = self.client.post("/admin/core/book/export/", data)
         self.assertEqual(response.status_code, 200)
         self.assertTrue(response.has_header("Content-Disposition"))
@@ -137,7 +137,7 @@ def test_export_escape_formulae(self):
         self.assertEqual(response.status_code, 200)
 
         xlsx_index = self._get_input_format_index("xlsx")
-        data = {"file_format": str(xlsx_index)}
+        data = {"format": str(xlsx_index)}
         response = self.client.post("/admin/core/book/export/", data)
         self.assertEqual(response.status_code, 200)
         content = response.content
@@ -153,7 +153,7 @@ def test_export_escape_formulae_csv(self):
         self.assertEqual(response.status_code, 200)
 
         index = self._get_input_format_index("csv")
-        data = {"file_format": str(index)}
+        data = {"format": str(index)}
         response = self.client.post("/admin/core/book/export/", data)
         self.assertIn(
             f"{b1.id},SUM(1+1),,,0,,,,,\r\n".encode(),
@@ -168,7 +168,7 @@ def test_export_escape_formulae_csv_false(self):
         self.assertEqual(response.status_code, 200)
 
         index = self._get_input_format_index("csv")
-        data = {"file_format": str(index)}
+        data = {"format": str(index)}
         response = self.client.post("/admin/core/book/export/", data)
         self.assertIn(
             f"{b1.id},=SUM(1+1),,,0,,,,,\r\n".encode(),
@@ -184,7 +184,7 @@ def test_export_filters_by_form_param(self):
         # issue 1578
         author = Author.objects.get(name="Ian Fleming")
 
-        data = {"file_format": "0", "author": str(author.id)}
+        data = {"format": "0", "author": str(author.id)}
         date_str = datetime.now().strftime("%Y-%m-%d")
         response = self.client.post("/admin/core/ebook/export/", data)
         self.assertEqual(response.status_code, 200)
@@ -205,7 +205,7 @@ def test_export_filters_by_form_param(self):
 
 class TestExportEncoding(TestCase):
     mock_request = MagicMock(spec=HttpRequest)
-    mock_request.POST = {"file_format": 0}
+    mock_request.POST = {"format": 0}
 
     class TestMixin(ExportMixin):
         model = Book
diff --git a/tests/core/tests/admin_integration/test_import.py b/tests/core/tests/admin_integration/test_import.py
--- a/tests/core/tests/admin_integration/test_import.py
+++ b/tests/core/tests/admin_integration/test_import.py
@@ -16,6 +16,7 @@
 
 from import_export.admin import ExportMixin
 from import_export.formats import base_formats
+from import_export.formats.base_formats import XLSX
 
 
 class ImportAdminIntegrationTest(AdminTestMixin, TestCase):
@@ -299,7 +300,7 @@ def test_import_file_name_in_tempdir(self):
             os.path.dirname(__file__), os.path.pardir, "exports", "books.csv"
         )
         data = {
-            "input_format": "0",
+            "format": "0",
             "import_file_name": import_file_name,
             "original_file_name": "books.csv",
         }
@@ -366,7 +367,7 @@ def test_import_with_customized_forms(self):
             "books.csv",
         )
         with open(filename, "rb") as fobj:
-            data = {"author": 11, "input_format": input_format, "import_file": fobj}
+            data = {"author": 11, "format": input_format, "import_file": fobj}
             response = self.client.post("/admin/core/ebook/import/", data)
 
         self.assertEqual(response.status_code, 200)
@@ -456,8 +457,7 @@ def test_import_admin_uses_import_format_settings(self):
         mock_site = mock.MagicMock()
         import_form = BookAdmin(Book, mock_site).create_import_form(request)
 
-        items = list(import_form.fields.items())
-        file_format = items[len(items) - 1][1]
+        file_format = import_form.fields["format"]
         choices = file_format.choices
 
         self.assertEqual(len(choices), 3)
@@ -465,6 +465,20 @@ def test_import_admin_uses_import_format_settings(self):
         self.assertEqual(choices[1][1], "xlsx")
         self.assertEqual(choices[2][1], "xls")
 
+    @override_settings(IMPORT_FORMATS=[XLSX])
+    def test_get_export_form_single_format(self):
+        response = self.client.get(self.book_import_url)
+        form = response.context["form"]
+        self.assertEqual(1, len(form.fields["format"].choices))
+        self.assertTrue(form.fields["format"].widget.attrs["readonly"])
+        self.assertIn("xlsx", str(response.content))
+        self.assertNotIn('select name="format"', str(response.content))
+
+    @override_settings(IMPORT_FORMATS=[])
+    def test_export_empty_import_formats(self):
+        with self.assertRaisesRegex(ValueError, "invalid formats list"):
+            self.client.get(self.book_import_url)
+
 
 class ConfirmImportEncodingTest(AdminTestMixin, TestCase):
     """Test handling 'confirm import' step using different file encodings
diff --git a/tests/core/tests/test_forms.py b/tests/core/tests/test_forms.py
--- a/tests/core/tests/test_forms.py
+++ b/tests/core/tests/test_forms.py
@@ -1,6 +1,7 @@
 from django.test import TestCase
 
 from import_export import forms, resources
+from import_export.formats.base_formats import CSV
 
 
 class MyResource(resources.ModelResource):
@@ -10,20 +11,19 @@ class Meta:
 
 class FormTest(TestCase):
     def test_formbase_init_blank_resources(self):
-        resource_list = []
         with self.assertRaises(ValueError):
-            forms.ImportExportFormBase(resources=resource_list)
+            forms.ImportExportFormBase(["format1"], [])
 
     def test_formbase_init_one_resource(self):
         resource_list = [resources.ModelResource]
-        form = forms.ImportExportFormBase(resources=resource_list)
+        form = forms.ImportExportFormBase([CSV], resource_list)
         self.assertTrue("resource" in form.fields)
         self.assertEqual("ModelResource", form.fields["resource"].value)
         self.assertTrue(form.fields["resource"].widget.attrs["readonly"])
 
     def test_formbase_init_two_resources(self):
         resource_list = [resources.ModelResource, MyResource]
-        form = forms.ImportExportFormBase(resources=resource_list)
+        form = forms.ImportExportFormBase([CSV], resource_list)
         self.assertEqual(
             form.fields["resource"].choices,
             [(0, "ModelResource"), (1, "My super resource")],
diff --git a/tests/core/tests/test_mixins.py b/tests/core/tests/test_mixins.py
--- a/tests/core/tests/test_mixins.py
+++ b/tests/core/tests/test_mixins.py
@@ -22,10 +22,10 @@ def setUp(self):
         self.cat1 = Category.objects.create(name="Cat 1")
         self.cat2 = Category.objects.create(name="Cat 2")
         self.form = ExportViewMixinTest.TestExportForm(
-            formats.base_formats.DEFAULT_FORMATS,
+            formats=formats.base_formats.DEFAULT_FORMATS,
             resources=[modelresource_factory(Category)],
         )
-        self.form.cleaned_data["file_format"] = "0"
+        self.form.cleaned_data["format"] = "0"
 
     def test_get(self):
         response = self.client.get(self.url)
@@ -35,7 +35,7 @@ def test_get(self):
     @ignore_widget_deprecation_warning
     def test_post(self):
         data = {
-            "file_format": "0",
+            "format": "0",
         }
         with warnings.catch_warnings():
             warnings.filterwarnings("ignore", category=DeprecationWarning)
diff --git a/tests/core/tests/test_permissions.py b/tests/core/tests/test_permissions.py
--- a/tests/core/tests/test_permissions.py
+++ b/tests/core/tests/test_permissions.py
@@ -37,7 +37,7 @@ def test_import(self):
 
         with open(filename, "rb") as f:
             data = {
-                "input_format": input_format,
+                "format": input_format,
                 "import_file": f,
             }
 
@@ -61,7 +61,7 @@ def test_import(self):
 
         with open(filename, "rb") as f:
             data = {
-                "input_format": input_format,
+                "format": input_format,
                 "import_file": f,
             }
 
@@ -78,7 +78,7 @@ def test_export_with_permission_set(self):
         response = self.client.get("/admin/core/book/export/")
         self.assertEqual(response.status_code, 403)
 
-        data = {"file_format": "0"}
+        data = {"format": "0"}
         response = self.client.post("/admin/core/book/export/", data)
         self.assertEqual(response.status_code, 403)
 
@@ -86,7 +86,7 @@ def test_export_with_permission_set(self):
         response = self.client.get("/admin/core/book/export/")
         self.assertEqual(response.status_code, 200)
 
-        data = {"file_format": "0"}
+        data = {"format": "0"}
         response = self.client.post("/admin/core/book/export/", data)
         self.assertEqual(response.status_code, 200)