From 4b79ed86159c70546618f1b66dcd5d74d4bb30f8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= <fpeters@entrouvert.com>
Date: Tue, 6 Oct 2020 19:53:07 +0200
Subject: [PATCH] general: add support for prefilling blocks (#45264)

---
 tests/test_form_pages.py | 145 ++++++++++++++++++++++++++++
 wcs/forms/root.py        | 203 ++++++++++++++++++++++++---------------
 wcs/wf/form.py           |  22 +----
 3 files changed, 272 insertions(+), 98 deletions(-)

diff --git a/tests/test_form_pages.py b/tests/test_form_pages.py
index 7317c1922..6a6f062b5 100644
--- a/tests/test_form_pages.py
+++ b/tests/test_form_pages.py
@@ -8841,6 +8841,151 @@ def test_block_geoloc_prefill(pub, blocks_feature):
     assert resp.html.find('div', {'data-geolocation': 'road'})
 
 
+def test_block_string_prefill(pub, blocks_feature):
+    create_user(pub)
+    FormDef.wipe()
+    BlockDef.wipe()
+
+    block = BlockDef()
+    block.name = 'foobar'
+    block.fields = [
+        fields.StringField(id='123', required=True, label='Test', type='string',
+            prefill={'type': 'string', 'value': '{{ form_var_foo }} World'}),
+    ]
+    block.store()
+
+    formdef = FormDef()
+    formdef.name = 'form title'
+    formdef.fields = [
+        fields.PageField(id='0', label='1st page', type='page'),
+        fields.StringField(id='1', label='string', varname='foo'),
+        fields.PageField(id='2', label='2nd page', type='page'),
+        fields.BlockField(id='3', label='test', type='block:foobar'),
+    ]
+    formdef.store()
+    formdef.data_class().wipe()
+
+    app = get_app(pub)
+    resp = app.get(formdef.get_url())
+    resp.form['f1'] = 'Hello'
+    resp = resp.form.submit('submit')  # -> 2nd page
+    assert resp.form['f3$element0$f123'].value == 'Hello World'
+    resp = resp.form.submit('submit')  # -> validation page
+    resp = resp.form.submit('submit')  # -> end page
+    resp = resp.follow()
+
+    formdata = formdef.data_class().select()[0]
+    assert formdata.data['3']['data'][0]['123'] == 'Hello World'
+
+    # check unmodified prefilled field
+    app = get_app(pub)
+    resp = app.get(formdef.get_url())
+    resp.form['f1'] = 'Hello'
+    resp = resp.form.submit('submit')  # -> 2nd page
+    assert resp.form['f3$element0$f123'].value == 'Hello World'
+    resp = resp.form.submit('submit')  # -> validation page
+    resp = resp.form.submit('previous')  # -> 2nd page
+    resp = resp.form.submit('previous')  # -> 1st page
+    resp.form['f1'] = 'Test'
+    resp = resp.form.submit('submit')  # -> 2nd page
+    assert resp.form['f3$element0$f123'].value == 'Test World'
+
+    # check modified prefilled field
+    app = get_app(pub)
+    resp = app.get(formdef.get_url())
+    resp.form['f1'] = 'Hello'
+    resp = resp.form.submit('submit')  # -> 2nd page
+    assert resp.form['f3$element0$f123'].value == 'Hello World'
+    resp.form['f3$element0$f123'] = 'Foobar'
+    resp = resp.form.submit('submit')  # -> validation page
+    resp = resp.form.submit('previous')  # -> 2nd page
+    resp = resp.form.submit('previous')  # -> 1st page
+    resp.form['f1'] = 'Test'
+    resp = resp.form.submit('submit')  # -> 2nd page
+    assert resp.form['f3$element0$f123'].value == 'Foobar'
+
+
+def test_block_locked_prefill(pub, blocks_feature):
+    create_user(pub)
+    FormDef.wipe()
+    BlockDef.wipe()
+
+    block = BlockDef()
+    block.name = 'foobar'
+    block.fields = [
+        fields.StringField(id='123', required=True, label='Test', type='string',
+            prefill={'type': 'string', 'value': '{{ form_var_foo }} World', 'locked': True}),
+    ]
+    block.store()
+
+    formdef = FormDef()
+    formdef.name = 'form title'
+    formdef.fields = [
+        fields.PageField(id='0', label='1st page', type='page'),
+        fields.StringField(id='1', label='string', varname='foo'),
+        fields.PageField(id='2', label='2nd page', type='page'),
+        fields.BlockField(id='3', label='test', type='block:foobar'),
+    ]
+    formdef.store()
+    formdef.data_class().wipe()
+
+    app = get_app(pub)
+    resp = app.get(formdef.get_url())
+    resp.form['f1'] = 'Hello'
+    resp = resp.form.submit('submit')  # -> 2nd page
+    assert resp.form['f3$element0$f123'].value == 'Hello World'
+    assert 'readonly' in resp.form['f3$element0$f123'].attrs
+    resp.form['f3$element0$f123'].value = 'Hello'  # try changing the value
+    resp = resp.form.submit('submit')  # -> validation page
+    resp = resp.form.submit('submit')  # -> end page
+    resp = resp.follow()
+
+    formdata = formdef.data_class().select()[0]
+    assert formdata.data['3']['data'][0]['123'] == 'Hello World'  # value got reverted
+
+
+def test_workflow_form_block_prefill(pub):
+    create_user(pub)
+    FormDef.wipe()
+    BlockDef.wipe()
+
+    block = BlockDef()
+    block.name = 'foobar'
+    block.fields = [
+        fields.StringField(id='123', required=True, label='Test', type='string',
+            prefill={'type': 'user', 'value': 'email'}),
+    ]
+    block.store()
+
+    wf = Workflow(name='status')
+    st1 = wf.add_status('Status1', 'st1')
+
+    display_form = FormWorkflowStatusItem()
+    display_form.id = '_x'
+    display_form.by = ['_submitter']
+    display_form.varname = 'xxx'
+    display_form.formdef = WorkflowFormFieldsFormDef(item=display_form)
+    display_form.formdef.fields.append(
+        fields.BlockField(id='3', label='test', type='block:foobar')
+    )
+    st1.items.append(display_form)
+    display_form.parent = st1
+
+    wf.store()
+
+    formdef = create_formdef()
+    formdef.workflow_id = wf.id
+    formdef.fields = []
+    formdef.store()
+    formdef.data_class().wipe()
+
+    resp = login(get_app(pub), username='foo', password='foo').get('/test/')
+    resp = resp.form.submit('submit')  # -> validation
+    resp = resp.form.submit('submit').follow()
+    assert 'The form has been recorded' in resp
+    assert resp.form['f3$element0$f123'].value == 'foo@localhost'
+
+
 def test_block_title_and_comment(pub, blocks_feature):
     create_user(pub)
     FormDef.wipe()
diff --git a/wcs/forms/root.py b/wcs/forms/root.py
index 778525c9e..d2a3efbb0 100644
--- a/wcs/forms/root.py
+++ b/wcs/forms/root.py
@@ -298,6 +298,104 @@ class FormPage(Directory, FormTemplateMixin):
                   'current_page_no': current_position,
                 })
 
