From c7a38f24c37e8546f4956708f891666696d28dc5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Thu, 4 Feb 2021 09:48:00 +0100
Subject: [PATCH] fields: custom error message for django/regex validation
 (#50772)

---
 tests/admin_pages/test_form.py | 14 ++++++-
 tests/test_widgets.py          | 32 +++++++++++++++
 wcs/qommon/form.py             | 75 ++++++++++++++++++++++++++++++----
 3 files changed, 112 insertions(+), 9 deletions(-)

diff --git a/tests/admin_pages/test_form.py b/tests/admin_pages/test_form.py
index 4f699da3..f8ff98a7 100644
--- a/tests/admin_pages/test_form.py
+++ b/tests/admin_pages/test_form.py
@@ -1501,8 +1501,13 @@ def test_form_edit_string_field_validation(pub):
     resp = resp.click('Edit', href='1/')
     resp.form['validation$type'] = 'Regular Expression'
     resp.form['validation$value_regex'] = r'\d+'
+    resp.form['validation$error_message'] = 'Foo Error'
     resp = resp.form.submit('submit').follow()
-    assert FormDef.get(formdef.id).fields[0].validation == {'type': 'regex', 'value': r'\d+'}
+    assert FormDef.get(formdef.id).fields[0].validation == {
+        'type': 'regex',
+        'value': r'\d+',
+        'error_message': 'Foo Error',
+    }
 
     resp = resp.click('Edit', href='1/')
     resp.form['validation$type'] = 'None'
@@ -1512,8 +1517,13 @@ def test_form_edit_string_field_validation(pub):
     resp = resp.click('Edit', href='1/')
     resp.form['validation$type'] = 'Django Condition'
     resp.form['validation$value_django'] = 'value|decimal < 20'
+    resp.form['validation$error_message'] = 'Bar Error'
     resp = resp.form.submit('submit').follow()
-    assert FormDef.get(formdef.id).fields[0].validation == {'type': 'django', 'value': 'value|decimal < 20'}
+    assert FormDef.get(formdef.id).fields[0].validation == {
+        'type': 'django',
+        'value': 'value|decimal < 20',
+        'error_message': 'Bar Error',
+    }
 
     resp = resp.click('Edit', href='1/')
     resp.form['validation$type'] = 'Django Condition'
diff --git a/tests/test_widgets.py b/tests/test_widgets.py
index 7f35ffd4..c260f858 100644
--- a/tests/test_widgets.py
+++ b/tests/test_widgets.py
@@ -743,6 +743,18 @@ def test_wcsextrastringwidget_regex_validation():
     widget.field = fakefield
     mock_form_submission(req, widget, {'test': '12,34'})
     assert widget.has_error()
+    assert widget.error == 'invalid value'
+
+    widget = WcsExtraStringWidget('test', value='foo', required=False)
+    fakefield.validation = {
+        'type': 'regex',
+        'value': r'\d+(\.\d{1,2})?',
+        'error_message': 'Foo Bar Custom Error',
+    }
+    widget.field = fakefield
+    mock_form_submission(req, widget, {'test': '12,34'})
+    assert widget.has_error()
+    assert widget.error == 'Foo Bar Custom Error'
 
 
 def test_wcsextrastringwidget_builtin_validation():
@@ -761,6 +773,7 @@ def test_wcsextrastringwidget_builtin_validation():
     widget.field = fakefield
     mock_form_submission(req, widget, {'test': 'az'})
     assert widget.has_error()
+    assert widget.error == 'Only digits are allowed'
 
     fakefield.validation = {'type': 'zipcode-fr'}
     widget = WcsExtraStringWidget('test', value='foo', required=False)
@@ -777,6 +790,7 @@ def test_wcsextrastringwidget_builtin_validation():
     widget.field = fakefield
     mock_form_submission(req, widget, {'test': '1234'})
     assert widget.has_error()
+    assert widget.error == 'Invalid zip code'
 
 
 def test_wcsextrastringwidget_phone():
@@ -798,6 +812,7 @@ def test_wcsextrastringwidget_phone():
         widget.field = fakefield
         mock_form_submission(req, widget, {'test': invalid_case})
         assert widget.has_error()
+        assert widget.error == 'Invalid phone number'
 
     # and check it gets a special HTML input type
     widget = WcsExtraStringWidget('test', value='foo', required=False)
@@ -827,6 +842,7 @@ def test_wcsextrastringwidget_phone_fr():
     widget.field = fakefield
     mock_form_submission(req, widget, {'test': 'az'})
     assert widget.has_error()
+    assert widget.error == 'Invalid phone number'
 
     widget = WcsExtraStringWidget('test', value='foo', required=False)
     widget.field = fakefield
@@ -858,6 +874,7 @@ def test_wcsextrastringwidget_siren_validation():
     widget.field = fakefield
     mock_form_submission(req, widget, {'test': '443170130'})
     assert widget.has_error()
+    assert widget.error == 'Invalid SIREN code'
 
     widget = WcsExtraStringWidget('test', value='foo', required=False)
     widget.field = fakefield
@@ -899,6 +916,7 @@ def test_wcsextrastringwidget_siret_validation():
     widget.field = fakefield
     mock_form_submission(req, widget, {'test': '44317013900037'})
     assert widget.has_error()
+    assert widget.error == 'Invalid SIRET code'
 
     widget = WcsExtraStringWidget('test', value='foo', required=False)
     widget.field = fakefield
@@ -940,6 +958,7 @@ def test_wcsextrastringwidget_nir_validation():
     widget.field = fakefield
     mock_form_submission(req, widget, {'test': '42'})
     assert widget.has_error()
+    assert widget.error == 'Invalid NIR'
 
     widget = WcsExtraStringWidget('test', value='foo', required=False)
     widget.field = fakefield
@@ -1016,6 +1035,7 @@ def test_wcsextrastringwidget_iban_validation():
         widget.field = fakefield
         mock_form_submission(req, widget, {'test': iban.replace(' ', '')})
         assert widget.has_error()
+        assert widget.error == 'Invalid IBAN'
 
 
 def test_wcsextrastringwidget_django_validation():
@@ -1039,6 +1059,18 @@ def test_wcsextrastringwidget_django_validation():
     widget.field = fakefield
     mock_form_submission(req, widget, {'test': 'az'})
     assert widget.has_error()
+    assert widget.error == 'invalid value'
+
+    widget = WcsExtraStringWidget('test', value='foo', required=False)
+    fakefield.validation = {
+        'type': 'django',
+        'value': 'value|decimal and value|decimal < 20',
+        'error_message': 'Foo Bar Custom Error',
+    }
+    widget.field = fakefield
+    mock_form_submission(req, widget, {'test': 'az'})
+    assert widget.has_error()
+    assert widget.error == 'Foo Bar Custom Error'
 
 
 def test_widgetdict_widget():
diff --git a/wcs/qommon/form.py b/wcs/qommon/form.py
index 66a2bebd..54412e3b 100644
--- a/wcs/qommon/form.py
+++ b/wcs/qommon/form.py
@@ -1051,28 +1051,48 @@ class ValidationCondition(Condition):
 class ValidationWidget(CompositeWidget):
     validation_methods = collections.OrderedDict(
         [
-            ('digits', {'title': N_('Digits'), 'regex': r'\d+', 'html_inputmode': 'numeric'}),
+            (
+                'digits',
+                {
+                    'title': N_('Digits'),
+                    'regex': r'\d+',
+                    'error_message': N_('Only digits are allowed'),
+                    'html_inputmode': 'numeric',
+                },
+            ),
             (
                 'phone',
-                {'title': N_('Phone Number'), 'regex': r'\+?[-\(\)\d\.\s/]+', 'html_input_type': 'tel'},
+                {
+                    'title': N_('Phone Number'),
+                    'regex': r'\+?[-\(\)\d\.\s/]+',
+                    'error_message': N_('Invalid phone number'),
+                    'html_input_type': 'tel',
+                },
             ),
             (
                 'phone-fr',
                 {
                     'title': N_('Phone Number (France)'),
                     'function': 'validate_phone_fr',
+                    'error_message': N_('Invalid phone number'),
                     'html_input_type': 'tel',
                 },
             ),
             (
                 'zipcode-fr',
-                {'title': N_('Zip Code (France)'), 'regex': r'\d{5}', 'html_inputmode': 'numeric'},
+                {
+                    'title': N_('Zip Code (France)'),
+                    'regex': r'\d{5}',
+                    'error_message': N_('Invalid zip code'),
+                    'html_inputmode': 'numeric',
+                },
             ),
             (
                 'siren-fr',
                 {
                     'title': N_('SIREN Code (France)'),
                     'function': 'validate_siren',
+                    'error_message': N_('Invalid SIREN code'),
                     'html_inputmode': 'numeric',
                 },
             ),
@@ -1081,11 +1101,15 @@ class ValidationWidget(CompositeWidget):
                 {
                     'title': N_('SIRET Code (France)'),
                     'function': 'validate_siret',
+                    'error_message': N_('Invalid SIRET code'),
                     'html_inputmode': 'numeric',
                 },
             ),
-            ('nir-fr', {'title': N_('NIR (France)'), 'function': 'validate_nir'}),
-            ('iban', {'title': N_('IBAN'), 'function': 'validate_iban'}),
+            (
+                'nir-fr',
+                {'title': N_('NIR (France)'), 'error_message': N_('Invalid NIR'), 'function': 'validate_nir'},
+            ),
+            ('iban', {'title': N_('IBAN'), 'function': 'validate_iban', 'error_message': N_('Invalid IBAN')}),
             ('regex', {'title': N_('Regular Expression')}),
             ('django', {'title': N_('Django Condition')}),
         ]
@@ -1130,6 +1154,20 @@ class ValidationWidget(CompositeWidget):
                 'data-dynamic-display-value': validation_labels.get('django'),
             },
         )
