From 1af0a493244dc153daeded2a191c44ce10a4e1a5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= <fpeters@entrouvert.com>
Date: Wed, 22 Jan 2020 10:28:45 +0100
Subject: [PATCH] forms: add option for prefilling fields as readonly (#39167)

---
 tests/test_admin_pages.py            | 10 +--
 tests/test_form_pages.py             | 92 +++++++++++++++++++++-------
 tests/test_formdef_import.py         | 29 +++++++++
 wcs/fields.py                        | 40 +++++++++---
 wcs/formdef.py                       |  5 ++
 wcs/forms/common.py                  |  3 +
 wcs/forms/root.py                    | 40 ++++++------
 wcs/qommon/form.py                   |  6 +-
 wcs/qommon/static/js/qommon.forms.js |  9 ++-
 9 files changed, 180 insertions(+), 54 deletions(-)

diff --git a/tests/test_admin_pages.py b/tests/test_admin_pages.py
index 85f60c414..c1528f0e3 100644
--- a/tests/test_admin_pages.py
+++ b/tests/test_admin_pages.py
@@ -1312,7 +1312,7 @@ def test_form_edit_field_advanced(pub):
     assert resp.location == 'http://example.net/backoffice/forms/1/fields/#itemId_1'
     resp = resp.follow()
 
-    assert FormDef.get(formdef.id).fields[0].prefill == {'type': 'string', 'value': 'test'}
+    assert FormDef.get(formdef.id).fields[0].prefill == {'type': 'string', 'value': 'test', 'readonly': False}
 
     # do the same with 'data sources' field
     resp = resp.click('Edit', href='1/')
@@ -1351,19 +1351,19 @@ def test_form_prefill_field(pub):
     resp.form['prefill$type'] = 'String / Template'
     resp.form['prefill$value_string'] = 'test'
     resp = resp.form.submit('submit').follow()
-    assert FormDef.get(formdef.id).fields[0].prefill == {'type': 'string', 'value': 'test'}
+    assert FormDef.get(formdef.id).fields[0].prefill == {'type': 'string', 'value': 'test', 'readonly': False}
 
     resp = app.get('/backoffice/forms/1/fields/1/')
     resp.form['prefill$type'] = 'Python Expression'
     resp.form['prefill$value_formula'] = 'True'
     resp = resp.form.submit('submit').follow()
-    assert FormDef.get(formdef.id).fields[0].prefill == {'type': 'formula', 'value': 'True'}
+    assert FormDef.get(formdef.id).fields[0].prefill == {'type': 'formula', 'value': 'True', 'readonly': False}
 
     resp = app.get('/backoffice/forms/1/fields/1/')
     resp.form['prefill$type'] = 'String / Template'
     resp.form['prefill$value_string'] = '{{form_var_toto}}'
     resp = resp.form.submit('submit').follow()
-    assert FormDef.get(formdef.id).fields[0].prefill == {'type': 'string', 'value': '{{form_var_toto}}'}
+    assert FormDef.get(formdef.id).fields[0].prefill == {'type': 'string', 'value': '{{form_var_toto}}', 'readonly': False}
 
     # check error handling
     resp = app.get('/backoffice/forms/1/fields/1/')
@@ -1757,7 +1757,7 @@ def test_form_edit_map_field(pub):
     resp.form['prefill$value_geolocation'].value = 'Position'
     resp = resp.form.submit('submit')
     assert FormDef.get(formdef.id).fields[0].prefill == {
-            'type': 'geolocation', 'value': 'position'}
+            'type': 'geolocation', 'value': 'position', 'readonly': False}
 
 
 def test_form_edit_field_warnings(pub):
diff --git a/tests/test_form_pages.py b/tests/test_form_pages.py
index 5360f8aa2..b00c625d4 100644
--- a/tests/test_form_pages.py
+++ b/tests/test_form_pages.py
@@ -5084,31 +5084,55 @@ def test_form_page_profile_verified_prefill(pub):
     user.verified_fields = ['email']
     user.store()
 