+    @classmethod
+    def iter_with_block_fields(cls, form, fields):
+        for field in fields:
+            field_key = '%s' % field.id
+            widget = form.get_widget('f%s' % field_key) if form else None
+            yield field, field_key, widget, None
+            if field.key == 'block':
+                # we only ever prefill the first item
+                subwidget = widget.widgets[0] if widget else None
+                for subfield in field.block.fields:
+                    subfield_key = '%s$%s' % (field.id, subfield.id)
+                    subfield_widget = subwidget.get_widget('f%s' % subfield.id) if subwidget else None
+                    yield subfield, subfield_key, subfield_widget, field
+
+    @classmethod
+    def apply_field_prefills(cls, data, form, displayed_fields):
+        req = get_request()
+        had_prefill = False
+        for field, field_key, widget, block in cls.iter_with_block_fields(form, displayed_fields):
+            v = None
+            prefilled = False
+            locked = False
+
+            if field.prefill:
+                prefill_user = get_request().user
+                if get_request().is_in_backoffice():
+                    prefill_user = get_publisher().substitutions.get_context_variables(
+                            ).get('form_user')
+                v, locked = field.get_prefill_value(user=prefill_user)
+
+                # always set additional attributes as they will be used for
+                # "live prefill", regardless of existing data.
+                widget.prefill_attributes = field.get_prefill_attributes()
+
+            should_prefill = bool(field.prefill)
+
+            has_current_value = False
+            if block:
+                try:
+                    current_value = data[block.id]['data'][0][field.id]
+                    has_current_value = True
+                except (IndexError, KeyError, ValueError):
+                    pass
+            else:
+                try:
+                    current_value = data[field_key]
+                    has_current_value = True
+                except KeyError:
+                    pass
+
+            if has_current_value:
+                # existing value, update it with the new computed value
+                # if it's the same that was previously computed.
+                prefill_value = v
+                v = current_value
+                if data.get('prefilling_data', {}).get(field_key) == current_value:
+                    # replace value with new value computed for prefill
+                    v = prefill_value
+                else:
+                    should_prefill = False
+
+            if should_prefill:
+                if get_request().is_in_backoffice() and (
+                        field.prefill and field.prefill.get('type') == 'geoloc'):
+                    # turn off prefilling from geolocation attributes if
+                    # the form is filled from the backoffice
+                    v = None
+                if v:
+                    prefilled = True
+                    widget.prefilled = True
+
+            if not prefilled and widget:
+                widget.clear_error()
+                widget._parsed = False
+
+            if v is not None:
+                # store computed value, it will be used to compare with
+                # submitted value if page is visited again.
+                if should_prefill:
+                    if 'prefilling_data' not in data:
+                        data['prefilling_data'] = {}
+                    data['prefilling_data'][field_key] = v
+                if not isinstance(v, str) and field.convert_value_to_str:
+                    v = field.convert_value_to_str(v)
+                widget.set_value(v)
+                widget.transfer_form_value(req)
+                if field.type == 'item' and v and widget.value != v:
+                    # mark field as invalid if the value was not accepted
+                    # (this is required by quixote>=3 as the value would
+                    # not be evaluated in the initial GET request of the
+                    # page).
+                    widget.set_error(get_selection_error_text())
+                if locked:
+                    widget.readonly = 'readonly'
+                    widget.attrs['readonly'] = 'readonly'
+                had_prefill = True
+        return had_prefill
+
     def page(self, page, page_change=True, page_error_messages=None, submit_button=None):
         displayed_fields = []
 