+        self.add(
+            StringWidget,
+            'error_message',
+            size=80,
+            value=value.get('error_message') if value.get('type') in ['regex', 'django'] else None,
+            title=_('Custom error message'),
+            hint=_('This message will be be displayed if validation fails.'),
+            attrs={
+                'data-dynamic-display-child-of': 'validation$type',
+                'data-dynamic-display-value-in': '|'.join(
+                    [validation_labels.get('regex'), validation_labels.get('django')]
+                ),
+            },
+        )
         self._parsed = False
 
     def _parse(self, request):
@@ -1140,14 +1178,23 @@ class ValidationWidget(CompositeWidget):
             value = self.get('value_%s' % type_)
             if value:
                 values['value'] = value
+            if type_ in ['regex', 'django']:
+                error_message = self.get('error_message')
+                if error_message:
+                    values['error_message'] = error_message
         self.value = values or None
 
     def render_content(self):
         r = TemplateIO(html=True)
-        for widget in self.get_widgets():
+        inlines = ['type', 'value_regex', 'value_django']
+        for name in inlines:
+            widget = self.get_widget(name)
             r += widget.render_error(widget.get_error())
-        for widget in self.get_widgets():
+        for name in inlines:
+            widget = self.get_widget(name)
             r += widget.render_content()
