From b84e6cd54e0759762d910b1c6e37278ee799255d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= <fpeters@entrouvert.com>
Date: Sat, 8 Feb 2020 11:01:17 +0100
Subject: [PATCH 4/4] workflows: add support for form details odt section in
 documents (#36627)

---
 tests/template-form-details-no-styles.odt | Bin 0 -> 8001 bytes
 tests/template-form-details.odt           | Bin 0 -> 9503 bytes
 tests/test_form_pages.py                  |   2 +
 tests/test_workflows.py                   |  75 +++++++++++++++-
 wcs/fields.py                             |  24 ++---
 wcs/wf/export_to_model.py                 | 104 +++++++++++++++++++++-
 6 files changed, 183 insertions(+), 22 deletions(-)
 create mode 100644 tests/template-form-details-no-styles.odt
 create mode 100644 tests/template-form-details.odt

diff --git a/tests/template-form-details-no-styles.odt b/tests/template-form-details-no-styles.odt
new file mode 100644
index 0000000000000000000000000000000000000000..ed30af53d7f3ebe6fd85e108a58777a80ed631ef
GIT binary patch
diff --git a/tests/template-form-details.odt b/tests/template-form-details.odt
new file mode 100644
index 0000000000000000000000000000000000000000..f17ad95c1a3c7793251e42df96719ead1634379c
GIT binary patch
diff --git a/tests/test_form_pages.py b/tests/test_form_pages.py
index 099224c46..4dbe4a62f 100644
--- a/tests/test_form_pages.py
+++ b/tests/test_form_pages.py
@@ -61,6 +61,8 @@ def assert_equal_zip(stream1, stream2):
     z2 = zipfile.ZipFile(stream2)
     assert set(z1.namelist()) == set(z2.namelist())
     for name in z1.namelist():
+        if name == 'styles.xml':
+            continue
         t1, t2 = z1.read(name), z2.read(name)
         assert t1 == t2, 'file "%s" differs' % name
 
diff --git a/tests/test_workflows.py b/tests/test_workflows.py
index 7f9c4e019..abec6992c 100644
--- a/tests/test_workflows.py
+++ b/tests/test_workflows.py
@@ -10,7 +10,7 @@ import zipfile
 import mock
 
 from django.utils import six
-from django.utils.encoding import force_bytes
+from django.utils.encoding import force_bytes, force_text
 from django.utils.six import BytesIO, StringIO
 from django.utils.six.moves.urllib import parse as urlparse
 
@@ -23,7 +23,8 @@ from wcs.qommon.form import *
 from wcs.formdef import FormDef
 from wcs import sessions
 from wcs.fields import (StringField, DateField, MapField, FileField, ItemField,
-        ItemsField, CommentField)
+        ItemsField, CommentField, EmailField, PageField, TitleField,
+        SubtitleField, TextField, BoolField, TableField)
 from wcs.formdata import Evolution
 from wcs.logged_errors import LoggedError
 from wcs.roles import Role
@@ -3130,6 +3131,76 @@ def test_export_to_model_django_template(pub):
     assert b'>A &lt;&gt; name<' in new_content
 
 
