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

Issue:
[3.0] Export Action should support multiple resources too
When using import-export 3.0, the export action in the changelist does not allow resource selection.

I think we should add the support to complete the multi resource feature.

https://github.com/django-import-export/django-import-export/blob/033f803c5994ceba9da8b610819ee5b52a630bf7/import_export/admin.py#L489

@matthewhegarty @PetrDlouhy what do you think?



Discussion:
Will take a look when I get chance

Resolving patch:
diff --git a/import_export/admin.py b/import_export/admin.py
--- a/import_export/admin.py
+++ b/import_export/admin.py
@@ -2,14 +2,15 @@
 import warnings
 
 import django
-from django import forms
 from django.conf import settings
 from django.contrib import admin, messages
 from django.contrib.admin.models import ADDITION, CHANGE, DELETION, LogEntry
 from django.contrib.auth import get_permission_codename
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import PermissionDenied
+from django.forms import MultipleChoiceField, MultipleHiddenInput
 from django.http import HttpResponse, HttpResponseRedirect
+from django.shortcuts import render
 from django.template.response import TemplateResponse
 from django.urls import path, reverse
 from django.utils.decorators import method_decorator
@@ -17,7 +18,7 @@
 from django.utils.translation import gettext_lazy as _
 from django.views.decorators.http import require_POST
 
-from .forms import ConfirmImportForm, ExportForm, ImportForm, export_action_form_factory
+from .forms import ConfirmImportForm, ExportForm, ImportForm
 from .mixins import BaseExportMixin, BaseImportMixin
 from .results import RowResult
 from .signals import post_export, post_import
@@ -641,8 +642,8 @@ def get_export_queryset(self, request):
             "list_max_show_all": self.list_max_show_all,
             "list_editable": self.list_editable,
             "model_admin": self,
+            "sortable_by": self.sortable_by,
         }
-        changelist_kwargs["sortable_by"] = self.sortable_by
         if django.VERSION >= (4, 0):
             changelist_kwargs["search_help_text"] = self.search_help_text
         cl = ChangeList(**changelist_kwargs)
@@ -681,7 +682,7 @@ def get_export_form_class(self):
         """
         return self.export_form_class
 
-    def export_action(self, request, *args, **kwargs):
+    def export_action(self, request):
         if not self.has_export_permission(request):
             raise PermissionDenied
 
@@ -690,10 +691,24 @@ def export_action(self, request, *args, **kwargs):
         form = form_type(
             formats, request.POST or None, resources=self.get_export_resource_classes()
         )
+        form.fields["export_items"] = MultipleChoiceField(
+            widget=MultipleHiddenInput,
+            required=False,
+            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"])]()
 
-            queryset = self.get_export_queryset(request)
+            if "export_items" in form.changed_data:
+                # this request has arisen from an Admin UI action
+                # export item pks are stored in form data
+                # so generate the queryset from the stored pks
+                queryset = self.model.objects.filter(
+                    pk__in=form.cleaned_data["export_items"]
+                )
+            else:
+                queryset = self.get_export_queryset(request)
+
             export_data = self.get_export_data(
                 file_format,
                 queryset,
@@ -709,16 +724,9 @@ def export_action(self, request, *args, **kwargs):
 
             post_export.send(sender=None, model=self.model)
             return response
-
-        context = self.get_export_context_data()
-
-        context.update(self.admin_site.each_context(request))
-
-        context["title"] = _("Export")
-        context["form"] = form
-        context["opts"] = self.model._meta
+        context = self.init_request_context_data(request, form)
         request.current_app = self.admin_site.name
-        return TemplateResponse(request, [self.export_template_name], context)
+        return TemplateResponse(request, [self.export_template_name], context=context)
 
     def changelist_view(self, request, extra_context=None):
         if extra_context is None:
@@ -729,6 +737,14 @@ def changelist_view(self, request, extra_context=None):
     def get_export_filename(self, request, queryset, file_format):
         return super().get_export_filename(file_format)
 
+    def init_request_context_data(self, request, form):
+        context = self.get_export_context_data()
+        context.update(self.admin_site.each_context(request))
+        context["title"] = _("Export")
+        context["form"] = form
+        context["opts"] = self.model._meta
+        return context
+
 
 class ImportExportMixin(ImportMixin, ExportMixin):
     """