-    resp = login(get_app(pub), username='foo', password='foo').get('/test/')
-    assert resp.form['f0'].value == 'foo@localhost'
-    assert 'readonly' in resp.form['f0'].attrs
+    for prefill_settings in (
+            {'type': 'user', 'value': 'email'},  # verified profile
+            {'type': 'string', 'value': 'foo@localhost', 'readonly': True},  # readonly value
+        ):
+        formdef.confirmation = True
+        formdef.fields[0].prefill = prefill_settings
+        formdef.store()
+        formdef.data_class().wipe()
+        resp = login(get_app(pub), username='foo', password='foo').get('/test/')
+        assert resp.form['f0'].value == 'foo@localhost'
+        assert 'readonly' in resp.form['f0'].attrs
 
-    resp.form['f0'].value = 'Hello' # try changing the value
-    resp = resp.form.submit('submit')
-    assert 'Check values then click submit.' in resp.text
-    assert resp.form['f0'].value == 'foo@localhost'  # it is reverted
+        resp.form['f0'].value = 'Hello' # try changing the value
+        resp = resp.form.submit('submit')
+        assert 'Check values then click submit.' in resp.text
+        assert resp.form['f0'].value == 'foo@localhost'  # it is reverted
 
-    resp.form['f0'].value = 'Hello' # try again changing the value
-    resp = resp.form.submit('submit')
+        resp.form['f0'].value = 'Hello' # try again changing the value
+        resp = resp.form.submit('submit')
 
-    formdatas = [x for x in formdef.data_class().select() if not x.is_draft()]
-    assert len(formdatas) == 1
-    assert formdatas[0].data['0'] == 'foo@localhost'
+        formdatas = [x for x in formdef.data_class().select() if not x.is_draft()]
+        assert len(formdatas) == 1
+        assert formdatas[0].data['0'] == 'foo@localhost'
 
-    resp = login(get_app(pub), username='foo', password='foo').get('/test/')
-    assert resp.form['f0'].value == 'foo@localhost'
-    resp = resp.form.submit('submit')
-    assert 'Check values then click submit.' in resp.text
-    resp.form['f0'].value = 'Hello' # try changing
-    resp = resp.form.submit('previous')
-    assert 'readonly' in resp.form['f0'].attrs
-    assert not 'Check values then click submit.' in resp.text
-    assert resp.form['f0'].value == 'foo@localhost'
+        resp = login(get_app(pub), username='foo', password='foo').get('/test/')
+        assert resp.form['f0'].value == 'foo@localhost'
+        resp = resp.form.submit('submit')
+        assert 'Check values then click submit.' in resp.text
+        resp.form['f0'].value = 'Hello' # try changing
+        resp = resp.form.submit('previous')
+        assert 'readonly' in resp.form['f0'].attrs
+        assert not 'Check values then click submit.' in resp.text
+        assert resp.form['f0'].value == 'foo@localhost'
+
+        # try it without validation page
+        formdef.confirmation = False
+        formdef.store()
+        formdef.data_class().wipe()
+
+        resp = login(get_app(pub), username='foo', password='foo').get('/test/')
+        assert resp.form['f0'].value == 'foo@localhost'
+        assert 'readonly' in resp.form['f0'].attrs
+
+        resp.form['f0'].value = 'Hello' # try changing the value
+        resp = resp.form.submit('submit')
+
+        formdatas = [x for x in formdef.data_class().select() if not x.is_draft()]
+        assert len(formdatas) == 1
+        assert formdatas[0].data['0'] == 'foo@localhost'
 
 
 def test_form_page_profile_verified_date_prefill(pub):
@@ -7339,3 +7363,29 @@ def test_form_comment_is_hidden_attribute(pub):
     resp.forms[0]['f1'] = '2'
     resp = resp.forms[0].submit('submit')
     assert 'style="display: none"' in comment.search(resp.forms[0].text).group(0)
