diff --git a/.coveragerc b/.coveragerc
index c6959a4a..40a388d7 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -1,6 +1,10 @@
[run]
relative_files = True
+omit =
+ src/collective/volto/formsupport/tests/*
+ src/collective/volto/formsupport/upgrades.py
+
[report]
include =
*/src/collective/*
diff --git a/README.rst b/README.rst
index 0986b382..2d2d5beb 100644
--- a/README.rst
+++ b/README.rst
@@ -313,6 +313,13 @@ This add-on can be seen in action at the following sites:
- https://www.comune.modena.it/form/contatti
+Storing different values from how they are displayed
+=========================
+
+In some cases, the text that is displayed for a field on the page and in the sent email may need to be different from the value that is stored internally. For example, you may want your "Yes/ No" widget to show "Accept" and "Decline" as the labels, but internally still store `True` and `False`.
+
+By storing a `display_values` dictionary for each field in the block data, you can perform these mappings.
+
Translations
============
diff --git a/src/collective/volto/formsupport/browser/send_mail_template.pt b/src/collective/volto/formsupport/browser/send_mail_template.pt
index 0eb472be..ed872114 100644
--- a/src/collective/volto/formsupport/browser/send_mail_template.pt
+++ b/src/collective/volto/formsupport/browser/send_mail_template.pt
@@ -9,7 +9,7 @@
-
label:
diff --git a/src/collective/volto/formsupport/browser/send_mail_template_table.pt b/src/collective/volto/formsupport/browser/send_mail_template_table.pt
index aac09ccb..9aec9f14 100644
--- a/src/collective/volto/formsupport/browser/send_mail_template_table.pt
+++ b/src/collective/volto/formsupport/browser/send_mail_template_table.pt
@@ -31,7 +31,7 @@
diff --git a/src/collective/volto/formsupport/datamanager/catalog.py b/src/collective/volto/formsupport/datamanager/catalog.py
index f746119c..6e7dd7fb 100644
--- a/src/collective/volto/formsupport/datamanager/catalog.py
+++ b/src/collective/volto/formsupport/datamanager/catalog.py
@@ -77,17 +77,19 @@ def add(self, data):
)
)
return None
+ fields = {}
+ for field in form_fields:
+ custom_field_id = field.get("custom_field_id")
+ field_id = custom_field_id if custom_field_id else field["field_id"]
+ fields[field_id] = field.get("label", field_id)
- 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 = []
for field_data in data:
- field_id = field_data.get("field_id", "")
- value = field_data.get("value", "")
+ field_id = field_data.field_id
+ # TODO: not nice using the protected member to access the real internal value, but easiest way.
+ value = field_data.internal_value
if field_id in fields:
record.attrs[field_id] = value
fields_labels[field_id] = fields[field_id]
diff --git a/src/collective/volto/formsupport/restapi/services/submit_form/field.py b/src/collective/volto/formsupport/restapi/services/submit_form/field.py
new file mode 100644
index 00000000..9956a87c
--- /dev/null
+++ b/src/collective/volto/formsupport/restapi/services/submit_form/field.py
@@ -0,0 +1,104 @@
+from collective.volto.formsupport import _
+from plone.schema.email import _isemail
+from zExceptions import BadRequest
+from zope.i18n import translate
+
+
+class Field:
+ def __init__(self, field_data):
+ def _attribute(attribute_name):
+ setattr(self, attribute_name, field_data.get(attribute_name))
+
+ _attribute("field_type")
+ _attribute("id")
+ _attribute("show_when_when")
+ _attribute("show_when_is")
+ _attribute("show_when_to")
+ _attribute("input_values")
+ _attribute("required")
+ _attribute("widget")
+ _attribute("use_as_reply_to")
+ _attribute("use_as_reply_bcc")
+ self._display_value_mapping = field_data.get("display_value_mapping")
+ self._value = field_data.get("value", "")
+ self._custom_field_id = field_data.get("custom_field_id")
+ self._label = field_data.get("label", "")
+ self._field_id = field_data.get("field_id", "")
+
+ @property
+ def display_value(self):
+ if self._display_value_mapping:
+ return self._display_value_mapping.get(self._value, self._value)
+ return self._value
+
+ @property
+ def internal_value(self):
+ return self._value
+
+ @property
+ def label(self):
+ return self._label if self._label else self.field_id
+
+ @property
+ def field_id(self):
+ if self._custom_field_id:
+ return self._custom_field_id
+ return self._field_id if self._field_id else self._label
+
+ @property
+ def send_in_email(self):
+ return True
+
+ def validate(self, request):
+ return
+
+
+class YesNoField(Field):
+ @property
+ def display_value(self):
+ if not self._display_value_mapping:
+ return self.internal_value
+ if self.internal_value is True:
+ return self._display_value_mapping.get("yes")
+ elif self.internal_value is False:
+ return self._display_value_mapping.get("no")
+
+
+class AttachmentField(Field):
+ @property
+ def send_in_email(self):
+ return False
+
+
+class EmailField(Field):
+ def validate(self, request):
+ super().validate(request=request)
+
+ if _isemail(self.internal_value) is None:
+ raise BadRequest(
+ translate(
+ _(
+ "wrong_email",
+ default='Email not valid in "${field}" field.',
+ mapping={
+ "field": self.label,
+ },
+ ),
+ context=request,
+ )
+ )
+
+
+def construct_field(field_data):
+ if field_data.get("widget") == "single_choice":
+ return YesNoField(field_data)
+ elif field_data.get("field_type") == "attachment":
+ return AttachmentField(field_data)
+ elif field_data.get("field_type") in ["from", "email"]:
+ return EmailField(field_data)
+
+ return Field(field_data)
+
+
+def construct_fields(fields):
+ return [construct_field(field) for field in fields]
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 a80d702f..1a76b441 100644
--- a/src/collective/volto/formsupport/restapi/services/submit_form/post.py
+++ b/src/collective/volto/formsupport/restapi/services/submit_form/post.py
@@ -2,6 +2,9 @@
from collective.volto.formsupport.interfaces import ICaptchaSupport
from collective.volto.formsupport.interfaces import IFormDataStore
from collective.volto.formsupport.interfaces import IPostEvent
+from collective.volto.formsupport.restapi.services.submit_form.field import (
+ construct_fields,
+)
from collective.volto.formsupport.utils import get_blocks
from collective.volto.formsupport.utils import validate_email_token
from copy import deepcopy
@@ -15,7 +18,6 @@
from plone.registry.interfaces import IRegistry
from plone.restapi.deserializer import json_body
from plone.restapi.services import Service
-from plone.schema.email import _isemail
from xml.etree.ElementTree import Element
from xml.etree.ElementTree import ElementTree
from xml.etree.ElementTree import SubElement
@@ -45,6 +47,8 @@ def __init__(self, context, data):
class SubmitPost(Service):
+ fields = []
+
def __init__(self, context, request):
super().__init__(context, request)
@@ -65,6 +69,29 @@ def reply(self):
notify(PostEventService(self.context, self.form_data))
+ # Construct self.fieldss
+ fields_data = []
+ for submitted_field in self.form_data.get("data", []):
+ # TODO: Review if fields submitted without a field_id should be included. Is breaking change if we remove it
+ if submitted_field.get("field_id") is None:
+ fields_data.append(submitted_field)
+ continue
+ for field in self.block.get("subblocks", []):
+ if field.get("id", field.get("field_id")) == submitted_field.get(
+ "field_id"
+ ):
+ fields_data.append(
+ {
+ **field,
+ **submitted_field,
+ "display_value_mapping": field.get("display_values"),
+ "custom_field_id": self.block.get(field["field_id"]),
+ }
+ )
+ self.fields = construct_fields(fields_data)
+ for field in self.fields:
+ field.validate(request=self.request)
+
if send_action:
try:
self.send_data()
@@ -169,31 +196,28 @@ def validate_form(self):
name=self.block["captcha"],
).verify(self.form_data.get("captcha"))
- self.validate_email_fields()
self.validate_bcc()
- def validate_email_fields(self):
- email_fields = [
- x.get("field_id", "")
- for x in self.block.get("subblocks", [])
- if x.get("field_type", "") == "from"
- ]
- for form_field in self.form_data.get("data", []):
- if form_field.get("field_id", "") not in email_fields:
+ def validate_bcc(self):
+ bcc_fields = []
+ for field in self.block.get("subblocks", []):
+ if field.get("use_as_bcc", False):
+ field_id = field.get("field_id", "")
+ if field_id not in bcc_fields:
+ bcc_fields.append(field_id)
+
+ for data in self.form_data.get("data", []):
+ value = data.get("value", "")
+ if not value:
continue
- if _isemail(form_field.get("value", "")) is None:
- raise BadRequest(
- translate(
- _(
- "wrong_email",
- default='Email not valid in "${field}" field.',
- mapping={
- "field": form_field.get("label", ""),
- },
- ),
- context=self.request,
+
+ if data.get("field_id", "") in bcc_fields:
+ if not validate_email_token(
+ self.form_data.get("block_id", ""), data["value"], data["otp"]
+ ):
+ raise BadRequest(
+ _("{email}'s OTP is wrong").format(email=data["value"])
)
- )
def validate_attachments(self):
attachments_limit = os.environ.get("FORM_ATTACHMENTS_LIMIT", "")
@@ -225,27 +249,6 @@ def validate_attachments(self):
)
)
- def validate_bcc(self):
- bcc_fields = []
- for field in self.block.get("subblocks", []):
- if field.get("use_as_bcc", False):
- field_id = field.get("field_id", "")
- if field_id not in bcc_fields:
- bcc_fields.append(field_id)
-
- for data in self.form_data.get("data", []):
- value = data.get("value", "")
- if not value:
- continue
-
- if data.get("field_id", "") in bcc_fields:
- if not validate_email_token(
- self.form_data.get("block_id", ""), data["value"], data["otp"]
- ):
- raise BadRequest(
- _("{email}'s OTP is wrong").format(email=data["value"])
- )
-
def get_block_data(self, block_id):
blocks = get_blocks(self.context)
if not blocks:
@@ -422,16 +425,7 @@ def filter_parameters(self):
"""
do not send attachments fields.
"""
- skip_fields = [
- x.get("field_id", "")
- for x in self.block.get("subblocks", [])
- if x.get("field_type", "") == "attachment"
- ]
- return [
- x
- for x in self.form_data.get("data", [])
- if x.get("field_id", "") not in skip_fields
- ]
+ return [field for field in self.fields if field.send_in_email]
def send_mail(self, msg, charset):
host = api.portal.get_tool(name="MailHost")
@@ -484,9 +478,9 @@ def attach_xml(self, msg):
xmlRoot = Element("form")
for field in self.filter_parameters():
- SubElement(
- xmlRoot, "field", name=field.get("custom_field_id", field["label"])
- ).text = str(field.get("value", ""))
+ SubElement(xmlRoot, "field", name=field.field_id).text = str(
+ field.internal_value
+ )
doc = ElementTree(xmlRoot)
doc.write(output, encoding="utf-8", xml_declaration=True)
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 0363b7bb..6e1169e9 100644
--- a/src/collective/volto/formsupport/tests/test_send_action_form.py
+++ b/src/collective/volto/formsupport/tests/test_send_action_form.py
@@ -3,6 +3,7 @@
)
from collective.volto.formsupport.utils import generate_email_token
from email.parser import Parser
+from io import StringIO
from plone import api
from plone.app.testing import setRoles
from plone.app.testing import SITE_OWNER_NAME
@@ -1075,19 +1076,204 @@ def test_email_body_formated_as_list(
self.assertIn("Message: just want to say hi", msg)
self.assertIn("Name: John", msg)
+ def test_field_custom_display_value(
+ self,
+ ):
+ self.document.blocks = {
+ "form-id": {
+ "@type": "form",
+ "send": True,
+ "email_format": "list",
+ "subblocks": [
+ {
+ "field_id": "12345678",
+ "display_values": {"John": "Paul"},
+ },
+ {
+ "field_id": "000000002",
+ "field_type": "yes_no",
+ "widget": "single_choice",
+ "display_values": {"yes": "Correct", "no": "Incorrect"},
+ },
+ {
+ "label": "Yes/ no radio without display value",
+ "field_id": "000000003",
+ "field_type": "yes_no",
+ "widget": "single_choice",
+ },
+ {
+ "label": "My attachment field",
+ "field_id": "000000004",
+ "field_type": "attachment",
+ },
+ ],
+ }
+ }
+ transaction.commit()
+
+ filename = os.path.join(os.path.dirname(__file__), "file.pdf")
+ with open(filename, "rb") as f:
+ file_str = f.read()
+ response = self.submit_form(
+ data={
+ "from": "john@doe.com",
+ "data": [
+ {"label": "Message", "value": "just want to say hi"},
+ {
+ "label": "Name",
+ "field_id": "12345678",
+ "value": "John",
+ },
+ {
+ "label": "Yes/ no",
+ "field_id": "000000002",
+ "value": True,
+ },
+ {
+ "field_id": "000000003",
+ "value": True,
+ },
+ {
+ "field_id": "000000004",
+ "value": "Attachments don't work this way normally, this is just to test",
+ },
+ ],
+ "attachments": {"foo": {"data": base64.b64encode(file_str)}},
+ "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")
+ self.assertIn("Subject: test subject", msg)
+ self.assertIn("From: john@doe.com", msg)
+ self.assertIn("To: site_addr@plone.com", msg)
+ self.assertIn("Reply-To: john@doe.com", msg)
+ self.assertIn("Message: just want to say hi", msg)
+ self.assertIn("Name: Paul", msg)
+ self.assertIn("Yes/ no: Correct", msg)
+ self.assertIn("Yes/ no radio without display value: True", msg)
+ self.assertNotIn(
+ "My attachment field:",
+ Parser()
+ .parse(StringIO(msg))
+ .get_payload()[0]
+ .get_payload(), # 1st get_payload splits the messages. First message is the body and second is the attachment
+ )
+ self.assertNotIn("foo", msg)
+
+ # breakpoint()
+
+ response = self.submit_form(
+ data={
+ "from": "john@doe.com",
+ "data": [
+ {
+ "label": "Yes/ no",
+ "field_id": "000000002",
+ "value": False,
+ },
+ {
+ "field_id": "000000003",
+ "value": False,
+ },
+ ],
+ "subject": "test subject",
+ "block_id": "form-id",
+ },
+ )
+ transaction.commit()
+
+ msg = self.mailhost.messages[1]
+ if isinstance(msg, bytes) and bytes is not str:
+ # Python 3 with Products.MailHost 4.10+
+ msg = msg.decode("utf-8")
+ self.assertIn("Yes/ no: Incorrect", msg)
+ self.assertIn(
+ "Yes/ no radio without display value: False", msg
+ )
+
+ def test_send_custom_field_id(self):
+ """Custom field IDs should still appear as their friendly names in the email"""
+ self.document.blocks = {
+ "form-id": {
+ "@type": "form",
+ "send": True,
+ "internal_mapped_name": "renamed-internal_mapped_name",
+ "subblocks": [
+ {
+ "field_id": "internal_mapped_name",
+ "label": "Name with internal mapping",
+ "field_type": "text",
+ },
+ ],
+ },
+ }
+ transaction.commit()
+
+ form_data = [
+ {"label": "Name", "value": "John"},
+ {
+ "label": "Other name",
+ "value": "Test",
+ "custom_field_id": "My custom field id",
+ },
+ {
+ "field_id": "internal_mapped_name",
+ "value": "Test",
+ },
+ ]
+
+ 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))
+ body = parsed_msgs.get_payload()
+
+ self.assertIn("Name", body)
+ self.assertIn("John", body)
+ self.assertNotIn("My custom field id", body)
+ self.assertIn("Other name", body)
+ self.assertIn("Test", body)
+ self.assertIn("Name with internal mapping", body)
+
def test_send_xml(self):
self.document.blocks = {
"form-id": {
"@type": "form",
"send": True,
"attachXml": True,
+ "custom_name": "renamed_custom_name",
"subblocks": [
{
"field_id": "message",
+ "label": "Message",
"field_type": "text",
},
{
"field_id": "name",
+ "label": "Name",
+ "field_type": "text",
+ },
+ {
+ "field_id": "custom_name",
+ "label": "Name",
"field_type": "text",
},
],
@@ -1098,6 +1284,7 @@ def test_send_xml(self):
form_data = [
{"field_id": "message", "label": "Message", "value": "just want to say hi"},
{"field_id": "name", "label": "Name", "value": "John"},
+ {"field_id": "name", "label": "Name", "value": "Test"},
]
response = self.submit_form(
@@ -1121,7 +1308,11 @@ def test_send_xml(self):
xml_tree = ET.fromstring(msg_contents)
for index, field in enumerate(xml_tree):
- self.assertEqual(field.get("name"), form_data[index]["label"])
+ custom_field_id = form_data[index].get("custom_field_id")
+ self.assertEqual(
+ field.get("name"),
+ custom_field_id if custom_field_id else form_data[index]["field_id"],
+ )
self.assertEqual(field.text, form_data[index]["value"])
def test_submit_return_400_if_malformed_email_in_email_field(
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 5bf076d6..cc4225fc 100644
--- a/src/collective/volto/formsupport/tests/test_store_action_form.py
+++ b/src/collective/volto/formsupport/tests/test_store_action_form.py
@@ -127,6 +127,7 @@ def test_store_data(self):
"label": "Name",
"field_id": "name",
"field_type": "text",
+ "display_values": "Custom name",
},
],
},
@@ -305,8 +306,69 @@ def test_data_id_mapping(self):
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"])
+ # Check that 'test-field' got correctly mapped to it's label
+ self.assertEqual(data[0], ["Message", "Test 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))
+
+ def test_display_values(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",
+ "display_values": {"John": "Paul", "Sally": "Jack"},
+ },
+ ],
+ },
+ }
+ 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 correctly mapped to it's label
+ self.assertEqual(data[0], ["Message", "Test 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"])