@@ -752,37 +768,20 @@ class ExportActionMixin(ExportMixin):
     Mixin with export functionality implemented as an admin action.
     """
 
-    # Don't use custom change list template.
-    import_export_change_list_template = None
-
-    def __init__(self, *args, **kwargs):
-        """
-        Adds a custom action form initialized with the available export
-        formats.
-        """
-        choices = []
-        formats = self.get_export_formats()
-        if formats:
-            for i, f in enumerate(formats):
-                choices.append((str(i), f().get_title()))
-
-        if len(formats) > 1:
-            choices.insert(0, ("", "---"))
-
-        self.action_form = export_action_form_factory(choices)
-        super().__init__(*args, **kwargs)
+    # This action will receive a selection of items as a queryset,
+    # store them in the context, and then render the 'export' admin form page,
+    # so that users can select file format and resource
 
     def export_admin_action(self, request, queryset):
         """
-        Exports the selected rows using file_format.
+        Action runs on POST from instance action menu (if enabled).
         """
-        export_format = request.POST.get("file_format")
-
-        if not export_format:
-            messages.warning(request, _("You must select an export format."))
-        else:
-            formats = self.get_export_formats()
-            file_format = formats[int(export_format)]()
+        formats = self.get_export_formats()
+        if (
+            getattr(settings, "IMPORT_EXPORT_SKIP_ADMIN_ACTION_EXPORT_UI", False)
+            is True
+        ):
+            file_format = formats[0]()
 
             export_data = self.get_export_data(
                 file_format, queryset, request=request, encoding=self.to_encoding
@@ -794,11 +793,31 @@ def export_admin_action(self, request, queryset):
             )
             return response
 
+        form_type = self.get_export_form_class()
+        formats = self.get_export_formats()
+        form = form_type(
+            formats,
+            resources=self.get_export_resource_classes(),
+            initial={"export_items": list(queryset.values_list("id", flat=True))},
+        )
+        # selected items are to be stored as a hidden input on the form
+        form.fields["export_items"] = MultipleChoiceField(
+            widget=MultipleHiddenInput,
+            required=False,
+            choices=[(str(o), str(o)) for o in queryset.all()],
+        )
+        context = self.init_request_context_data(request, form)
+
+        # this is necessary to render the FORM action correctly
+        # i.e. so the POST goes to the correct URL
+        context["export_suffix"] = "export/"
+
+        return render(request, "admin/import_export/export.html", context=context)
+
     def get_actions(self, request):
         """
         Adds the export action to the list of available actions.
         """
-
         actions = super().get_actions(request)
         actions.update(
             export_admin_action=(
@@ -809,14 +828,6 @@ def get_actions(self, request):
         )
         return actions
 
-    @property
-    def media(self):
-        super_media = super().media
-        return forms.Media(
-            js=super_media._js + ["import_export/action_formats.js"],
-            css=super_media._css,
-        )
-
 
 class ExportActionModelAdmin(ExportActionMixin, admin.ModelAdmin):
     """
diff --git a/import_export/forms.py b/import_export/forms.py
--- a/import_export/forms.py
+++ b/import_export/forms.py
@@ -2,7 +2,6 @@
 
 from django import forms
 from django.conf import settings
-from django.contrib.admin.helpers import ActionForm
 from django.utils.translation import gettext_lazy as _
 
 
@@ -15,13 +14,16 @@ class ImportExportFormBase(forms.Form):
 
     def __init__(self, *args, resources=None, **kwargs):
         super().__init__(*args, **kwargs)