+@pytest.mark.parametrize('filename', ['template-form-details.odt', 'template-form-details-no-styles.odt'])
+def test_export_to_model_form_details_section(pub, filename):
+    FormDef.wipe()
+    formdef = FormDef()
+    formdef.name = 'foo-export-details'
+    formdef.fields = [
+        PageField(id='1', label='Page 1', type='page'),
+        TitleField(id='2', label='Title', type='title'),
+        SubtitleField(id='3', label='Subtitle', type='subtitle'),
+        StringField(id='4', label='String', type='string', varname='string'),
+        EmailField(id='5', label='Email', type='email'),
+        TextField(id='6', label='Text', type='text'),
+        BoolField(id='8', label='Bool', type='bool'),
+        FileField(id='9', label='File', type='file'),
+        DateField(id='10', label='Date', type='date'),
+        ItemField(id='11', label='Item', type='item', items=['foo', 'bar']),
+        ItemsField(id='11', label='Items', type='items', items=['foo', 'bar']),
+        TableField(id='12', label='Table', type='table', columns=['a', 'b'], rows=['c', 'd'])
+    ]
+    formdef.store()
+    formdef.data_class().wipe()
+    upload = PicklableUpload('test.jpeg', 'image/jpeg')
+    upload.receive([open(os.path.join(os.path.dirname(__file__), 'image-with-gps-data.jpeg'), 'rb').read()])
+    formdata = formdef.data_class()()
+    formdata.data = {
+        '4': 'string',
+        '5': 'foo@localhost',
+        '6': 'para1\npara2',
+        '8': False,
+        '9': upload,
+        '10': time.strptime('2015-05-12', '%Y-%m-%d'),
+        '11': 'foo',
+        '12': [['1', '2'], ['3', '4']],
+    }
+    formdata.just_created()
+    formdata.store()
+    pub.substitutions.feed(formdata)
+
+    item = ExportToModel()
+    item.method = 'non-interactive'
+    item.attach_to_history = True
+    template_filename = os.path.join(os.path.dirname(__file__), filename)
+    template = open(template_filename, 'rb').read()
+    upload = QuixoteUpload(filename, content_type='application/octet-stream')
+    upload.fp = BytesIO()
+    upload.fp.write(template)
+    upload.fp.seek(0)
+    item.model_file = UploadedFile(pub.app_dir, None, upload)
+    item.convert_to_pdf = False
+    item.perform(formdata)
+
+    new_content = force_text(zipfile.ZipFile(open(formdata.evolution[0].parts[0].filename, 'rb')).read('content.xml'))
+    assert 'Titre de page' not in new_content  # section contents has been replaced
+    assert '>Page 1<' in new_content
+    assert '>Title<' in new_content
+    assert '>Subtitle<' in new_content
+    assert '<text:span>string</text:span>' in new_content
+    assert '>para1<' in new_content
+    assert '>para2<' in new_content
+    assert '<text:span>No</text:span>' in new_content
+    assert 'xlink:href="http://example.net/foo-export-details/1/download?f=9"' in new_content
+    assert '>test.jpeg</text:a' in new_content
+    assert '>2015-05-12<' in new_content
+    assert new_content.count('/table:table-cell') == 8
+
+    if filename == 'template-form-details-no-styles.odt':
+        new_styles = force_text(zipfile.ZipFile(open(formdata.evolution[0].parts[0].filename, 'rb')).read('styles.xml'))
+        assert 'Field_20_Label' in new_styles
+
+
 def test_global_timeouts(two_pubs):
     pub = two_pubs
     FormDef.wipe()
diff --git a/wcs/fields.py b/wcs/fields.py
index c2f62d74e..0324e1240 100644
--- a/wcs/fields.py
+++ b/wcs/fields.py
@@ -875,23 +875,13 @@ class TextField(WidgetField):
                 return ''
 
     def get_opendocument_node_value(self, value, formdata=None, **kwargs):
-        if self.pre:
-            p = ET.Element('{%s}p' % OD_NS['text'])
-            line_break = '<nsa:line-break xmlns:nsa="%(ns)s"/>' % {'ns': OD_NS['text']}
-            as_node = ET.fromstring(str(htmlescape(value)).replace('\n', line_break))
-            p.text = as_node.text
-            p.tail = as_node.tail
-            for child in as_node.getchildren():
-                p.append(child)
-            return p
-        else:
-            paragraphs = []
-            for paragraph in value.splitlines():
-                if paragraph.strip():
-                    p = ET.Element('{%s}p' % OD_NS['text'])
-                    p.text = paragraph
-                    paragraphs.append(p)
-            return paragraphs
+        paragraphs = []
+        for paragraph in value.splitlines():
+            if paragraph.strip():
+                p = ET.Element('{%s}p' % OD_NS['text'])
+                p.text = paragraph
+                paragraphs.append(p)
+        return paragraphs
 
     def get_view_short_value(self, value, max_len = 30):
         return ellipsize(str(value), max_len)