+        widget = self.get_widget('error_message')
+        r += widget.render()
         return r.getvalue()
 
     @classmethod
@@ -1170,6 +1217,17 @@ class ValidationWidget(CompositeWidget):
         if validation_method and 'function' in validation_method:
             return getattr(misc, validation_method['function'])
 
+    @classmethod
+    def get_validation_error_message(cls, validation):
+        pattern = cls.get_validation_pattern(validation)
+        if validation['type'] == 'regex' and pattern:
+            return validation.get('error_message')
+        if validation['type'] == 'django' and validation.get('value'):
+            return validation.get('error_message')
+        validation_method = cls.validation_methods.get(validation['type'])
+        if validation_method and 'error_message' in validation_method:
+            return validation_method['error_message']
+
     @classmethod
     def get_validation_pattern(cls, validation):
         validation_method = cls.validation_methods.get(validation['type'])
@@ -1214,6 +1272,9 @@ class WcsExtraStringWidget(StringWidget):
         StringWidget._parse(self, request)
         if self.field and self.field.validation and self.value is not None:
             self.validation_function = ValidationWidget.get_validation_function(self.field.validation)
+            self.validation_function_error_message = ValidationWidget.get_validation_error_message(
+                self.field.validation
+            )
 
         if self.validation_function and not self.validation_function(self.value):
             self.error = self.validation_function_error_message or _('invalid value')
-- 
2.29.2