-        if resources and len(resources) > 1:
+        if not resources:
+            raise ValueError("no defined resources")
+        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:
-            del self.fields["resource"]
+            self.fields["resource"].value = resources[0].get_display_name()
+            self.fields["resource"].widget.attrs["readonly"] = True
 
 
 class ImportForm(ImportExportFormBase):
@@ -71,6 +73,9 @@ class ExportForm(ImportExportFormBase):
         label=_("Format"),
         choices=(),
     )
+    export_items = forms.MultipleChoiceField(
+        widget=forms.MultipleHiddenInput(), required=False
+    )
 
     def __init__(self, formats, *args, **kwargs):
         resources = kwargs.pop("resources", None)
@@ -85,25 +90,12 @@ def __init__(self, formats, *args, **kwargs):
             )
         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
-
-
-def export_action_form_factory(formats):
-    """
-    Returns an ActionForm subclass containing a ChoiceField populated with
-    the given formats.
-    """
-
-    class _ExportActionForm(ActionForm):
-        """
-        Action form with export format ChoiceField.
-        """
-
-        file_format = forms.ChoiceField(
-            label=_("Format"), choices=formats, required=False
-        )
-
-    _ExportActionForm.__name__ = str("ExportActionForm")
-
-    return _ExportActionForm
diff --git a/import_export/mixins.py b/import_export/mixins.py
--- a/import_export/mixins.py
+++ b/import_export/mixins.py
@@ -138,6 +138,7 @@ def get_context_data(self, **kwargs):
     def get_form_kwargs(self):
         kwargs = super().get_form_kwargs()
         kwargs["formats"] = self.get_export_formats()
+        kwargs["resources"] = self.get_export_resource_classes()
         return kwargs
 
 


Test patch:
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
@@ -10,12 +10,7 @@
 from django.test.testcases import TestCase
 from django.test.utils import override_settings
 
-from import_export.admin import (
-    ExportActionModelAdmin,
-    ExportMixin,
-    ImportExportActionModelAdmin,
-)
-from import_export.formats import base_formats
+from import_export.admin import ExportMixin, ImportExportActionModelAdmin
 from import_export.tmp_storages import TempFolderStorage
 
 
@@ -25,14 +20,7 @@ def setUp(self):
         self.cat1 = Category.objects.create(name="Cat 1")
         self.cat2 = Category.objects.create(name="Cat 2")
 
-    @ignore_widget_deprecation_warning
-    def test_export(self):
-        data = {
-            "action": ["export_admin_action"],
-            "file_format": "0",
-            "_selected_action": [str(self.cat1.id)],
-        }
-        response = self.client.post("/admin/core/category/", data)
+    def _check_export_response(self, response):
         self.assertContains(response, self.cat1.name, status_code=200)
         self.assertNotContains(response, self.cat2.name, status_code=200)
         self.assertTrue(response.has_header("Content-Disposition"))
@@ -42,135 +30,63 @@ def test_export(self):
             'attachment; filename="Category-{}.csv"'.format(date_str),
         )
 
-    def test_export_no_format_selected(self):
+    @override_settings(IMPORT_EXPORT_SKIP_ADMIN_ACTION_EXPORT_UI=True)
+    @ignore_widget_deprecation_warning
+    def test_export_skips_export_ui_page(self):
         data = {
             "action": ["export_admin_action"],
             "_selected_action": [str(self.cat1.id)],
         }
         response = self.client.post("/admin/core/category/", data)