@@ -360,70 +458,8 @@ class FormPage(Directory, FormTemplateMixin):
             # visited, we restore values; otherwise we set req.form as empty.
             req = get_request()
             req.environ['REQUEST_METHOD'] = 'GET'
-            for field in displayed_fields:
-                k = field.id
-                v = None
-                prefilled = False
-                locked = False
-
-                if field.prefill:
-                    prefill_user = get_request().user
-                    if get_request().is_in_backoffice():
-                        prefill_user = get_publisher().substitutions.get_context_variables(
-                                ).get('form_user')
-                    v, locked = field.get_prefill_value(user=prefill_user)
-
-                    # always set additional attributes as they will be used for
-                    # "live prefill", regardless of existing data.
-                    form.get_widget('f%s' % k).prefill_attributes = field.get_prefill_attributes()
-
-                should_prefill = bool(field.prefill)
-
-                if k in data:
-                    # existing value, update it with the new computed value
-                    # if it's the same that was previously computed.
-                    prefill_value = v
-                    v = data[k]
-                    if data.get('prefilling_data', {}).get(k) == data[k]:
-                        # replace value with new value computed for prefill
-                        v = prefill_value
-                    else:
-                        should_prefill = False
 
-                if should_prefill:
-                    if get_request().is_in_backoffice() and (
-                            field.prefill and field.prefill.get('type') == 'geoloc'):
-                        # turn off prefilling from geolocation attributes if
-                        # the form is filled from the backoffice
-                        v = None
-                    if v:
-                        prefilled = True
-                        form.get_widget('f%s' % k).prefilled = True
-
-                if not prefilled and form.get_widget('f%s' % k):
-                    form.get_widget('f%s' % k).clear_error()
-
-                if v is not None:
-                    # store computed value, it will be used to compare with
-                    # submitted value if page is visited again.
-                    if should_prefill:
-                        if 'prefilling_data' not in data:
-                            data['prefilling_data'] = {}
-                        data['prefilling_data'][k] = v
-                    if not isinstance(v, str) and field.convert_value_to_str:
-                        v = field.convert_value_to_str(v)
-                    form.get_widget('f%s' % k).set_value(v)
-                    form.get_widget('f%s' % k).transfer_form_value(req)
-                    if field.type == 'item' and v and form.get_widget('f%s' % k).value != v:
-                        # mark field as invalid if the value was not accepted
-                        # (this is required by quixote>=3 as the value would
-                        # not be evaluated in the initial GET request of the
-                        # page).
-                        form.get_widget('f%s' % k).set_error(get_selection_error_text())
-                    if locked:
-                        form.get_widget('f%s' % k).readonly = 'readonly'
-                        form.get_widget('f%s' % k).attrs['readonly'] = 'readonly'
-                    had_prefill = True
+            had_prefill = self.apply_field_prefills(data, form, displayed_fields)
 
             if had_prefill:
                 # include prefilled data
