From 2e175ed79002ed2349e9cb025a8acc96b8ac9332 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= <fpeters@entrouvert.com>
Date: Sat, 10 Aug 2019 16:15:25 +0200
Subject: [PATCH] fields: extend string field validation support (#11455)

---
 tests/test_admin_pages.py |  33 ++++++++++++
 tests/test_form_pages.py  |   4 +-
 tests/test_widgets.py     |  51 +++++++++++++++++-
 wcs/fields.py             |  14 ++++-
 wcs/qommon/form.py        | 107 +++++++++++++++++++++++++++++++++++---
 5 files changed, 197 insertions(+), 12 deletions(-)

diff --git a/tests/test_admin_pages.py b/tests/test_admin_pages.py
index cc2b34dfd..27bde3f44 100644
--- a/tests/test_admin_pages.py
+++ b/tests/test_admin_pages.py
@@ -1300,6 +1300,39 @@ def test_form_prefill_field(pub):
     resp = resp.form.submit('submit')
     assert 'syntax error in Django template: Unexpected end of expression' in resp.body
 
+def test_form_edit_string_field_validation(pub):
+    create_superuser(pub)
+    create_role()
+
+    FormDef.wipe()
+    formdef = FormDef()
+    formdef.name = 'form title'
+    formdef.fields = [fields.StringField(id='1', label='1st field', type='string')]
+    formdef.store()
+
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/forms/1/')
+    resp = resp.click(href='fields/')
+    assert '1st field' in resp.body
+
+    resp = resp.click('Edit', href='1/')
+    resp.form['validation$type'] = 'Regular Expression'
+    resp.form['validation$value_regex'] = r'\d+'
+    resp = resp.form.submit('submit').follow()
+    assert FormDef.get(formdef.id).fields[0].validation == {'type': 'regex', 'value': r'\d+'}
+
+    resp = resp.click('Edit', href='1/')
+    resp.form['validation$type'] = 'Django Condition'
+    resp.form['validation$value_django'] = 'value|decimal < 20'
+    resp = resp.form.submit('submit').follow()
+    assert FormDef.get(formdef.id).fields[0].validation == {'type': 'django', 'value': 'value|decimal < 20'}
+
+    resp = resp.click('Edit', href='1/')
+    resp.form['validation$type'] = 'Django Condition'
+    resp.form['validation$value_django'] = '{{ value|decimal < 20 }}'
+    resp = resp.form.submit('submit')
+    assert 'syntax error' in resp.body
+
 def test_form_edit_item_field(pub):
     create_superuser(pub)
     create_role()
diff --git a/tests/test_form_pages.py b/tests/test_form_pages.py
index 47977b4d7..5e90079ca 100644
--- a/tests/test_form_pages.py
+++ b/tests/test_form_pages.py
@@ -4076,7 +4076,7 @@ def test_form_jsonp_item_field(http_requests, pub):
 def test_form_string_regex_field_submit(pub):
     formdef = create_formdef()
     formdef.fields = [fields.StringField(id='0', label='string', type='string',
-        validation=r'\d{5}$', required=False)]
+        validation={'type': 'regex', 'value': r'\d{5}$'}, required=False)]
     formdef.store()
 
     formdef.data_class().wipe()
@@ -4112,7 +4112,7 @@ def test_form_string_regex_field_submit(pub):
     resp = get_app(pub).get('/test/')
     resp.forms[0]['f0'] = 'foobar'
     resp = resp.forms[0].submit('submit')
-    assert 'wrong format' in resp.body
+    assert 'invalid value' in resp.body
 
 def test_form_text_field_submit(pub):
     formdef = create_formdef()
diff --git a/tests/test_widgets.py b/tests/test_widgets.py
index 6c794d931..d1481182d 100644
--- a/tests/test_widgets.py
+++ b/tests/test_widgets.py
@@ -567,10 +567,11 @@ def test_wcsextrastringwidget():
     assert not widget.has_error()
     assert widget.parse() == 'bar'
 
+def test_wcsextrastringwidget_regex_validation():
     # check regex validation
     class FakeField: pass
     fakefield = FakeField()
-    fakefield.validation = r'\d+'
+    fakefield.validation = {'type': 'regex', 'value': r'\d+'}
 
     widget = WcsExtraStringWidget('test', value='foo', required=False)
     widget.field = fakefield