-        self.assertEqual(response.status_code, 302)
-
-    def test_get_export_data_raises_PermissionDenied_when_no_export_permission_assigned(
-        self,
-    ):
-        request = MagicMock(spec=HttpRequest)
-
-        class TestMixin(ExportMixin):
-            model = Book
-
-            def has_export_permission(self, request):
-                return False
-
-        m = TestMixin()
-        with self.assertRaises(PermissionDenied):
-            m.get_export_data("0", Book.objects.none(), request=request)
-
-    def test_export_admin_action_one_formats(self):
-        mock_model = mock.MagicMock()
-        mock_site = mock.MagicMock()
-
-        class TestCategoryAdmin(ExportActionModelAdmin):
-            def __init__(self):
-                super().__init__(mock_model, mock_site)
-
-            export_formats = [base_formats.CSV]
-
-        m = TestCategoryAdmin()
-        action_form = m.action_form
-
-        items = list(action_form.base_fields.items())
-        file_format = items[len(items) - 1][1]
-        choices = file_format.choices
-
-        self.assertNotEqual(choices[0][0], "---")
-        self.assertEqual(choices[0][1], "csv")
-
-    def test_export_admin_action_formats(self):
-        mock_model = mock.MagicMock()
-        mock_site = mock.MagicMock()
-
-        class TestCategoryAdmin(ExportActionModelAdmin):
-            def __init__(self):
-                super().__init__(mock_model, mock_site)
-
-        class TestFormatsCategoryAdmin(ExportActionModelAdmin):
-            def __init__(self):
-                super().__init__(mock_model, mock_site)
-
-            export_formats = [base_formats.CSV, base_formats.JSON]
-
-        m = TestCategoryAdmin()
-        action_form = m.action_form
-
-        items = list(action_form.base_fields.items())
-        file_format = items[len(items) - 1][1]
-        choices = file_format.choices
-
-        self.assertEqual(choices[0][1], "---")
-        self.assertEqual(len(choices), 9)
-
-        m = TestFormatsCategoryAdmin()
-        action_form = m.action_form
-
-        items = list(action_form.base_fields.items())
-        file_format = items[len(items) - 1][1]
-        choices = file_format.choices
-
-        self.assertEqual(choices[0][1], "---")
-        self.assertEqual(len(m.export_formats) + 1, len(choices))
-
-        self.assertIn("csv", [c[1] for c in choices])
-        self.assertIn("json", [c[1] for c in choices])
-
-    @override_settings(EXPORT_FORMATS=[base_formats.XLSX, base_formats.CSV])
-    def test_export_admin_action_uses_export_format_settings(self):
-        """
-        Test that export action only avails the formats provided by the
-        EXPORT_FORMATS setting
-        """
-        mock_model = mock.MagicMock()
-        mock_site = mock.MagicMock()
+        self._check_export_response(response)
 
-        class TestCategoryAdmin(ExportActionModelAdmin):
-            def __init__(self):
-                super().__init__(mock_model, mock_site)
-
-        m = TestCategoryAdmin()
-        action_form = m.action_form
-
-        items = list(action_form.base_fields.items())
-        file_format = items[len(items) - 1][1]
-        choices = file_format.choices
-
-        self.assertEqual(len(choices), 3)
-        self.assertEqual(choices[0][1], "---")
-        self.assertEqual(choices[1][1], "xlsx")
-        self.assertEqual(choices[2][1], "csv")
-
-    @override_settings(IMPORT_EXPORT_FORMATS=[base_formats.XLS, base_formats.CSV])
-    def test_export_admin_action_uses_import_export_format_settings(self):
-        """
-        Test that export action only avails the formats provided by the
-        IMPORT_EXPORT_FORMATS setting
-        """
-        mock_model = mock.MagicMock()
-        mock_site = mock.MagicMock()
+    @ignore_widget_deprecation_warning
+    def test_export_displays_ui_select_page(self):
+        data = {
+            "action": ["export_admin_action"],
+            "_selected_action": [str(self.cat1.id)],
+        }
+        response = self.client.post("/admin/core/category/", data)
 
-        class TestCategoryAdmin(ExportActionModelAdmin):
-            def __init__(self):
-                super().__init__(mock_model, mock_site)
+        self.assertEqual(response.status_code, 200)
+        self.assertIn("form", response.context)
+        export_form = response.context["form"]
+        data = export_form.initial
+        self.assertEqual([self.cat1.id], data["export_items"])
+        self.assertIn("Export 1 selected item.", str(response.content))
 
