From ec0aa9d09192a45159bfc93b5deea9d1f79b76b0 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 |  6 ++++--
 tests/test_widgets.py          | 30 +++++++++++++++++++++++++++
 wcs/qommon/form.py             | 37 +++++++++++++++++++++++++++++++---
 3 files changed, 68 insertions(+), 5 deletions(-)

diff --git a/tests/admin_pages/test_form.py b/tests/admin_pages/test_form.py
index 21c82582..c45bde40 100644
--- a/tests/admin_pages/test_form.py
+++ b/tests/admin_pages/test_form.py
@@ -1492,8 +1492,9 @@ 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'
@@ -1503,8 +1504,9 @@ 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 0059f046..a7dd8464 100644
--- a/tests/test_widgets.py
+++ b/tests/test_widgets.py
@@ -720,6 +720,17 @@ 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():
@@ -736,6 +747,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)
@@ -752,6 +764,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 (5 digits required)'
 
 
 def test_wcsextrastringwidget_phone():
@@ -772,6 +785,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)
@@ -800,6 +814,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
@@ -829,6 +844,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
@@ -868,6 +884,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
@@ -907,6 +924,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
@@ -979,6 +997,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():
@@ -1000,6 +1019,17 @@ 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 a3fc3f6c..6e55b9f3 100644
--- a/wcs/qommon/form.py
+++ b/wcs/qommon/form.py
@@ -1022,40 +1022,48 @@ class ValidationWidget(CompositeWidget):
         ('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/]+',
+            '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}',
+            'error_message': N_('Invalid zip code (5 digits required)'),
             'html_inputmode': 'numeric'}
         ),
         ('siren-fr', {
             'title': N_('SIREN Code (France)'),
             'function': 'validate_siren',
+            'error_message': N_('Invalid SIREN code'),
             'html_inputmode': 'numeric'}
         ),
         ('siret-fr', {
             'title': N_('SIRET Code (France)'),
             'function': 'validate_siret',
+            'error_message': N_('Invalid SIRET code'),
             'html_inputmode': 'numeric'}
         ),
         ('nir-fr', {
             'title': N_('NIR (France)'),
+            'error_message': N_('Invalid NIR'),
             'function': 'validate_nir'}
         ),
         ('iban', {
             'title': N_('IBAN'),
-            'function': 'validate_iban'}
+            'function': 'validate_iban',
+            'error_message': N_('Invalid IBAN')}
         ),
         ('regex', {'title': N_('Regular Expression')}),
         ('django', {'title': N_('Django Condition')}),
@@ -1075,14 +1083,21 @@ class ValidationWidget(CompositeWidget):
             self.value = {}
 
         validation_labels = collections.OrderedDict(options)
-        self.add(RegexStringWidget, 'value_regex', size=80,
+        self.add(RegexStringWidget, 'value_regex', size=40,
                  value=value.get('value') if value.get('type') == 'regex' else None,
                  attrs={'data-dynamic-display-child-of': 'validation$type',
                         'data-dynamic-display-value': validation_labels.get('regex')})
-        self.add(DjangoConditionWidget, 'value_django', size=80,
+        self.add(DjangoConditionWidget, 'value_django', size=40,
                  value=value.get('value') if value.get('type') == 'django' else None,
                  attrs={'data-dynamic-display-child-of': 'validation$type',
                         'data-dynamic-display-value': validation_labels.get('django')})
+        self.add(StringWidget, 'error_message', size=60,
+                 value=value.get('error_message') if value.get('type') in ['regex', 'django'] else None,
+                 placeholder=_('Custom error message.'),
+                 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):
@@ -1093,6 +1108,10 @@ 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):
@@ -1119,6 +1138,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'])
@@ -1163,6 +1193,7 @@ 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