+
+
+def test_field_live_readonly_prefilled_field(pub, http_requests):
+    FormDef.wipe()
+    formdef = FormDef()
+    formdef.name = 'Foo'
+    formdef.fields = [
+        fields.StringField(type='string', id='1', label='Bar', size='40',
+            required=True, varname='bar'),
+        fields.StringField(type='string', id='2', label='readonly', size='40',
+            required=True,
+            prefill={'type': 'string', 'value': 'bla {{form_var_bar}} bla', 'readonly': True}),
+    ]
+    formdef.store()
+    formdef.data_class().wipe()
+
+    app = get_app(pub)
+    resp = app.get('/foo/')
+    assert 'f1' in resp.form.fields
+    assert resp.html.find('div', {'data-field-id': '1'}).attrs['data-live-source'] == 'true'
+    resp.form['f1'] = 'hello'
+    live_resp = app.post('/foo/live', params=resp.form.submit_fields())
+    assert live_resp.json['result']['2']['content'] == 'bla hello bla'
+    resp.form['f1'] = 'toto'
+    live_resp = app.post('/foo/live?modified_field_id=1', params=resp.form.submit_fields())
+    assert live_resp.json['result']['2']['content'] == 'bla toto bla'
diff --git a/tests/test_formdef_import.py b/tests/test_formdef_import.py
index 39dd6120c..064740a11 100644
--- a/tests/test_formdef_import.py
+++ b/tests/test_formdef_import.py
@@ -593,3 +593,32 @@ def test_digest_template():
     formdef.digest_template = '{{form_number}}'
     f2 = assert_xml_import_export_works(formdef)
     assert f2.digest_template == formdef.digest_template
+
+
+def test_field_prefill():
+    formdef = FormDef()
+    formdef.name = 'Foo'
+    formdef.fields = [
+            fields.StringField(type='string', id=1, label='Bar', size='40',
+                prefill={'type': 'string', 'value': 'plop'})
+            ]
+    f2 = assert_xml_import_export_works(formdef)
+    assert len(f2.fields) == len(formdef.fields)
+    assert f2.fields[0].prefill == {'type': 'string', 'value': 'plop'}
+
+    formdef.fields = [
+            fields.StringField(type='string', id=1, label='Bar', size='40',
+                prefill={'type': 'string', 'value': 'plop', 'readonly': True})
+            ]
+    f2 = assert_xml_import_export_works(formdef)
+    assert len(f2.fields) == len(formdef.fields)
+    assert f2.fields[0].prefill == {'type': 'string', 'value': 'plop', 'readonly': True}
+
+    formdef.fields = [
+            fields.StringField(type='string', id=1, label='Bar', size='40',
+                prefill={'type': 'string', 'value': 'plop', 'readonly': False})
+            ]
+    formdef_xml = formdef.export_to_xml()
+    f2 = FormDef.import_from_xml_tree(formdef_xml)
+    assert len(f2.fields) == len(formdef.fields)
+    assert f2.fields[0].prefill == {'type': 'string', 'value': 'plop'}
diff --git a/wcs/fields.py b/wcs/fields.py
index 4de7f9db4..898047b0c 100644
--- a/wcs/fields.py
+++ b/wcs/fields.py
@@ -122,6 +122,18 @@ class PrefillSelectionWidget(CompositeWidget):
                  attrs={'data-dynamic-display-child-of': 'prefill$type',
                         'data-dynamic-display-value': prefill_types.get('geolocation')})
 
+        # exclude geolocation from readonly prefill as the data necessarily
+        # comes from the user device.
+        self.add(CheckboxWidget,
+                 'readonly',
+                 value=value.get('readonly'),
+                 attrs={'data-dynamic-display-child-of': 'prefill$type',
+                        'data-dynamic-display-value-in': '|'.join(
+                            [x[1] for x in options if x[0] not in ('none', 'geolocation')]),
+                        'inline_title': _('Readonly'),
+                        }
+                 )
+
         self._parsed = False
 
 
@@ -130,6 +142,7 @@ class PrefillSelectionWidget(CompositeWidget):
         type_ = self.get('type')
         if type_:
             values['type'] = type_
+            values['readonly'] = self.get('readonly')
             value = self.get('value_%s' % type_)
             if value:
                 values['value'] = value
@@ -310,6 +323,17 @@ class Field(object):
         elif node.text:
             self.condition = {'type': 'python', 'value': force_str(node.text).strip()}
 
+    def prefill_init_with_xml(self, node, charset, include_id=False):
+        self.prefill = {}
+        if node is not None and node.findall('type'):
+            self.prefill = {
+                'type': force_str(node.find('type').text),
+            }
+            if self.prefill['type'] and self.prefill['type'] != 'none':
+                self.prefill['value'] = force_str(node.find('value').text)
+                if node.find('readonly') is not None and force_str(node.find('readonly').text) == 'True':
+                    self.prefill['readonly'] = True
+
     def get_rst_view_value(self, value, indent=''):
         return indent + self.get_view_value(value)
 