-        m = TestCategoryAdmin()
-        action_form = m.action_form
+    @ignore_widget_deprecation_warning
+    def test_export_displays_ui_select_page_multiple_items(self):
+        data = {
+            "action": ["export_admin_action"],
+            "_selected_action": [str(self.cat1.id), str(self.cat2.id)],
+        }
+        response = self.client.post("/admin/core/category/", data)
 
-        items = list(action_form.base_fields.items())
-        file_format = items[len(items) - 1][1]
-        choices = file_format.choices
+        self.assertEqual(response.status_code, 200)
+        self.assertIn("form", response.context)
+        export_form = response.context["form"]
+        data = export_form.initial
+        self.assertEqual(
+            sorted([self.cat1.id, self.cat2.id]), sorted(data["export_items"])
+        )
+        self.assertIn("Export 2 selected items.", str(response.content))
 
-        self.assertEqual(len(choices), 3)
-        self.assertEqual(choices[0][1], "---")
-        self.assertEqual(choices[1][1], "xls")
-        self.assertEqual(choices[2][1], "csv")
+    @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)]}
+        date_str = datetime.now().strftime("%Y-%m-%d")
+        response = self.client.post("/admin/core/category/export/", data)
+        self.assertEqual(response.status_code, 200)
+        self.assertTrue(response.has_header("Content-Disposition"))
+        self.assertEqual(response["Content-Type"], "text/csv")
+        self.assertEqual(
+            response["Content-Disposition"],
+            'attachment; filename="Category-{}.csv"'.format(date_str),
+        )
+        target_str = f"id,name\r\n{self.cat1.id},Cat 1\r\n"
+        self.assertEqual(target_str.encode(), response.content)
 
     def test_export_admin_action(self):
         with mock.patch(
@@ -188,6 +104,21 @@ def test_export_admin_action(self):
             assert 200 <= response.status_code <= 399
             mock_export_admin_action.assert_called()
 
+    def test_get_export_data_raises_PermissionDenied_when_no_export_permission_assigned(
+        self,
+    ):
+        request = MagicMock(spec=HttpRequest)
+
+        class TestMixin(ExportMixin):
+            model = Book
+
+            def has_export_permission(self, request):
+                return False
+
+        m = TestMixin()
+        with self.assertRaises(PermissionDenied):
+            m.get_export_data("0", Book.objects.none(), request=request)
+
 
 class TestImportExportActionModelAdmin(ImportExportActionModelAdmin):
     def __init__(self, mock_model, mock_site, error_instance):
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
@@ -16,12 +16,16 @@
 
 from import_export import formats
 from import_export.admin import ExportActionMixin, ExportMixin
+from import_export.formats.base_formats import XLSX
 
 
 class ExportAdminIntegrationTest(AdminTestMixin, TestCase):
     def test_export(self):
         response = self.client.get("/admin/core/book/export/")
         self.assertEqual(response.status_code, 200)
+        self.assertNotIn("Export 0 selected items.", str(response.content))
+        form = response.context["form"]
+        self.assertEqual(2, len(form.fields["resource"].choices))
 
         data = {
             "file_format": "0",
@@ -41,6 +45,16 @@ def test_export(self):
             response.content,
         )
 
+    def test_get_export_form_single_resource(self):
+        response = self.client.get("/admin/core/category/export/")
+        self.assertEqual(response.status_code, 200)
+        self.assertNotIn("Export 0 selected items.", str(response.content))
+        form = response.context["form"]
+        self.assertEqual(0, len(form.fields["resource"].choices))
+        self.assertTrue(form.fields["resource"].widget.attrs["readonly"])
+        self.assertIn("CategoryResource", str(response.content))
+        self.assertNotIn('select name="resource"', str(response.content))
+
     def test_export_second_resource(self):
         response = self.client.get("/admin/core/book/export/")
         self.assertEqual(response.status_code, 200)
@@ -61,6 +75,20 @@ def test_export_second_resource(self):
         )
         self.assertEqual(b"id,name\r\n", response.content)
 
