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

Issue:
Admin site: Invalid date/times during import generate confusing errors
**Describe the bug**

If a datetime is invalid during import, this information is reported via the admin site confirmation page.  However it is not clear what the exact nature of the error is.

The error is reported, but it looks like the import was ok, because the date field is rendered:

![date_err](https://user-images.githubusercontent.com/6249838/163270160-80521a8f-3a84-435b-a2b3-0ecd31086711.png)

**To Reproduce**

Steps to reproduce the behavior:

1. Edit the file `tests/core/exports/books1.csv`
2. Add a column called `added` with value: `2022/02/17 19:46:59` (this is a date which cannot be parsed by default)
3. Import the file via the Admin console
4. See error

**Versions (please complete the following information):**
 - Django Import Export: 3.0.0 (beta)
 - Python 3.9
 - Django 4.0

**Expected behavior**

It would be best to see a clear indication of what the problem is.  Note the original [exception](https://github.com/django-import-export/django-import-export/blob/033f803c5994ceba9da8b610819ee5b52a630bf7/import_export/widgets.py#L229) is:

> time data '2022/02/17 19:46:59' does not match format '%Y-%m-%d %H:%M:%S'

This information is lost.




Discussion:

Resolving patch:
diff --git a/docs/conf.py b/docs/conf.py
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -115,3 +115,5 @@
 
 # intersphinx documentation
 intersphinx_mapping = {"tablib": ("https://tablib.readthedocs.io/en/stable/", None)}
+
+exclude_patterns = ["image_src"]
diff --git a/import_export/widgets.py b/import_export/widgets.py
--- a/import_export/widgets.py
+++ b/import_export/widgets.py
@@ -1,4 +1,5 @@
 import json
+import logging
 from datetime import date, datetime, time
 from decimal import Decimal
 from warnings import warn
@@ -10,6 +11,9 @@
 from django.utils.dateparse import parse_duration
 from django.utils.encoding import force_str, smart_str
 from django.utils.formats import number_format
+from django.utils.translation import gettext_lazy as _
+
+logger = logging.getLogger(__name__)
 
 
 def format_datetime(value, datetime_format):
@@ -225,6 +229,10 @@ def __init__(self, format=None, coerce_to_string=True):
         self.formats = formats
 
     def clean(self, value, row=None, **kwargs):
+        """
+        :returns: A python date instance.
+        :raises: ValueError if the value cannot be parsed using defined formats.
+        """
         if not value:
             return None
         if isinstance(value, date):
@@ -232,9 +240,9 @@ def clean(self, value, row=None, **kwargs):
         for format in self.formats:
             try:
                 return datetime.strptime(value, format).date()
-            except (ValueError, TypeError):
-                continue
-        raise ValueError("Enter a valid date.")
+            except (ValueError, TypeError) as e:
+                logger.debug(str(e))
+        raise ValueError(_("Value could not be parsed using defined date formats."))
 
     def render(self, value, obj=None):
         self._obj_deprecation_warning(obj)
@@ -265,6 +273,10 @@ def __init__(self, format=None, coerce_to_string=True):
         self.formats = formats
 
     def clean(self, value, row=None, **kwargs):
+        """
+        :returns: A python datetime instance.
+        :raises: ValueError if the value cannot be parsed using defined formats.
+        """
         dt = None
         if not value:
             return None
@@ -274,13 +286,13 @@ def clean(self, value, row=None, **kwargs):
             for format_ in self.formats:
                 try:
                     dt = datetime.strptime(value, format_)
-                except (ValueError, TypeError):
-                    continue
+                except (ValueError, TypeError) as e:
+                    logger.debug(str(e))
         if dt:
             if settings.USE_TZ and timezone.is_naive(dt):
                 dt = timezone.make_aware(dt)
             return dt
-        raise ValueError("Enter a valid date/time.")
+        raise ValueError(_("Value could not be parsed using defined datetime formats."))
 
     def render(self, value, obj=None):
         self._obj_deprecation_warning(obj)
@@ -313,6 +325,10 @@ def __init__(self, format=None, coerce_to_string=True):
         self.formats = formats
 
     def clean(self, value, row=None, **kwargs):
+        """
+        :returns: A python time instance.
+        :raises: ValueError if the value cannot be parsed using defined formats.
+        """
         if not value:
             return None
         if isinstance(value, time):
@@ -320,9 +336,9 @@ def clean(self, value, row=None, **kwargs):
         for format in self.formats:
             try:
                 return datetime.strptime(value, format).time()
-            except (ValueError, TypeError):
-                continue
-        raise ValueError("Enter a valid time.")
+            except (ValueError, TypeError) as e:
+                logger.debug(str(e))
+        raise ValueError(_("Value could not be parsed using defined time formats."))
 
     def render(self, value, obj=None):
         self._obj_deprecation_warning(obj)
@@ -339,13 +355,18 @@ class DurationWidget(Widget):
     """
 
     def clean(self, value, row=None, **kwargs):
+        """
+        :returns: A python duration instance.
+        :raises: ValueError if the value cannot be parsed.
+        """
         if not value:
             return None
 
         try:
             return parse_duration(value)
-        except (ValueError, TypeError):
-            raise ValueError("Enter a valid duration.")
+        except (ValueError, TypeError) as e:
+            logger.debug(str(e))
+            raise ValueError(_("Value could not be parsed."))
 
     def render(self, value, obj=None):
         self._obj_deprecation_warning(obj)


Test patch:
diff --git a/tests/core/tests/test_admin_integration.py b/tests/core/tests/test_admin_integration.py
--- a/tests/core/tests/test_admin_integration.py
+++ b/tests/core/tests/test_admin_integration.py
@@ -1220,7 +1220,8 @@ def test_import_action_invalid_date(self):
         # there should be a single invalid row
         self.assertEqual(1, len(result.invalid_rows))
         self.assertEqual(
-            "Enter a valid date.", result.invalid_rows[0].error.messages[0]
+            "Value could not be parsed using defined date formats.",
+            result.invalid_rows[0].error.messages[0],
         )
         # no rows should be imported because we rollback on validation errors
         self.assertEqual(0, Book.objects.count())
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
@@ -811,6 +811,10 @@ def test_import_data_error_saving_model(self):
         row[0] = "foo"
         self.dataset.append(row)
         result = self.resource.import_data(self.dataset, raise_errors=False)
+        if result.has_errors():
+            for row in result.rows:
+                for error in row.errors:
+                    print(str(error.error))
 
         self.assertTrue(result.has_errors())
         self.assertTrue(result.rows[0].errors)
diff --git a/tests/core/tests/test_widgets.py b/tests/core/tests/test_widgets.py
--- a/tests/core/tests/test_widgets.py
+++ b/tests/core/tests/test_widgets.py
@@ -2,6 +2,7 @@
 from datetime import date, datetime, time, timedelta
 from decimal import Decimal
 from unittest import mock, skipUnless
+from unittest.mock import patch
 
 import django
 import pytz
@@ -144,10 +145,16 @@ def test_clean_returns_None_for_empty_value(self):
     def test_clean_returns_date_when_date_passed(self):
         self.assertEqual(self.date, self.widget.clean(self.date))
 
-    def test_clean_raises_ValueError(self):
+    @patch("import_export.widgets.logger")
+    def test_clean_raises_ValueError(self, mock_logger):
         self.widget = widgets.DateWidget("x")
-        with self.assertRaisesRegex(ValueError, "Enter a valid date."):
+        with self.assertRaisesRegex(
+            ValueError, "Value could not be parsed using defined date formats."
+        ):
             self.widget.clean("2021-05-01")
+        mock_logger.debug.assert_called_with(
+            "time data '2021-05-01' does not match format 'x'"
+        )
 
     @override_settings(USE_TZ=True)
     def test_use_tz(self):
@@ -178,6 +185,17 @@ def test_render_coerce_to_string_is_False(self):
     def test_clean(self):
         self.assertEqual(self.widget.clean("13.08.2012 18:00:00"), self.datetime)
 
+    @patch("import_export.widgets.logger")
+    def test_clean_raises_ValueError(self, mock_logger):
+        self.widget = widgets.DateTimeWidget("x")
+        with self.assertRaisesRegex(
+            ValueError, "Value could not be parsed using defined datetime formats."
+        ):
+            self.widget.clean("2021-05-01")
+        mock_logger.debug.assert_called_with(
+            "time data '2021-05-01' does not match format 'x'"
+        )
+
     @override_settings(USE_TZ=True, TIME_ZONE="Europe/Ljubljana")
     def test_use_tz(self):
         utc_dt = timezone.make_aware(self.datetime, pytz.UTC)
@@ -264,10 +282,16 @@ def test_default_format(self):
         self.widget = widgets.TimeWidget()
         self.assertEqual(("%H:%M:%S",), self.widget.formats)
 
-    def test_clean_raises_ValueError(self):
+    @patch("import_export.widgets.logger")
+    def test_clean_raises_ValueError(self, mock_logger):
         self.widget = widgets.TimeWidget("x")
-        with self.assertRaisesRegex(ValueError, "Enter a valid time."):
+        with self.assertRaisesRegex(
+            ValueError, "Value could not be parsed using defined time formats."
+        ):
             self.widget.clean("20:15:00")
+        mock_logger.debug.assert_called_with(
+            "time data '20:15:00' does not match format 'x'"
+        )
 
     def test_clean_returns_time_when_time_passed(self):
         self.assertEqual(self.time, self.widget.clean(self.time))
@@ -300,10 +324,12 @@ def test_clean_none(self):
     def test_clean_zero(self):
         self.assertEqual(self.widget.clean("0:00:00"), timedelta(0))
 
-    @mock.patch("import_export.widgets.parse_duration", side_effect=ValueError("err"))
-    def test_clean_raises_ValueError(self, _):
-        with self.assertRaisesRegex(ValueError, "Enter a valid duration."):
+    @patch("import_export.widgets.parse_duration", side_effect=ValueError("err"))
+    @patch("import_export.widgets.logger")
+    def test_clean_raises_ValueError(self, mock_logger, _):
+        with self.assertRaisesRegex(ValueError, "Value could not be parsed."):
             self.widget.clean("x")
+        mock_logger.debug.assert_called_with("err")
 
 
 class NumberWidgetTest(TestCase, RowDeprecationTestMixin):