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

Issue:
The error message for invalid column names is misleading
**Describe the bug**

The error message for invalid column names is misleading.  It also breaks the interface because `FieldError` is raised and thrown from the process, ignoring the `raise_error` flag.


**To Reproduce**

- See test in PR


**Versions (please complete the following information):**
 - Django Import Export: 4.0.0 rc0
 - Python 3.11
 - Django 5.0.2

**Expected behavior**

A meaningful error message and can be controlled via `raise_errors`.





Discussion:

Resolving patch:
diff --git a/import_export/exceptions.py b/import_export/exceptions.py
--- a/import_export/exceptions.py
+++ b/import_export/exceptions.py
@@ -23,4 +23,10 @@ def __init__(self, error, number=None, row=None):
         self.row = row
 
     def __str__(self):
-        return f"{self.number}: {self.error} ({self.row})"
+        s = ""
+        if self.number is not None:
+            s += f"{self.number}: "
+        s += f"{self.error}"
+        if self.row is not None:
+            s += f" ({self.row})"
+        return s
diff --git a/import_export/resources.py b/import_export/resources.py
--- a/import_export/resources.py
+++ b/import_export/resources.py
@@ -866,10 +866,10 @@ def import_data_inner(
         try:
             with atomic_if_using_transaction(using_transactions, using=db_connection):
                 self.before_import(dataset, **kwargs)
+            self._check_import_id_fields(dataset.headers)
         except Exception as e:
             self.handle_import_error(result, e, raise_errors)
 
-        self._check_import_id_fields(dataset.headers)
         instance_loader = self._meta.instance_loader_class(self, dataset)
 
         # Update the total in case the dataset was altered by before_import()
@@ -1121,6 +1121,7 @@ def _is_dry_run(self, kwargs):
     def _check_import_id_fields(self, headers):
         import_id_fields = list()
         missing_fields = list()
+        missing_headers = list()
 
         for field_name in self.get_import_id_fields():
             if field_name not in self.fields:
@@ -1128,17 +1129,27 @@ def _check_import_id_fields(self, headers):
             else:
                 import_id_fields.append(self.fields[field_name])
 
+        if missing_fields:
+            raise exceptions.FieldError(
+                _(
+                    "The following fields are declared in 'import_id_fields' but "
+                    "are not present in the resource fields: %s"
+                    % ", ".join(missing_fields)
+                )
+            )
+
         for field in import_id_fields:
             if not headers or field.column_name not in headers:
                 # escape to be safe (exception could end up in logs)
                 col = escape(field.column_name)
-                missing_fields.append(col)
+                missing_headers.append(col)
 
-        if missing_fields:
+        if missing_headers:
             raise exceptions.FieldError(
                 _(
                     "The following fields are declared in 'import_id_fields' but "
-                    "are not present in the resource: %s" % ", ".join(missing_fields)
+                    "are not present in the file headers: %s"
+                    % ", ".join(missing_headers)
                 )
             )
 


Test patch:
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
@@ -211,7 +211,7 @@ def test_import_action_handles_FieldError(self):
             mock_check_import_id_fields.side_effect = FieldError("some unknown error")
             response = self._do_import_post(self.book_import_url, "books.csv")
         self.assertEqual(response.status_code, 200)
-        target_msg = "Some unknown error"
+        target_msg = "some unknown error"
         self.assertIn(target_msg, str(response.content))
 
     @override_settings(LANGUAGE_CODE="es")
diff --git a/tests/core/tests/test_resources/test_import_export.py b/tests/core/tests/test_resources/test_import_export.py
--- a/tests/core/tests/test_resources/test_import_export.py
+++ b/tests/core/tests/test_resources/test_import_export.py
@@ -281,14 +281,25 @@ def test_custom_column_name_warns_if_not_present(self):
         dataset = tablib.Dataset(
             *[(self.book.pk, "Some book")], headers=["id", "wrong_name"]
         )
-        with self.assertRaises(exceptions.FieldError) as e:
-            self.resource.import_data(dataset)
+        with self.assertRaises(exceptions.ImportError) as e:
+            self.resource.import_data(dataset, raise_errors=True)
         self.assertEqual(
             "The following fields are declared in 'import_id_fields' "
-            "but are not present in the resource: book_name",
+            "but are not present in the file headers: book_name",
             str(e.exception),
         )
 
+    def test_custom_column_name_warns_if_not_present_as_error_in_result(self):
+        dataset = tablib.Dataset(
+            *[(self.book.pk, "Some book")], headers=["id", "wrong_name"]
+        )
+        res = self.resource.import_data(dataset, raise_errors=False)
+        target = (
+            "The following fields are declared in 'import_id_fields' "
+            "but are not present in the file headers: book_name"
+        )
+        self.assertEqual(target, str(res.base_errors[0].error))
+
     def test_missing_import_id_field_raises_exception(self):
         class TestBookResource(resources.ModelResource):
             class Meta:
@@ -302,11 +313,11 @@ class Meta:
         dataset = tablib.Dataset(*[row], headers=["id", "name"])
         dataset.append(row)
 
-        with self.assertRaises(exceptions.FieldError) as e:
-            resource.import_data(dataset)
+        with self.assertRaises(exceptions.ImportError) as e:
+            resource.import_data(dataset, raise_errors=True)
         self.assertEqual(
             "The following fields are declared in 'import_id_fields' "
-            "but are not present in the resource: a, b",
+            "but are not present in the resource fields: a, b",
             str(e.exception),
         )
 
@@ -321,11 +332,11 @@ class Meta:
             *[(self.book.pk, "Goldeneye", "ian.fleming@example.com")],
             headers=["A", "name", "B"],
         )
-        with self.assertRaises(exceptions.FieldError) as e:
-            self.resource.import_data(dataset)
+        with self.assertRaises(exceptions.ImportError) as e:
+            self.resource.import_data(dataset, raise_errors=True)
         self.assertEqual(
             "The following fields are declared in 'import_id_fields' "
-            "but are not present in the resource: id, author_email",
+            "but are not present in the file headers: id, author_email",
             str(e.exception),
         )
 
diff --git a/tests/core/tests/test_resources/test_modelresource/test_error_handling.py b/tests/core/tests/test_resources/test_modelresource/test_error_handling.py
--- a/tests/core/tests/test_resources/test_modelresource/test_error_handling.py
+++ b/tests/core/tests/test_resources/test_modelresource/test_error_handling.py
@@ -80,11 +80,13 @@ def test_import_data_raises_field_specific_validation_errors_with_skip_unchanged
 
     def test_import_data_empty_dataset_with_collect_failed_rows(self):
         resource = AuthorResource()
-        with self.assertRaises(exceptions.FieldError) as e:
-            resource.import_data(tablib.Dataset(), collect_failed_rows=True)
+        with self.assertRaises(exceptions.ImportError) as e:
+            resource.import_data(
+                tablib.Dataset(), collect_failed_rows=True, raise_errors=True
+            )
         self.assertEqual(
             "The following fields are declared in 'import_id_fields' "
-            "but are not present in the resource: id",
+            "but are not present in the file headers: id",
             str(e.exception),
         )