+    @override_settings(EXPORT_FORMATS=[XLSX])
+    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.assertIn("xlsx", str(response.content))
+        self.assertNotIn('select name="file_format"', str(response.content))
+
+    @override_settings(EXPORT_FORMATS=[])
+    def test_export_empty_export_formats(self):
+        with self.assertRaisesRegex(ValueError, "invalid export formats list"):
+            self.client.get("/admin/core/category/export/")
+
     def test_returns_xlsx_export(self):
         response = self.client.get("/admin/core/book/export/")
         self.assertEqual(response.status_code, 200)
@@ -210,9 +238,7 @@ def test_to_encoding_not_set_for_binary_file(self):
         binary_dataset = tablib.import_set(data)
         self.assertEqual("teststr", binary_dataset.dict[0]["name"])
 
-    @mock.patch("import_export.admin.ImportForm")
-    def test_export_action_to_encoding(self, mock_form):
-        mock_form.is_valid.return_value = True
+    def test_export_action_to_encoding(self):
         self.export_mixin.to_encoding = "utf-8"
         with mock.patch(
             "import_export.admin.ExportMixin.get_export_data"
@@ -221,17 +247,14 @@ def test_export_action_to_encoding(self, mock_form):
             encoding_kwarg = mock_get_export_data.call_args_list[0][1]["encoding"]
             self.assertEqual("utf-8", encoding_kwarg)
 
-    @mock.patch("import_export.admin.ImportForm")
-    def test_export_admin_action_to_encoding(self, mock_form):
+    @override_settings(IMPORT_EXPORT_SKIP_ADMIN_ACTION_EXPORT_UI=True)
+    def test_export_admin_action_to_encoding(self):
         class TestExportActionMixin(ExportActionMixin):
             def get_export_filename(self, request, queryset, file_format):
                 return "f"
 
-        self.mock_request.POST = {"file_format": "1"}
-
         self.export_mixin = TestExportActionMixin()
         self.export_mixin.to_encoding = "utf-8"
-        mock_form.is_valid.return_value = True
         with mock.patch(
             "import_export.admin.ExportMixin.get_export_data"
         ) as mock_get_export_data:
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
@@ -14,7 +14,7 @@
 from django.test.utils import override_settings
 from django.utils.translation import gettext_lazy as _
 
-from import_export.admin import ExportActionModelAdmin, ExportMixin
+from import_export.admin import ExportMixin
 from import_export.formats import base_formats
 
 
@@ -434,22 +434,6 @@ def test_get_context_data_returns_empty_dict(self):
         m = ExportMixin()
         self.assertEqual(dict(), m.get_context_data())
 
-    def test_media_attribute(self):
-        """
-        Test that the 'media' attribute of the ModelAdmin class is overridden to include
-        the project-specific js file.
-        """
-        mock_model = mock.MagicMock()
-        mock_site = mock.MagicMock()
-
-        class TestExportActionModelAdmin(ExportActionModelAdmin):
-            def __init__(self):
-                super().__init__(mock_model, mock_site)
-
-        m = TestExportActionModelAdmin()
-        target_media = m.media
-        self.assertEqual("import_export/action_formats.js", target_media._js[-1])
-
     @patch("import_export.admin.logger")
     def test_issue_1521_change_list_template_as_property(self, mock_logger):
         class TestImportCls(ImportMixin):
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
@@ -11,13 +11,15 @@ class Meta:
 class FormTest(TestCase):
     def test_formbase_init_blank_resources(self):
         resource_list = []
-        form = forms.ImportExportFormBase(resources=resource_list)
-        self.assertTrue("resource" not in form.fields)
+        with self.assertRaises(ValueError):
+            forms.ImportExportFormBase(resources=resource_list)
 
-    def test_formbase_init_one_resources(self):
+    def test_formbase_init_one_resource(self):
         resource_list = [resources.ModelResource]
         form = forms.ImportExportFormBase(resources=resource_list)
-        self.assertTrue("resource" not in form.fields)
+        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]
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
@@ -8,6 +8,7 @@
 from django.urls import reverse
 
 from import_export import admin, formats, forms, mixins, resources