diff --git a/wcs/wf/export_to_model.py b/wcs/wf/export_to_model.py
index 8cd714d4c..805153a9f 100644
--- a/wcs/wf/export_to_model.py
+++ b/wcs/wf/export_to_model.py
@@ -48,10 +48,14 @@ from wcs.portfolio import has_portfolio, push_document
 
 OO_TEXT_NS = 'urn:oasis:names:tc:opendocument:xmlns:text:1.0'
 OO_OFFICE_NS = 'urn:oasis:names:tc:opendocument:xmlns:office:1.0'
+OO_STYLE_NS = 'urn:oasis:names:tc:opendocument:xmlns:style:1.0'
 OO_DRAW_NS = 'urn:oasis:names:tc:opendocument:xmlns:drawing:1.0'
+OO_FO_NS = 'urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0'
 XLINK_NS = 'http://www.w3.org/1999/xlink'
 USER_FIELD_DECL = '{%s}user-field-decl' % OO_TEXT_NS
 USER_FIELD_GET = '{%s}user-field-get' % OO_TEXT_NS
+SECTION_NODE = '{%s}section' % OO_TEXT_NS
+SECTION_NAME = '{%s}name' % OO_TEXT_NS
 STRING_VALUE = '{%s}string-value' % OO_OFFICE_NS
 DRAW_FRAME = '{%s}frame' % OO_DRAW_NS
 DRAW_NAME = '{%s}name' % OO_DRAW_NS
@@ -94,8 +98,8 @@ def transform_opendocument(instream, outstream, process):
     new_images = {}
     assert 'content.xml' in zin.namelist()
     for filename in zin.namelist():
-        # first pass to process meta.xml and content.xml
-        if filename not in ('meta.xml', 'content.xml'):
+        # first pass to process meta.xml, content.xml and styles.xml
+        if filename not in ('meta.xml', 'content.xml', 'styles.xml'):
             continue
         content = zin.read(filename)
         root = ET.fromstring(content)
@@ -105,7 +109,7 @@ def transform_opendocument(instream, outstream, process):
 
     for filename in zin.namelist():
         # second pass to copy/replace other files
-        if filename in ('meta.xml', 'content.xml'):
+        if filename in ('meta.xml', 'content.xml', 'styles.xml'):
             continue
         if filename in new_images:
             content = new_images[filename].get_content()
@@ -418,15 +422,54 @@ class ExportToModel(WorkflowStatusItem):
     def apply_od_template_to_formdata(self, formdata):
         context = get_formdata_template_context(formdata)
 
+        def process_styles(root):
+            styles_node = root.find('{%s}styles' % OO_OFFICE_NS)
+            if styles_node is None:
+                return
+            style_names = set([x.attrib.get('{%s}name' % OO_STYLE_NS) for x in styles_node.getchildren()])
+            for style_name in ['Page_20_Title', 'Form_20_Title', 'Form_20_Subtitle',
+                               'Field_20_Label', 'Field_20_Value']:
+                # if any style name is defined, don't alter styles
+                if style_name in style_names:
+                    return
+            for i, style_name in enumerate(['Field_20_Label', 'Field_20_Value',
+                                            'Form_20_Subtitle', 'Form_20_Title', 'Page_20_Title']):
+                style_node = ET.SubElement(styles_node, '{%s}style' % OO_STYLE_NS)
+                style_node.attrib['{%s}name' % OO_STYLE_NS] = style_name
+                style_node.attrib['{%s}display-name' % OO_STYLE_NS] = style_name.replace('_20_', ' ')
+                style_node.attrib['{%s}family' % OO_STYLE_NS] = 'paragraph'
+                para_props = ET.SubElement(style_node, '{%s}paragraph-properties' % OO_STYLE_NS)
+                if 'Value' not in style_name:
+                    para_props.attrib['{%s}margin-top' % OO_FO_NS] = '0.5cm'
+                else:
+                    para_props.attrib['{%s}margin-left' % OO_FO_NS] = '0.25cm'
+                if 'Title' in style_name:
+                    text_props = ET.SubElement(style_node, '{%s}text-properties' % OO_STYLE_NS)
+                    text_props.attrib['{%s}font-size' % OO_FO_NS] = '%s%%' % (90 + i * 10)
+                    text_props.attrib['{%s}font-weight' % OO_FO_NS] = 'bold'
+
         def process_root(root, new_images):