@@ -327,30 +351,32 @@ class Field(object):
     def get_prefill_value(self, user=None, force_string=True):
         # returns a tuple with two items,
         #  1. value[str], the value that will be used to prefill
-        #  2. verified[bool], a flag to know if this is a "verified" value
-        #     (that will therefore be marked as readonly etc.)
+        #  2. readonly[bool], a flag to know if this is a readonly value
+        #     (because it has been explicitely marked so or because it
+        #     comes from verified identity data).
         t = self.prefill.get('type')
+        explicit_readonly = bool(self.prefill.get('readonly'))
         if t == 'string':
             value = self.prefill.get('value')
             if not Template.is_template_string(value):
-                return (value, False)
+                return (value, explicit_readonly)
 
             context = get_publisher().substitutions.get_context_variables()
             try:
-                return (Template(value, autoescape=False, raises=True).render(context), False)
+                return (Template(value, autoescape=False, raises=True).render(context), explicit_readonly)
             except TemplateError:
                 return (None, False)
 
         elif t == 'user' and user:
             x = self.prefill.get('value')
             if x == 'email':
-                return (user.email, 'email' in (user.verified_fields or []))
+                return (user.email, explicit_readonly or 'email' in (user.verified_fields or []))
             elif user.form_data:
                 userform = user.get_formdef()
                 for userfield in userform.fields:
                     if userfield.id == x:
                         return (user.form_data.get(x),
-                                str(userfield.id) in (user.verified_fields or []))
+                                explicit_readonly or str(userfield.id) in (user.verified_fields or []))
 
         elif t == 'formula':
             formula = self.prefill.get('value')
@@ -369,7 +395,7 @@ class Field(object):
                         # (items field are prefilled with list of strings, and
                         # will get the native python object)
                         ret = str(ret)
-                    return (ret, False)
+                    return (ret, explicit_readonly)
             except:
                 pass
 
diff --git a/wcs/formdef.py b/wcs/formdef.py
index fa5fa97be..12c63374e 100644
--- a/wcs/formdef.py
+++ b/wcs/formdef.py
@@ -684,6 +684,11 @@ class FormDef(StorableObject):
                     if not varname in live_condition_fields:
                         live_condition_fields[varname] = []
                     live_condition_fields[varname].append(field)
+            if field.prefill and field.prefill.get('readonly') and field.prefill.get('type') == 'string':
+                for varname in field.get_referenced_varnames(formdef=self, value=field.prefill.get('value', '')):
+                    if varname not in live_condition_fields:
+                        live_condition_fields[varname] = []
+                    live_condition_fields[varname].append(field)
             if field.key == 'comment':
                 for varname in field.get_referenced_varnames(formdef=self, value=field.label):
                     if not varname in live_condition_fields:
diff --git a/wcs/forms/common.py b/wcs/forms/common.py
index 95a69728f..ee38a68f2 100644
--- a/wcs/forms/common.py
+++ b/wcs/forms/common.py
@@ -674,6 +674,9 @@ class FormStatusPage(Directory, FormTemplateMixin):
                 continue
             if widget.field.key == 'comment':
                 result[widget.field.id]['content'] = widget.content
+            elif widget.field.prefill and widget.field.prefill.get('readonly') and widget.field.prefill.get('type') == 'string':
+                value, verified = widget.field.get_prefill_value()
+                result[widget.field.id]['content'] = value
 
         return json.dumps({'result': result})
 
diff --git a/wcs/forms/root.py b/wcs/forms/root.py
index 960a65cb9..15cb61dcb 100644
--- a/wcs/forms/root.py
+++ b/wcs/forms/root.py
@@ -733,24 +733,6 @@ class FormPage(Directory, FormTemplateMixin):
         except (TypeError, ValueError):
             step = 0
 