+from import_export.resources import modelresource_factory
 
 from .utils import ignore_widget_deprecation_warning
 
@@ -21,7 +22,8 @@ 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.base_formats.DEFAULT_FORMATS,
+            resources=[modelresource_factory(Category)],
         )
         self.form.cleaned_data["file_format"] = "0"
 
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
@@ -1,5 +1,6 @@
 import os.path
 
+from core.models import Category
 from django.contrib.auth.models import Permission, User
 from django.test.testcases import TestCase
 from django.test.utils import override_settings
@@ -17,8 +18,8 @@ def setUp(self):
         self.user = user
         self.client.login(username="admin", password="password")
 
-    def set_user_book_model_permission(self, action):
-        permission = Permission.objects.get(codename="%s_book" % action)
+    def set_user_model_permission(self, action, model_name):
+        permission = Permission.objects.get(codename="%s_%s" % (action, model_name))
         self.user.user_permissions.add(permission)
 
     @override_settings(IMPORT_EXPORT_IMPORT_PERMISSION_CODE="change")
@@ -47,7 +48,7 @@ def test_import(self):
             self.assertEqual(response.status_code, 403)
 
         # user has sufficient permission to import
-        self.set_user_book_model_permission("change")
+        self.set_user_model_permission("change", "book")
 
         response = self.client.get("/admin/core/book/import/")
         self.assertEqual(response.status_code, 200)
@@ -73,7 +74,7 @@ def test_import(self):
             self.assertEqual(response.status_code, 302)
 
     @override_settings(IMPORT_EXPORT_EXPORT_PERMISSION_CODE="change")
-    def test_import_with_permission_set(self):
+    def test_export_with_permission_set(self):
         response = self.client.get("/admin/core/book/export/")
         self.assertEqual(response.status_code, 403)
 
@@ -81,7 +82,7 @@ def test_import_with_permission_set(self):
         response = self.client.post("/admin/core/book/export/", data)
         self.assertEqual(response.status_code, 403)
 
-        self.set_user_book_model_permission("change")
+        self.set_user_model_permission("change", "book")
         response = self.client.get("/admin/core/book/export/")
         self.assertEqual(response.status_code, 200)
 
@@ -89,9 +90,23 @@ def test_import_with_permission_set(self):
         response = self.client.post("/admin/core/book/export/", data)
         self.assertEqual(response.status_code, 200)
 
+    @override_settings(IMPORT_EXPORT_EXPORT_PERMISSION_CODE="change")
+    def test_export_action_with_permission_set(self):
+        self.cat1 = Category.objects.create(name="Cat 1")
+        data = {
+            "action": ["export_admin_action"],
+            "_selected_action": [str(self.cat1.id)],
+        }
+        response = self.client.post("/admin/core/category/", data)
+        self.assertEqual(response.status_code, 403)
+
+        self.set_user_model_permission("change", "category")
+        response = self.client.post("/admin/core/category/", data)
+        self.assertEqual(response.status_code, 200)
+
     @override_settings(IMPORT_EXPORT_EXPORT_PERMISSION_CODE="add")
     def test_check_export_button(self):
-        self.set_user_book_model_permission("change")
+        self.set_user_model_permission("change", "book")
 
         response = self.client.get("/admin/core/book/")
         widget = "import_link"
@@ -101,7 +116,7 @@ def test_check_export_button(self):
 
     @override_settings(IMPORT_EXPORT_IMPORT_PERMISSION_CODE="add")
     def test_check_import_button(self):
-        self.set_user_book_model_permission("change")
+        self.set_user_model_permission("change", "book")
 
         response = self.client.get("/admin/core/book/")
         widget = "import_link"