@@ -587,7 +588,7 @@ def test_wcsextrastringwidget():
     mock_form_submission(req, widget, {'test': 'cdab 12'})
     assert widget.has_error()
 
-    fakefield.validation = r'\d+(\.\d{1,2})?'
+    fakefield.validation = {'type': 'regex', 'value': r'\d+(\.\d{1,2})?'}
     widget = WcsExtraStringWidget('test', value='foo', required=False)
     widget.field = fakefield
     mock_form_submission(req, widget, {'test': '12'})
@@ -603,6 +604,52 @@ def test_wcsextrastringwidget():
     mock_form_submission(req, widget, {'test': '12,34'})
     assert widget.has_error()
 
+def test_wcsextrastringwidget_builtin_validation():
+    class FakeField: pass
+    fakefield = FakeField()
+
+    fakefield.validation = {'type': 'digits'}
+    widget = WcsExtraStringWidget('test', value='foo', required=False)
+    widget.field = fakefield
+    mock_form_submission(req, widget, {'test': '12'})
+    assert not widget.has_error()
+
+    widget = WcsExtraStringWidget('test', value='foo', required=False)
+    widget.field = fakefield
+    mock_form_submission(req, widget, {'test': 'az'})
+    assert widget.has_error()
+
+    fakefield.validation = {'type': 'zipcode-fr'}
+    widget = WcsExtraStringWidget('test', value='foo', required=False)
+    widget.field = fakefield
+    mock_form_submission(req, widget, {'test': '12345'})
+    assert not widget.has_error()
+
+    widget = WcsExtraStringWidget('test', value='foo', required=False)
+    widget.field = fakefield
+    mock_form_submission(req, widget, {'test': '1234'})
+    assert widget.has_error()
+
+def test_wcsextrastringwidget_django_validation():
+    class FakeField: pass
+    fakefield = FakeField()
+
+    fakefield.validation = {'type': 'django', 'value': 'value|decimal and value|decimal < 20'}
+    widget = WcsExtraStringWidget('test', value='foo', required=False)
+    widget.field = fakefield
+    mock_form_submission(req, widget, {'test': '12'})
+    assert not widget.has_error()
+
+    widget = WcsExtraStringWidget('test', value='foo', required=False)
+    widget.field = fakefield
+    mock_form_submission(req, widget, {'test': '35'})
+    assert widget.has_error()
+
+    widget = WcsExtraStringWidget('test', value='foo', required=False)
+    widget.field = fakefield
+    mock_form_submission(req, widget, {'test': 'az'})
+    assert widget.has_error()
+
 
 def test_widgetdict_widget():
     widget = WidgetDict('test', value={'a': None, 'b': None, 'c': None})
diff --git a/wcs/fields.py b/wcs/fields.py
index f96125790..5048975cd 100644
--- a/wcs/fields.py
+++ b/wcs/fields.py
@@ -691,8 +691,8 @@ class StringField(WidgetField):
                      value=self.size)
         else:
             form.add(HiddenWidget, 'size', value=None)