@@ -438,16 +474,16 @@ class FormPage(Directory, FormTemplateMixin):
         else:
             # not a page change, reset_locked_data() will have been called
             # earlier, we use that to set appropriate fields as readonly.
-            for field in displayed_fields:
-                if get_request().form.get('__locked_f%s' % field.id):
-                    form.get_widget('f%s' % field.id).readonly = 'readonly'
-                    form.get_widget('f%s' % field.id).attrs['readonly'] = 'readonly'
+            for field, field_key, widget, block in self.iter_with_block_fields(form, displayed_fields):
+                if get_request().form.get('__locked_f%s' % field_key):
+                    widget.readonly = 'readonly'
+                    widget.attrs['readonly'] = 'readonly'
 
-        for field in displayed_fields:
+        for field, field_key, widget, block in self.iter_with_block_fields(form, displayed_fields):
             if field.prefill:
                 # always set additional attributes as they will be used for
                 # "live prefill", regardless of existing data.
-                form.get_widget('f%s' % field.id).prefill_attributes = field.get_prefill_attributes()
+                widget.prefill_attributes = field.get_prefill_attributes()
 
         self.formdef.set_live_condition_sources(form, displayed_fields)
 
@@ -455,8 +491,7 @@ class FormPage(Directory, FormTemplateMixin):
             # pass over prefilled fields that are used as live source of item
             # fields
             fields_to_update = set()
-            for field in displayed_fields:
-                widget = form.get_widget('f%s' % field.id)
+            for field, field_key, widget, block in self.iter_with_block_fields(form, displayed_fields):
                 if getattr(widget, 'prefilled', False) and getattr(widget, 'live_condition_source', False):
                     fields_to_update.update(widget.live_condition_fields)
                 elif field in fields_to_update and field.type == 'item':
@@ -857,7 +892,7 @@ class FormPage(Directory, FormTemplateMixin):
                     transient_formdata, force_mode='lazy'):
                 # reset locked data with newly submitted values, this allows
                 # for templates referencing fields from the sampe page.