+            if root.tag == '{%s}document-styles' % OO_OFFICE_NS:
+                return process_styles(root)
+
             # cache for keeping computed user-field-decl value around
             user_field_values = {}
 
             def process_text(t):
                 t = template_on_context(context, force_str(t), autoescape=False)
                 return force_text(t, get_publisher().site_charset)
+            nodes = []
             for node in root.iter():
+                nodes.append(node)
+            for node in nodes:
                 got_blank_lines = False
+                if node.tag == SECTION_NODE and node.attrib.get(SECTION_NAME) == 'form_details':
+                    # custom behaviour for {{form_details}}, create real odt
+                    # markup.
+                    for child in node.getchildren():
+                        node.remove(child)
+                    self.insert_form_details(node, formdata)
+
                 # apply template to user-field-decl and update user-field-get
                 if node.tag == USER_FIELD_DECL and STRING_VALUE in node.attrib:
                     node.attrib[STRING_VALUE] = process_text(node.attrib[STRING_VALUE])
@@ -481,6 +524,61 @@ class ExportToModel(WorkflowStatusItem):
         outstream.seek(0)
         return outstream
 
+    def insert_form_details(self, node, formdata):
+        field_details = formdata.get_summary_field_details()
+        section_node = node
+        for field_value_info in field_details:
+            f = field_value_info['field']
+
+            if f.type == 'page':
+                page_title = ET.SubElement(section_node, '{%s}h' % OO_TEXT_NS)
+                page_title.attrib['{%s}outline-level' % OO_TEXT_NS] = '1'
+                page_title.attrib['{%s}style-name' % OO_TEXT_NS] = 'Page_20_Title'
+                page_title.text = f.label
+                continue
+
+            if f.type in ('title', 'subtitle'):
+                label = template_on_formdata(None, f.label, autoescape=False)
+                title = ET.SubElement(section_node, '{%s}h' % OO_TEXT_NS)
+                title.attrib['{%s}outline-level' % OO_TEXT_NS] = '2'
+                title.attrib['{%s}style-name' % OO_TEXT_NS] = 'Form_20_Title'
+                if f.type == 'subtitle':
+                    title.attrib['{%s}outline-level' % OO_TEXT_NS] = '3'
+                    title.attrib['{%s}style-name' % OO_TEXT_NS] = 'Form_20_Subtitle'
+                title.text = label
+                continue
+
+            if f.type == 'comment':
+                # comment can be free form HTML, ignore them.
+                continue
+
+            if not f.get_opendocument_node_value:
+                # unsupported field type
+                continue
+
+            label_p = ET.SubElement(section_node, '{%s}p' % OO_TEXT_NS)
+            label_p.attrib['{%s}style-name' % OO_TEXT_NS] = 'Field_20_Label'
+            label_p.text = f.label
+            value = field_value_info['value']
+            if value is None:
+                unset_value_p = ET.SubElement(section_node, '{%s}p' % OO_TEXT_NS)
+                unset_value_p.attrib['{%s}style-name' % OO_TEXT_NS] = 'Field_20_Value'
+                unset_value_i = ET.SubElement(unset_value_p, '{%s}span' % OO_TEXT_NS)
+                unset_value_i.text = _('Not set')
+            else:
+                node_value = f.get_opendocument_node_value(value, formdata)
+                if isinstance(node_value, list):
+                    for node in node_value:
+                        section_node.append(node)
+                        node.attrib['{%s}style-name' % OO_TEXT_NS] = 'Field_20_Value'
+                elif node_value.tag in ('{%s}span' % OO_TEXT_NS, '{%s}a' % OO_TEXT_NS):
+                    value_p = ET.SubElement(section_node, '{%s}p' % OO_TEXT_NS)
+                    value_p.attrib['{%s}style-name' % OO_TEXT_NS] = 'Field_20_Value'
+                    value_p.append(node_value)
+                else:
+                    node_value.attrib['{%s}style-name' % OO_TEXT_NS] = 'Field_20_Value'
+                    section_node.append(node_value)
+
     def model_file_export_to_xml(self, xml_item, charset, include_id=False):
         if not self.model_file:
             return
-- 
2.25.1