-        form.add(RegexStringWidget, 'validation', title = _('Validation regex'),
-                value=self.validation, advanced=(not self.validation))
+        form.add(ValidationWidget, 'validation', title=_('Validation'),
+                 value=self.validation, advanced=(not self.validation))
         form.add(data_sources.DataSourceSelectionWidget, 'data_source',
                  value=self.data_source,
                  title=_('Data Source'),
@@ -720,6 +720,16 @@ class StringField(WidgetField):
             return None
         return str(value)
 
+    def migrate(self):
+        if isinstance(self.validation, basestring):
+            self.validation = {'type': 'regex', 'value': self.validation}
+            return True
+        return False
+
+    def init_with_xml(self, element, charset, include_id=False):
+        super(StringField, self).init_with_xml(element, charset, include_id=include_id)
+        self.migrate()
+
 register_field_class(StringField)
 
 
diff --git a/wcs/qommon/form.py b/wcs/qommon/form.py
index e29b0238b..2a7702f96 100644
--- a/wcs/qommon/form.py
+++ b/wcs/qommon/form.py
@@ -892,6 +892,90 @@ class EmailWidget(StringWidget):
                     pass
 
 
+class ValidationCondition(Condition):
+    def __init__(self, django_condition, value):
+        super(ValidationCondition, self).__init__({'type': 'django', 'value': django_condition})
+        self.evaluated_value = value
+
+    def get_data(self):
+        return {'value': self.evaluated_value}
+
+
+class ValidationWidget(CompositeWidget):
+    validation_methods = collections.OrderedDict([
+        ('digits', {'title': N_('Digits'), 'regex': '\d+'}),
+        ('phone-fr', {'title': N_('Phone Number (France)'), 'regex': '0[\d\.\s]{9}'}),
+        ('zipcode-fr', {'title': N_('Zip Code (France)'), 'regex': '\d{5}'}),
+        ('regex', {'title': N_('Regular Expression')}),
+        ('django', {'title': N_('Django Condition')}),
+    ])
+
+    def __init__(self, name, value=None, **kwargs):
+        super(ValidationWidget, self).__init__(name, value=value, **kwargs)
+        if not value:
+            value = {}
+
+        options = [('none', _('None'))] + [(x, _(y['title'])) for x, y in self.validation_methods.items()]
+
+        self.add(SingleSelectWidget, 'type', options=options, value=value.get('type'),
+                 attrs={'data-dynamic-display-parent': 'true'})
+        self.parse()
+        if not self.value:
+            self.value = {}
+
+        validation_labels = collections.OrderedDict(options)
+        self.add(RegexStringWidget, 'value_regex', size=80,
+                 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,
+                 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._parsed = False
+
+    def _parse(self, request):
+        values = {}
+        type_ = self.get('type')
+        if type_:
+            values['type'] = type_
+            value = self.get('value_%s' % type_)
+            if value:
+                values['value'] = value
+        self.value = values or None
+
+    def render_content(self):
+        r = TemplateIO(html=True)
+        for widget in self.get_widgets():
+            r += widget.render_error(widget.get_error())
+        for widget in self.get_widgets():
+            r += widget.render_content()
+        return r.getvalue()
+
+    @classmethod
+    def get_validation_function(cls, validation):
+        pattern = cls.get_validation_pattern(validation)
+        if pattern:
+            def regex_validation(value):
+                match = re.match(pattern, value)
+                return bool(match and match.group() == value)
+            return regex_validation
+        if validation['type'] == 'django':
+            def django_validation(value):
+                condition = ValidationCondition(validation['value'], value=value)
+                return condition.evaluate()
+            return django_validation
+
+    @classmethod
+    def get_validation_pattern(cls, validation):
+        validation_method = cls.validation_methods.get(validation['type'])
+        if validation_method and validation_method.get('regex'):
+            return validation_method.get('regex')
+        if validation['type'] == 'regex':
+            return validation['value']
+        return None
+
+
 class WcsExtraStringWidget(StringWidget):
     field = None
     prefill = False
@@ -903,16 +987,17 @@ class WcsExtraStringWidget(StringWidget):
 
     def render_content(self):
         if self.field and self.field.validation and not 'pattern' in self.attrs:
-            self.attrs['pattern'] = self.field.validation
-        s = StringWidget.render_content(self)
-        return s
+            pattern = ValidationWidget.get_validation_pattern(self.field.validation)
+            if pattern:
+                self.attrs['pattern'] = pattern
+        return super(WcsExtraStringWidget, self).render_content()
 
     def _parse(self, request):
         StringWidget._parse(self, request)
         if self.field and self.field.validation and self.value is not None:
-            match = re.match(self.field.validation, self.value)
-            if not match or match.group() != self.value:
-                self.error = _('wrong format')
+            validation_function = ValidationWidget.get_validation_function(self.field.validation)
+            if not validation_function(self.value):
+                self.error = _('invalid value')
 
 
 class DateWidget(StringWidget):
@@ -2348,3 +2433,13 @@ class ConditionWidget(CompositeWidget):
                 'value_python': self.get_widget('value_python').render_content(),
                 'type': self.get_widget('type').render_content()
             }
+
+
+class DjangoConditionWidget(StringWidget):
+    def _parse(self, request):
+        super(DjangoConditionWidget, self)._parse(request)
+        if self.value:
+            try:
+                Condition({'type': 'django', 'value': self.value}).validate()
+            except ValidationError as e:
+                self.set_error(str(e))
-- 
2.23.0.rc1