-                self.reset_locked_data()
+                self.reset_locked_data(form)
                 data = self.formdef.get_data(form)
 
             form_data.update(data)
@@ -969,7 +1004,7 @@ class FormPage(Directory, FormTemplateMixin):
             else:
                 return self.page(self.pages[page_no])
 
-        self.reset_locked_data()
+        self.reset_locked_data(form)
         if step == 1:
             form.add_submit('previous')
             magictoken = form.get_widget('magictoken').parse()
@@ -1020,15 +1055,18 @@ class FormPage(Directory, FormTemplateMixin):
 
             return self.submitted(form, existing_formdata)
 
-    def reset_locked_data(self):
+    def reset_locked_data(self, form):
         # reset locked 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:
+        for field, field_key, widget, block in self.iter_with_block_fields(form, self.formdef.fields):
             if not field.prefill:
                 continue
-            if not 'f%s' % field.id in get_request().form:
+            post_key = 'f%s' % field_key
+            if block:
+                post_key = 'f%s$element0$f%s' % (block.id, field.id)
+            if post_key not in get_request().form:
                 continue
             v, locked = field.get_prefill_value(user=prefill_user)
             if locked:
@@ -1036,10 +1074,19 @@ class FormPage(Directory, FormTemplateMixin):
                     # 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
+                get_request().form[post_key] = v
+                if widget:
+                    widget.set_value(v)
+                    if block:
+                        # child widget value was changed, mark parent widgets
+                        # as unparsed
+                        block_widget = form.get_widget('f%s' % block.id)
+                        block_widget._parsed = False
+                        block_widget.widgets[0]._parsed = False
+
                 # keep track of locked field, this will be used when
                 # redisplaying the same page in case of errors.
-                get_request().form['__locked_f%s' % field.id] = True
+                get_request().form['__locked_f%s' % field_key] = True
 
     def previous_page(self, page_no, magictoken):
         session = get_session()
diff --git a/wcs/wf/form.py b/wcs/wf/form.py
index 2a3e7a8b7..9ff7519d8 100644
--- a/wcs/wf/form.py
+++ b/wcs/wf/form.py
@@ -26,6 +26,7 @@ from wcs.admin.fields import FieldDefPage, FieldsDirectory
 from wcs.formdata import get_dict_with_varnames
 
 from wcs.forms.common import FileDirectory
+from wcs.forms.root import FormPage
 
 
 def lookup_wf_form_file(self, filename):
@@ -177,7 +178,6 @@ class FormWorkflowStatusItem(WorkflowStatusItem):
 
         formdata.feed_session()
 
-        req = get_request()
         self.formdef.set_live_condition_sources(form, self.formdef.fields)
 
         if form.is_submitted():
@@ -188,25 +188,7 @@ class FormWorkflowStatusItem(WorkflowStatusItem):
         if displayed_fields is not None:
             fields = displayed_fields
 
-        for field in fields:
-            if ('f%s' % field.id) in req.form:
-                continue
-            if not field.prefill or field.prefill.get('type') == 'none':
-                continue
-            # FIXME: this code duplicates code from wcs/forms/root.py :/
-            prefill_user = get_request().user
-            if get_request().is_in_backoffice():
-                prefill_user = get_publisher().substitutions.get_context_variables(
-                        ).get('form_user')
-            v, verified = field.get_prefill_value(user=prefill_user)
-            if get_request().is_in_backoffice() and (
-                    field.prefill and field.prefill.get('type') == 'geoloc'):
-                # turn off prefilling from geolocation attributes if
-                # the form is filled from the backoffice
-                v = None
-            if v:
-                form.get_widget('f%s' % field.id).set_value(v)
-                req.form['f%s' % field.id] = v
+        FormPage.apply_field_prefills({}, form, fields)
 
     def evaluate_live_form(self, form, formdata, user):
         workflow_data = {}
-- 
2.28.0

