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

Issue:
Possible code duplication on import
**Describe the bug**

The import logic may be duplicated when identifying `import_id_fields`

1. [`Resource.get_instance()`](https://github.com/django-import-export/django-import-export/blob/77d659d5c34cb2ab272e5feff79c7883996dffe7/import_export/resources.py#L388)

2. [`ModelInstanceLoader.get_instance()`](https://github.com/django-import-export/django-import-export/blob/77d659d5c34cb2ab272e5feff79c7883996dffe7/import_export/instance_loaders.py#L27)

**Versions (please complete the following information):**
 - Django Import Export: 3.3.0






Discussion:

Resolving patch:
diff --git a/import_export/resources.py b/import_export/resources.py
--- a/import_export/resources.py
+++ b/import_export/resources.py
@@ -3,6 +3,7 @@
 import traceback
 from collections import OrderedDict
 from copy import deepcopy
+from html import escape
 
 import tablib
 from diff_match_patch import diff_match_patch
@@ -21,8 +22,10 @@
 from django.db.transaction import TransactionManagementError, set_rollback
 from django.utils.encoding import force_str
 from django.utils.safestring import mark_safe
+from django.utils.translation import gettext_lazy as _
 
 from . import widgets
+from .exceptions import FieldError
 from .fields import Field
 from .instance_loaders import ModelInstanceLoader
 from .results import Error, Result, RowResult
@@ -383,14 +386,8 @@ def init_instance(self, row=None):
 
     def get_instance(self, instance_loader, row):
         """
-        If all 'import_id_fields' are present in the dataset, calls
-        the :doc:`InstanceLoader <api_instance_loaders>`. Otherwise,
-        returns `None`.
+        Calls the :doc:`InstanceLoader <api_instance_loaders>`.
         """
-        import_id_fields = [self.fields[f] for f in self.get_import_id_fields()]
-        for field in import_id_fields:
-            if field.column_name not in row:
-                return
         return instance_loader.get_instance(row)
 
     def get_or_init_instance(self, instance_loader, row):
@@ -918,6 +915,7 @@ def import_data_inner(
         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()
@@ -1117,6 +1115,23 @@ def _get_ordered_field_names(self, order_field):
         [order.append(f) for f in defined_fields if f not in order]
         return tuple(order) + tuple(k for k in self.fields if k not in order)
 
+    def _check_import_id_fields(self, headers):
+        import_id_fields = [self.fields[f] for f in self.get_import_id_fields()]
+        missing_fields = list()
+        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)
+
+        if missing_fields:
+            raise FieldError(
+                _(
+                    "The following import_id_fields are not present in the dataset: %s"
+                    % ", ".join(missing_fields)
+                )
+            )
+
 
 class ModelDeclarativeMetaclass(DeclarativeMetaclass):
     def __new__(cls, name, bases, attrs):


Test patch:
diff --git a/tests/core/tests/test_resources.py b/tests/core/tests/test_resources.py
--- a/tests/core/tests/test_resources.py
+++ b/tests/core/tests/test_resources.py
@@ -23,7 +23,7 @@
 from django.utils.encoding import force_str
 from django.utils.html import strip_tags
 
-from import_export import fields, resources, results, widgets
+from import_export import exceptions, fields, resources, results, widgets
 from import_export.instance_loaders import ModelInstanceLoader
 from import_export.resources import Diff
 
@@ -381,25 +381,6 @@ class Meta:
         instance = resource.get_instance(instance_loader, self.dataset.dict[0])
         self.assertEqual(instance, self.book)
 
-    def test_get_instance_import_id_fields_with_custom_column_name(self):
-        class BookResource(resources.ModelResource):
-            name = fields.Field(
-                attribute="name", column_name="book_name", widget=widgets.CharWidget()
-            )
-
-            class Meta:
-                model = Book
-                import_id_fields = ["name"]
-
-        dataset = tablib.Dataset(headers=["id", "book_name", "author_email", "price"])
-        row = [self.book.pk, "Some book", "test@example.com", "10.25"]
-        dataset.append(row)
-
-        resource = BookResource()
-        instance_loader = resource._meta.instance_loader_class(resource)
-        instance = resource.get_instance(instance_loader, dataset.dict[0])
-        self.assertEqual(instance, self.book)
-
     def test_get_instance_usually_defers_to_instance_loader(self):
         self.resource._meta.import_id_fields = ["id"]
 
@@ -411,22 +392,6 @@ def test_get_instance_usually_defers_to_instance_loader(self):
             # instance_loader.get_instance() should have been called
             mocked_method.assert_called_once_with(row)
 
-    def test_get_instance_when_id_fields_not_in_dataset(self):
-        self.resource._meta.import_id_fields = ["id"]
-
-        # construct a dataset with a missing "id" column
-        dataset = tablib.Dataset(headers=["name", "author_email", "price"])
-        dataset.append(["Some book", "test@example.com", "10.25"])
-
-        instance_loader = self.resource._meta.instance_loader_class(self.resource)
-
-        with mock.patch.object(instance_loader, "get_instance") as mocked_method:
-            result = self.resource.get_instance(instance_loader, dataset.dict[0])
-            # Resource.get_instance() should return None
-            self.assertIs(result, None)
-            # instance_loader.get_instance() should NOT have been called
-            mocked_method.assert_not_called()
-
     def test_get_export_headers(self):
         headers = self.resource.get_export_headers()
         self.assertEqual(
@@ -657,8 +622,11 @@ 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()
-        result = resource.import_data(tablib.Dataset(), collect_failed_rows=True)
-        self.assertEqual(["Error"], result.failed_dataset.headers)
+        with self.assertRaisesRegex(
+            exceptions.FieldError,
+            "The following import_id_fields are not present in the dataset: id",
+        ):
+            resource.import_data(tablib.Dataset(), collect_failed_rows=True)
 
     def test_collect_failed_rows(self):
         resource = ProfileResource()
@@ -1841,8 +1809,8 @@ def test_many_to_many_widget_create(self):
     def test_many_to_many_widget_create_with_m2m_being_compared(self):
         # issue 1558 - when the object is a new instance and m2m is
         # evaluated for differences
-        dataset_headers = ["categories"]
-        dataset_row = ["1"]
+        dataset_headers = ["id", "categories"]
+        dataset_row = ["1", "1"]
         dataset = tablib.Dataset(headers=dataset_headers)
         dataset.append(dataset_row)
         book_resource = BookResource()
@@ -2086,8 +2054,8 @@ class Meta:
                 use_bulk = True
 
         self.resource = _BookResource()
-        rows = [("book_name",)] * 10
-        self.dataset = tablib.Dataset(*rows, headers=["name"])
+        rows = [(i + 1, "book_name") for i in range(10)]
+        self.dataset = tablib.Dataset(*rows, headers=["id", "name"])
 
     def init_update_test_data(self, model=Book):
         [model.objects.create(name="book_name") for _ in range(10)]
@@ -2872,3 +2840,46 @@ def test_fields_as_list_export_order(self):
         data = self.resource.export()
         target = f"id,price,name\r\n{self.pk},1.99,Ulysses\r\n"
         self.assertEqual(target, data.csv)
+
+
+class ImportIdFieldsTestCase(TestCase):
+    class BookResource(resources.ModelResource):
+        name = fields.Field(attribute="name", column_name="book_name")
+
+        class Meta:
+            model = Book
+            import_id_fields = ["name"]
+
+    def setUp(self):
+        super().setUp()
+        self.book = Book.objects.create(name="The Hobbit")
+        self.resource = ImportIdFieldsTestCase.BookResource()
+
+    def test_custom_column_name_warns_if_not_present(self):
+        dataset = tablib.Dataset(
+            *[(self.book.pk, "Some book")], headers=["id", "wrong_name"]
+        )
+        with self.assertRaisesRegex(
+            exceptions.FieldError,
+            "The following import_id_fields are not present "
+            "in the dataset: book_name",
+        ):
+            self.resource.import_data(dataset)
+
+    def test_multiple_import_id_fields(self):
+        class BookResource(resources.ModelResource):
+            class Meta:
+                model = Book
+                import_id_fields = ("id", "name", "author_email")
+
+        self.resource = BookResource()
+        dataset = tablib.Dataset(
+            *[(self.book.pk, "Goldeneye", "ian.fleming@example.com")],
+            headers=["A", "name", "B"],
+        )
+        with self.assertRaisesRegex(
+            exceptions.FieldError,
+            "The following import_id_fields are not present "
+            "in the dataset: id, author_email",
+        ):
+            self.resource.import_data(dataset)
diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml
--- a/tests/docker-compose.yml
+++ b/tests/docker-compose.yml
@@ -9,7 +9,7 @@ services:
       IMPORT_EXPORT_POSTGRESQL_USER: ${IMPORT_EXPORT_POSTGRESQL_USER}
       IMPORT_EXPORT_POSTGRESQL_PASSWORD: ${IMPORT_EXPORT_POSTGRESQL_PASSWORD}
       POSTGRES_PASSWORD: ${IMPORT_EXPORT_POSTGRESQL_PASSWORD}
-    image: postgres:12
+    image: postgres:13
     restart: "no"
     ports:
       - "5432:5432"