diff --git a/CHANGES.rst b/CHANGES.rst index f0078742..d49ec02a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,6 +4,10 @@ Changelog 2.7.1 (unreleased) ------------------ +- Allow attaching an XML version of the form data to the sent email #22 + [JeffersonBledsoe] +- Allow the IDs of fields to be customised for CSV download and XML attaachments #22 + [JeffersonBledsoe] - Add Spanish translation. [macagua] diff --git a/README.rst b/README.rst index 6dd3dfa5..32800988 100644 --- a/README.rst +++ b/README.rst @@ -6,7 +6,7 @@ :target: https://pypi.python.org/pypi/collective.volto.formsupport :alt: Egg Status -.. image:: https://img.shields.io/pypi/pyversions/collective.volto.formsupport.svg?style=plastic +.. image:: https://img.shields.io/pypi/pyversions/collective.volto.formsupport.svg?style=plastic :target: https://pypi.python.org/pypi/collective.volto.formsupport/ :alt: Supported - Python Versions @@ -104,7 +104,20 @@ Send If block is set to send data, an email with form data will be sent to the recipient set in block settings or (if not set) to the site address. -If ther is an ``attachments`` field in the POST data, these files will be attached to the emal sent. +If there is an ``attachments`` field in the POST data, these files will be attached to the email sent. + +XML attachments +^^^^^^^^^^^^^^^ + +An XML copy of the data can be optionally attached to the sent email by configuring the volto block's `attachXml` option. + +The sent XML follows the same format as the feature in [collective.easyform](https://github.com/collective/collective.easyform). An example is shown below: + +```xml +
+``` + +The field names in the XML will utilise the Data ID Mapping feature if it is used. Read more about this feature in the following Store section of the documentation. Store ----- @@ -122,6 +135,11 @@ Each Record stores also two *service* attributes: We store these attributes because the form can change over time and we want to have a snapshot of the fields in the Record. +Data ID Mapping +^^^^^^^^^^^^^^^ + +The exported CSV file may need to be used by further processes which require specific values for the columns of the CSV. In such a case, the `Data ID Mapping` feature can be used to change the column name to custom text for each field. + Block serializer ================ diff --git a/src/collective/volto/formsupport/datamanager/catalog.py b/src/collective/volto/formsupport/datamanager/catalog.py index 80836fa5..c92317ae 100644 --- a/src/collective/volto/formsupport/datamanager/catalog.py +++ b/src/collective/volto/formsupport/datamanager/catalog.py @@ -60,7 +60,15 @@ def get_form_fields(self): form_block = deepcopy(block) if not form_block: return {} - return form_block.get("subblocks", []) + + subblocks = form_block.get("subblocks", []) + + # Add the 'custom_field_id' field back in as this isn't stored with each subblock + for index, field in enumerate(subblocks): + if form_block.get(field["field_id"]): + subblocks[index]["custom_field_id"] = form_block.get(field["field_id"]) + + return subblocks def add(self, data): form_fields = self.get_form_fields() @@ -72,7 +80,10 @@ def add(self, data): ) return None - fields = {x["field_id"]: x.get("label", x["field_id"]) for x in form_fields} + fields = { + x["field_id"]: x.get("custom_field_id", x.get("label", x["field_id"])) + for x in form_fields + } record = Record() fields_labels = {} fields_order = [] diff --git a/src/collective/volto/formsupport/restapi/services/submit_form/post.py b/src/collective/volto/formsupport/restapi/services/submit_form/post.py index 4a3b6514..de3df91e 100644 --- a/src/collective/volto/formsupport/restapi/services/submit_form/post.py +++ b/src/collective/volto/formsupport/restapi/services/submit_form/post.py @@ -5,6 +5,7 @@ from collective.volto.formsupport.interfaces import IFormDataStore from collective.volto.formsupport.interfaces import IPostEvent from collective.volto.formsupport.utils import get_blocks +from datetime import datetime from email.message import EmailMessage from plone import api from plone.protect.interfaces import IDisableCSRFProtection @@ -12,6 +13,7 @@ from plone.restapi.deserializer import json_body from plone.restapi.services import Service from Products.CMFPlone.interfaces.controlpanel import IMailSchema +from xml.etree.ElementTree import ElementTree, Element, SubElement from zExceptions import BadRequest from zope.component import getMultiAdapter from zope.component import getUtility @@ -311,6 +313,10 @@ def send_mail(self, msg, charset): def manage_attachments(self, msg): attachments = self.form_data.get("attachments", {}) + + if self.block.get("attachXml", False): + self.attach_xml(msg=msg) + if not attachments: return [] for key, value in attachments.items(): @@ -330,13 +336,40 @@ def manage_attachments(self, msg): file_data = file_data.encode("utf-8") else: file_data = value + maintype, subtype = content_type.split("/") msg.add_attachment( file_data, - maintype=content_type, - subtype=content_type, + maintype=maintype, + subtype=subtype, filename=filename, ) + def attach_xml(self, msg): + now = ( + datetime.now() + .isoformat(timespec="seconds") + .replace(" ", "-") + .replace(":", "") + ) + filename = f"formdata_{now}.xml" + output = six.BytesIO() + xmlRoot = Element("form") + + for field in self.filter_parameters(): + SubElement( + xmlRoot, "field", name=field.get("custom_field_id", field["label"]) + ).text = str(field["value"]) + + doc = ElementTree(xmlRoot) + doc.write(output, encoding="utf-8", xml_declaration=True) + xmlstr = output.getvalue() + msg.add_attachment( + xmlstr, + maintype="application", + subtype="xml", + filename=filename, + ) + def store_data(self): store = getMultiAdapter((self.context, self.request), IFormDataStore) res = store.add(data=self.filter_parameters()) diff --git a/src/collective/volto/formsupport/tests/test_send_action_form.py b/src/collective/volto/formsupport/tests/test_send_action_form.py index 9ff81099..7678eafd 100644 --- a/src/collective/volto/formsupport/tests/test_send_action_form.py +++ b/src/collective/volto/formsupport/tests/test_send_action_form.py @@ -2,6 +2,7 @@ from collective.volto.formsupport.testing import ( # noqa: E501, VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING, ) +from email.parser import Parser from plone import api from plone.app.testing import setRoles from plone.app.testing import SITE_OWNER_NAME @@ -10,6 +11,8 @@ from plone.registry.interfaces import IRegistry from plone.restapi.testing import RelativeSession from Products.MailHost.interfaces import IMailHost +from six import StringIO +import xml.etree.ElementTree as ET from zope.component import getUtility import transaction @@ -579,3 +582,37 @@ def test_send_attachment_validate_size( response.json()["message"], ) self.assertEqual(len(self.mailhost.messages), 0) + + def test_send_xml(self): + self.document.blocks = { + "form-id": {"@type": "form", "send": True, "attachXml": True}, + } + transaction.commit() + + form_data = [ + {"label": "Message", "value": "just want to say hi"}, + {"label": "Name", "value": "John"}, + ] + + response = self.submit_form( + data={ + "from": "john@doe.com", + "data": form_data, + "subject": "test subject", + "block_id": "form-id", + }, + ) + transaction.commit() + self.assertEqual(response.status_code, 204) + msg = self.mailhost.messages[0] + if isinstance(msg, bytes) and bytes is not str: + # Python 3 with Products.MailHost 4.10+ + msg = msg.decode("utf-8") + + parsed_msgs = Parser().parse(StringIO(msg)) + # 1st index is the XML attachment + msg_contents = parsed_msgs.get_payload()[1].get_payload(decode=True) + xml_tree = ET.fromstring(msg_contents) + for index, field in enumerate(xml_tree): + self.assertEqual(field.get("name"), form_data[index]['label']) + self.assertEqual(field.text, form_data[index]['value']) diff --git a/src/collective/volto/formsupport/tests/test_store_action_form.py b/src/collective/volto/formsupport/tests/test_store_action_form.py index e671beca..505992d0 100644 --- a/src/collective/volto/formsupport/tests/test_store_action_form.py +++ b/src/collective/volto/formsupport/tests/test_store_action_form.py @@ -251,3 +251,63 @@ def test_export_csv(self): now = datetime.utcnow().strftime("%Y-%m-%dT%H:%M") self.assertTrue(sorted_data[0][-1].startswith(now)) self.assertTrue(sorted_data[1][-1].startswith(now)) + + def test_data_id_mapping(self): + self.document.blocks = { + "form-id": { + "@type": "form", + "store": True, + "test-field": "renamed-field", + "subblocks": [ + { + "field_id": "message", + "label": "Message", + "field_type": "text", + }, + { + "field_id": "test-field", + "label": "Test field", + "field_type": "text", + }, + ], + }, + } + transaction.commit() + response = self.submit_form( + data={ + "from": "john@doe.com", + "data": [ + {"field_id": "message", "value": "just want to say hi"}, + {"field_id": "test-field", "value": "John"}, + ], + "subject": "test subject", + "block_id": "form-id", + }, + ) + + response = self.submit_form( + data={ + "from": "sally@doe.com", + "data": [ + {"field_id": "message", "value": "bye"}, + {"field_id": "test-field", "value": "Sally"}, + ], + "subject": "test subject", + "block_id": "form-id", + }, + ) + + self.assertEqual(response.status_code, 204) + response = self.export_csv() + data = [*csv.reader(StringIO(response.text), delimiter=",")] + self.assertEqual(len(data), 3) + # Check that 'test-field' got renamed + self.assertEqual(data[0], ["Message", "renamed-field", "date"]) + sorted_data = sorted(data[1:]) + self.assertEqual(sorted_data[0][:-1], ["bye", "Sally"]) + self.assertEqual(sorted_data[1][:-1], ["just want to say hi", "John"]) + + # check date column. Skip seconds because can change during test + now = datetime.utcnow().strftime("%Y-%m-%dT%H:%M") + self.assertTrue(sorted_data[0][-1].startswith(now)) + self.assertTrue(sorted_data[1][-1].startswith(now))