-        # reset verified fields, making sure the user cannot alter them.
-        prefill_user = get_request().user
-        if get_request().is_in_backoffice():
-            prefill_user = get_publisher().substitutions.get_context_variables().get('form_user')
-        if prefill_user:
-            for field in self.formdef.fields:
-                if not field.prefill:
-                    continue
-                if not 'f%s' % field.id in get_request().form:
-                    continue
-                v, verified = field.get_prefill_value(user=prefill_user)
-                if verified:
-                    if not isinstance(v, six.string_types) and field.convert_value_to_str:
-                        # convert structured data to strings as if they were
-                        # submitted by the browser.
-                        v = field.convert_value_to_str(v)
-                    get_request().form['f%s' % field.id] = v
-
         if step == 0:
             try:
                 page_no = int(form.get_widget('page').parse())
@@ -789,7 +771,11 @@ class FormPage(Directory, FormTemplateMixin):
             form_data = session.get_by_magictoken(magictoken, {})
             with get_publisher().substitutions.temporary_feed(
                     transient_formdata, force_mode='lazy'):
+                # reset readonly data with newly submitted values, this allows
+                # for templates referencing fields from the sampe page.
+                self.reset_readonly_data()
                 data = self.formdef.get_data(form)
+
             form_data.update(data)
 
             if self.has_draft_support() and form.get_submit() == 'savedraft':
@@ -892,6 +878,7 @@ class FormPage(Directory, FormTemplateMixin):
             else:
                 return self.page(self.pages[page_no])
 
+        self.reset_readonly_data()
         if step == 1:
             form.add_submit('previous')
             magictoken = form.get_widget('magictoken').parse()
@@ -942,6 +929,23 @@ class FormPage(Directory, FormTemplateMixin):
 
             return self.submitted(form, existing_formdata)
 
+    def reset_readonly_data(self):
+        # reset readonly fields, making sure the user cannot alter them.
+        prefill_user = get_request().user
+        if get_request().is_in_backoffice():
+            prefill_user = get_publisher().substitutions.get_context_variables().get('form_user')
+        for field in self.formdef.fields:
+            if not field.prefill:
+                continue
+            if not 'f%s' % field.id in get_request().form:
+                continue
+            v, verified = field.get_prefill_value(user=prefill_user)
+            if verified:
+                if not isinstance(v, six.string_types) and field.convert_value_to_str:
+                    # convert structured data to strings as if they were
+                    # submitted by the browser.
+                    v = field.convert_value_to_str(v)
+                get_request().form['f%s' % field.id] = v
 
     def previous_page(self, page_no, magictoken):
         session = get_session()
diff --git a/wcs/qommon/form.py b/wcs/qommon/form.py
index 3735cc476..896146f3a 100644
--- a/wcs/qommon/form.py
+++ b/wcs/qommon/form.py
@@ -230,13 +230,17 @@ def checkbox_render_content(self, standalone=True):
     attrs = {'id': 'form_' + self.name}
     if self.required:
         attrs['aria-required'] = 'true'
+    inline_title = self.attrs.pop('inline_title', '')
     if self.attrs:
         attrs.update(self.attrs)
     checkbox = htmltag("input", xml_end=True, type="checkbox", name=self.name,
                        value="yes", checked=self.value and "checked" or None,
                        **attrs)
     if standalone:
-        return htmltext('<label>%s<span></span></label>' % checkbox)  # for custom style
+        data_attrs = ' '.join('%s="%s"' % x for x in attrs.items() if x[0].startswith('data-'))
+        # more elaborate markup so standalone checkboxes can be applied a
+        # custom style.
+        return htmltext('<label %s>%s<span>' % (data_attrs, checkbox)) + inline_title + htmltext('</span></label>')
     return checkbox
 CheckboxWidget.render_content = checkbox_render_content
 
diff --git a/wcs/qommon/static/js/qommon.forms.js b/wcs/qommon/static/js/qommon.forms.js
index 4faf4547e..4df1b776c 100644
--- a/wcs/qommon/static/js/qommon.forms.js
+++ b/wcs/qommon/static/js/qommon.forms.js
@@ -114,9 +114,14 @@ $(function() {
             }
           }
           if (value.content) {
-            // replace comment content
             var $widget = $('[data-field-id="' + key + '"]');
-            $widget.html(value.content);
+            if ($widget.hasClass('comment-field')) {
+              // replace comment content
+              $widget.html(value.content);
+            } else {
+              // replace text input value
+              $widget.find('input, textarea').val(value.content);
+            }
           }
           if (value.source_url) {
             // json change of URL
-- 
2.25.0

