diff --git a/.coveragerc b/.coveragerc index acf9b175..6feee2a9 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 70b8a232..04384e0e 100644 --- a/README.rst +++ b/README.rst @@ -258,6 +258,13 @@ This add-on can be seen in action at the following sites: - https://www.comune.modena.it/form/contatti +Custom label mapping +========================= + +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/base.cfg b/base.cfg index db51faf9..32e0e3d8 100644 --- a/base.cfg +++ b/base.cfg @@ -6,7 +6,7 @@ extensions = parts = instance test - code-analysis + # code-analysis coverage test-coverage createcoverage @@ -33,6 +33,7 @@ eggs = Plone Pillow collective.volto.formsupport [test] + collective.volto.formsupport [validation] zcml-additional += =2.1", ], + "validation": [ + "Products.validation", + "z3c.form" + ], "test": [ "plone.app.testing", # Plone KGS does not use this version, because it would break diff --git a/src/collective/volto/formsupport/configure.zcml b/src/collective/volto/formsupport/configure.zcml index 36f6b34e..2258e98b 100644 --- a/src/collective/volto/formsupport/configure.zcml +++ b/src/collective/volto/formsupport/configure.zcml @@ -22,6 +22,7 @@ + + diff --git a/src/collective/volto/formsupport/restapi/deserializer/__init__.py b/src/collective/volto/formsupport/restapi/deserializer/__init__.py new file mode 100644 index 00000000..b1b8e6c9 --- /dev/null +++ b/src/collective/volto/formsupport/restapi/deserializer/__init__.py @@ -0,0 +1,44 @@ +from plone.base.interfaces import IPloneSiteRoot +from plone.restapi.behaviors import IBlocks +from plone.restapi.interfaces import IBlockFieldDeserializationTransformer +from zope.component import adapter +from zope.interface import implementer +from zope.publisher.interfaces.browser import IBrowserRequest + + +@adapter(IBlocks, IBrowserRequest) +class FormBlockDeserializerBase: + block_type = "form" + order = 100 + + def __init__(self, context, request): + self.context = context + self.request = request + + def __call__(self, block): + return self._process_data(block) + + def _process_data( + self, + data, + ): + self._update_validations(data) + return data + + def _update_validations(self, data): + for field in data.get("subblocks"): + if field.get("field_type") not in ["text", "textarea", "from"]: + field["validations"] = [] + field["validationSettings"] = {} + + +@implementer(IBlockFieldDeserializationTransformer) +@adapter(IBlocks, IBrowserRequest) +class FormBlockDeserializer(FormBlockDeserializerBase): + """Serializer for content-types with IBlocks behavior""" + + +@implementer(IBlockFieldDeserializationTransformer) +@adapter(IPloneSiteRoot, IBrowserRequest) +class FormBlockDeserializerRoot(FormBlockDeserializerBase): + """Serializer for site root""" diff --git a/src/collective/volto/formsupport/restapi/deserializer/configure.zcml b/src/collective/volto/formsupport/restapi/deserializer/configure.zcml new file mode 100644 index 00000000..885cd6a4 --- /dev/null +++ b/src/collective/volto/formsupport/restapi/deserializer/configure.zcml @@ -0,0 +1,16 @@ + + + + + + diff --git a/src/collective/volto/formsupport/restapi/serializer/blocks.py b/src/collective/volto/formsupport/restapi/serializer/blocks.py index 90771487..895b1f81 100644 --- a/src/collective/volto/formsupport/restapi/serializer/blocks.py +++ b/src/collective/volto/formsupport/restapi/serializer/blocks.py @@ -1,15 +1,18 @@ # -*- coding: utf-8 -*- -from collective.volto.formsupport.interfaces import ICaptchaSupport -from collective.volto.formsupport.interfaces import ICollectiveVoltoFormsupportLayer +import os + from plone import api from plone.restapi.behaviors import IBlocks from plone.restapi.interfaces import IBlockFieldSerializationTransformer from Products.CMFPlone.interfaces import IPloneSiteRoot -from zope.component import adapter -from zope.component import getMultiAdapter +from zope.component import adapter, getMultiAdapter from zope.interface import implementer -import os +from collective.volto.formsupport.interfaces import ( + ICaptchaSupport, + ICollectiveVoltoFormsupportLayer, +) +from collective.volto.formsupport.validation import get_validation_information class FormSerializer(object): @@ -37,6 +40,11 @@ def __call__(self, value): attachments_limit = os.environ.get("FORM_ATTACHMENTS_LIMIT", "") if attachments_limit: value["attachments_limit"] = attachments_limit + + # Add information on the settings for validations to the response + validation_settings = get_validation_information() + value["validationSettings"] = validation_settings + if api.user.has_permission("Modify portal content", obj=self.context): return value return {k: v for k, v in value.items() if not k.startswith("default_")} 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..49284270 --- /dev/null +++ b/src/collective/volto/formsupport/restapi/services/submit_form/field.py @@ -0,0 +1,158 @@ +import re +from typing import Any + +from collective.volto.formsupport.validation import getValidations + +validation_message_matcher = re.compile("Validation failed\(([^\)]+)\): ") + + +def always(): + return True + + +def value_is(value, target_value): + if isinstance(target_value, list): + return value in target_value + return value == target_value + + +def value_is_not(value, target_value): + if isinstance(target_value, list): + return value not in target_value + return value != target_value + + +show_when_validators = { + "": always, + "always": always, + "value_is": value_is, + "value_is_not": value_is_not, +} + + +class Field: + def __init__(self, field_data: dict[str, Any]): + def _attribute(attribute_name: str): + 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("widget") + _attribute("use_as_reply_to") + _attribute("use_as_reply_bcc") + self.required = field_data.get("required") + self.validations = field_data.get("validations") + self._display_value_mapping = field_data.get("dislpay_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 value(self): + if self._display_value_mapping: + return self._display_value_mapping.get(self._value, self._value) + return self._value + + @value.setter + def value(self, value): + self._value = value + + def should_show(self, show_when_is, target_value): + always_show_validator = show_when_validators["always"] + if not show_when_is: + return always_show_validator() + show_when_validator = show_when_validators[show_when_is] + if not show_when_validator: + return always_show_validator + return show_when_validator(value=self.value, target_value=target_value) + + @property + def label(self): + return self._label if self._label else self.field_id + + @label.setter + def label(self, label): + self._label = label + + @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 + + @field_id.setter + def field_id(self, field_id): + self._field_id = field_id + + @property + def send_in_email(self): + return True + + def validate(self): + # Making sure we've got a validation that actually exists. + if not self._value and not self.required: + return + errors = {} + + if self.required and not self.internal_value: + errors['required'] = 'This field is required' + + available_validations = [ + validation + for validationId, validation in getValidations() + if validationId in self.validations.keys() + ] + for validation in available_validations: + error = validation(self._value, **self.validations.get(validation._name)) + if error: + match_result = validation_message_matcher.match(error) + # We should be able to clean up messages that follow the + # `Validation failed({validation_id}): {message}` pattern. + # No guarantees we will encounter it though. + if match_result: + error = validation_message_matcher.sub("", error) + + errors[validation._name] = error + + return ( + errors if errors else None + ) # Return None to match how errors normally return in z3c.form + + +class YesNoField(Field): + @property + def value(self): + if self._display_value_mapping: + if self._value is True: + return self._display_value_mapping.get("yes") + elif self._value is False: + return self._display_value_mapping.get("no") + return self._value + + @property + def send_in_email(self): + return True + + +class AttachmentField(Field): + @property + def send_in_email(self): + return False + + +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) + + 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 ae03d2ff..b8cf6100 100644 --- a/src/collective/volto/formsupport/restapi/services/submit_form/post.py +++ b/src/collective/volto/formsupport/restapi/services/submit_form/post.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- + import codecs import logging import math @@ -13,6 +14,7 @@ from plone.protect.interfaces import IDisableCSRFProtection from plone.registry.interfaces import IRegistry from plone.restapi.deserializer import json_body +from plone.restapi.serializer.converters import json_compatible from plone.restapi.services import Service from Products.CMFPlone.interfaces.controlpanel import IMailSchema from zExceptions import BadRequest @@ -27,6 +29,9 @@ IFormDataStore, IPostEvent, ) +from collective.volto.formsupport.restapi.services.submit_form.field import ( + construct_fields, +) from collective.volto.formsupport.utils import get_blocks logger = logging.getLogger(__name__) @@ -41,6 +46,8 @@ def __init__(self, context, data): class SubmitPost(Service): + fields = [] + def __init__(self, context, request): super(SubmitPost, self).__init__(context, request) @@ -61,6 +68,77 @@ 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" + ): + validation_ids_to_apply = field.get("validations", []) + validations_for_field = {} + for validation_and_setting_id, setting_value in field.get( + "validationSettings", {} + ).items(): + split_validation_and_setting_ids = validation_and_setting_id.split("-") + if len(split_validation_and_setting_ids) < 2: + continue + validation_id, setting_id = split_validation_and_setting_ids + if validation_id not in validation_ids_to_apply: + continue + if validation_id not in validations_for_field: + validations_for_field[validation_id] = {} + validations_for_field[validation_id][setting_id] = setting_value + fields_data.append( + { + **field, + **submitted_field, + "display_value_mapping": field.get("display_values"), + "custom_field_id": self.block.get(field["field_id"]), + # We're straying from how validations are serialized and deserialized here to make our lives easier. + # Let's use a dictionary of {'validation_id': {'setting_id': 'setting_value'}} when working inside fields for simplicity. + "validations": validations_for_field, + } + ) + self.fields = construct_fields(fields_data) + + errors = {} + for field in self.fields: + show_when = field.show_when_when + should_show = True + if show_when and show_when != "always": + target_field = [ + val for val in self.fields if val.id == field.show_when_when + ][0] + should_show = ( + target_field.should_show( + show_when_is=field.show_when_is, target_value=field.show_when_to + ) + if target_field + else True + ) + + if should_show: + field_errors = field.validate() + + if field_errors: + errors[field.field_id] = field_errors + + if errors: + self.request.response.setStatus(400) + return json_compatible( + { + "error": { + "type": "Invalid", + "errors": errors, + } + } + ) + if send_action: try: self.send_data() @@ -332,16 +410,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") @@ -394,9 +463,7 @@ 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._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 010147a7..9a2d89f9 100644 --- a/src/collective/volto/formsupport/tests/test_send_action_form.py +++ b/src/collective/volto/formsupport/tests/test_send_action_form.py @@ -1,24 +1,27 @@ # -*- coding: utf-8 -*- -from collective.volto.formsupport.testing import ( # noqa: E501, - VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING, -) +import base64 +import os +import unittest +import xml.etree.ElementTree as ET from email.parser import Parser + +import transaction from plone import api -from plone.app.testing import setRoles -from plone.app.testing import SITE_OWNER_NAME -from plone.app.testing import SITE_OWNER_PASSWORD -from plone.app.testing import TEST_USER_ID +from plone.app.testing import ( + SITE_OWNER_NAME, + SITE_OWNER_PASSWORD, + TEST_USER_ID, + setRoles, +) from plone.registry.interfaces import IRegistry from plone.restapi.testing import RelativeSession from Products.MailHost.interfaces import IMailHost from six import StringIO from zope.component import getUtility -import base64 -import os -import transaction -import unittest -import xml.etree.ElementTree as ET +from collective.volto.formsupport.testing import ( # noqa: E501, + VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING, +) class TestMailSend(unittest.TestCase): @@ -795,15 +798,140 @@ 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"}, + }, + ], + } + } + transaction.commit() + + 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", + }, + ], + "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) + + 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}, + "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", + }, + ], + }, } transaction.commit() form_data = [ {"label": "Message", "value": "just want to say hi"}, {"label": "Name", "value": "John"}, + {"label": "Name", "value": "Test"}, ] response = self.submit_form( @@ -826,5 +954,9 @@ def test_send_xml(self): 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"]) + 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]["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 fd0af723..a55045f8 100644 --- a/src/collective/volto/formsupport/tests/test_store_action_form.py +++ b/src/collective/volto/formsupport/tests/test_store_action_form.py @@ -1,21 +1,24 @@ # -*- coding: utf-8 -*- -from collective.volto.formsupport.testing import ( # noqa: E501, - VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING, -) +import csv +import unittest from datetime import datetime from io import StringIO + +import transaction from plone import api -from plone.app.testing import setRoles -from plone.app.testing import SITE_OWNER_NAME -from plone.app.testing import SITE_OWNER_PASSWORD -from plone.app.testing import TEST_USER_ID +from plone.app.testing import ( + SITE_OWNER_NAME, + SITE_OWNER_PASSWORD, + TEST_USER_ID, + setRoles, +) from plone.restapi.testing import RelativeSession from Products.MailHost.interfaces import IMailHost from zope.component import getUtility -import csv -import transaction -import unittest +from collective.volto.formsupport.testing import ( # noqa: E501, + VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING, +) class TestMailSend(unittest.TestCase): @@ -122,6 +125,7 @@ def test_store_data(self): "label": "Name", "field_id": "name", "field_type": "text", + "display_values": "Custom name", }, ], }, @@ -300,8 +304,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"]) diff --git a/src/collective/volto/formsupport/tests/test_validation.py b/src/collective/volto/formsupport/tests/test_validation.py new file mode 100644 index 00000000..91d083cc --- /dev/null +++ b/src/collective/volto/formsupport/tests/test_validation.py @@ -0,0 +1,141 @@ +# -*- coding: utf-8 -*- +import os +import unittest + +import transaction +from plone import api +from plone.app.testing import ( + SITE_OWNER_NAME, + SITE_OWNER_PASSWORD, + TEST_USER_ID, + setRoles, +) +from plone.registry.interfaces import IRegistry +from plone.restapi.testing import RelativeSession +from Products.MailHost.interfaces import IMailHost +from zope.component import getUtility + +from collective.volto.formsupport.testing import ( # noqa: E501, + VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING, +) +from collective.volto.formsupport.validation import getValidations + + +class TestMailSend(unittest.TestCase): + layer = VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING + + def setUp(self): + self.app = self.layer["app"] + self.portal = self.layer["portal"] + self.portal_url = self.portal.absolute_url() + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + self.mailhost = getUtility(IMailHost) + + registry = getUtility(IRegistry) + registry["plone.email_from_address"] = "site_addr@plone.com" + registry["plone.email_from_name"] = "Plone test site" + + self.api_session = RelativeSession(self.portal_url) + self.api_session.headers.update({"Accept": "application/json"}) + self.api_session.auth = (SITE_OWNER_NAME, SITE_OWNER_PASSWORD) + self.anon_api_session = RelativeSession(self.portal_url) + self.anon_api_session.headers.update({"Accept": "application/json"}) + + self.document = api.content.create( + type="Document", + title="Example context", + container=self.portal, + ) + self.document.blocks = { + "text-id": {"@type": "text"}, + "form-id": {"@type": "form"}, + } + self.document_url = self.document.absolute_url() + transaction.commit() + + def tearDown(self): + self.api_session.close() + self.anon_api_session.close() + + # set default block + self.document.blocks = { + "text-id": {"@type": "text"}, + "form-id": {"@type": "form"}, + } + + os.environ["FORM_ATTACHMENTS_LIMIT"] = "" + + transaction.commit() + + def submit_form(self, data): + url = "{}/@submit-form".format(self.document_url) + response = self.api_session.post( + url, + json=data, + ) + transaction.commit() + return response + + def test_validation(self): + self.document.blocks = { + "text-id": {"@type": "text"}, + "form-id": { + "@type": "form", + "default_subject": "block subject", + "default_from": "john@doe.com", + "send": ["acknowledgement"], + "acknowledgementFields": "contact", + "acknowledgementMessage": { + "data": "

This message will be sent to the person filling in the form.

It is Rich Text

" + }, + "subblocks": [ + { + "field_id": "123456789", + "field_type": "text", + "id": "123456789", + "inNumericRange": {"maxval": "7", "minval": "2"}, + "label": "My field", + "required": False, + "show_when_when": "always", + "validationSettings": {"maxval": "7", "minval": "2"}, + "validations": ["inNumericRange"], + } + ], + }, + } + transaction.commit() + + response = self.api_session.get(self.document_url) + res = response.json() + + validations = getValidations() + # breakpoint() + # self.assertEqual(res["blocks"]["form-id"], self.document.blocks["form-id"]) + + # response = self.submit_form( + # data={ + # "data": [{"field_id": "123456789", "value": "4", "label": "My field"}], + # "block_id": "form-id", + # }, + # ) + # transaction.commit() + + # breakpoint() + # 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_msg = Parser().parse(StringIO(msg)) + # self.assertEqual(parsed_msg.get("from"), "john@doe.com") + # self.assertEqual(parsed_msg.get("to"), "smith@doe.com") + # self.assertEqual(parsed_msg.get("subject"), "block subject") + # msg_body = parsed_msg.get_payload(decode=True).decode() + # self.assertIn( + # "

This message will be sent to the person filling in the form.

", + # msg_body, + # ) + # self.assertIn("

It is Rich Text

", msg_body) diff --git a/src/collective/volto/formsupport/validation/__init__.py b/src/collective/volto/formsupport/validation/__init__.py new file mode 100644 index 00000000..755db221 --- /dev/null +++ b/src/collective/volto/formsupport/validation/__init__.py @@ -0,0 +1,113 @@ +# -*- coding: utf-8 -*- + +from zope.component import getUtilitiesFor, provideUtility +from zope.interface import Interface, provider +from zope.schema.interfaces import IVocabularyFactory +from zope.schema.vocabulary import SimpleVocabulary + + +try: + from Products.validation.validators.BaseValidators import baseValidators + from collective.volto.formsupport.validation.custom_validators import custom_validators + from collective.volto.formsupport.validation.definition import ValidationDefinition +except ImportError: # Products.validation is optional + validation = None + baseValidators = None + custom_validators = [] + + +IGNORED_VALIDATION_DEFINITION_ARGUMENTS = [ + "title", + "description", + "name", + "errmsg", + "regex", + "regex_strings", + "ignore", + "_internal_type", +] + +VALIDATIONS_TO_IGNORE = ["inNumericRange"] + + +class IFieldValidator(Interface): + """Base marker for collective.volto.formsupport field validators.""" + + +def _update_validators(): + """ + Add Products.validation validators to the available list of validators + Code taken from collective.easyform . Could lookup based on `IValidator` instead of re-registering? + """ + + if baseValidators: + for validator in baseValidators: + if validator.name in VALIDATIONS_TO_IGNORE: + continue + provideUtility( + ValidationDefinition(validator), + provides=IFieldValidator, + name=validator.name, + ) + for validator in custom_validators: + provideUtility( + ValidationDefinition(validator), + provides=IFieldValidator, + name=validator.name, + ) + + +_update_validators() + + +def getValidations(): + utils = getUtilitiesFor(IFieldValidator) + return utils + + +PYTHON_TYPE_SCHEMA_TYPE_MAPPING = { + "bool": "boolean", + "date": "date", + "dict": "obj", + "float": "number", + "int": "integer", + "list": "array", + "str": "string", + "time": "datetime", +} + + +def get_validation_information(): + """Adds the individual validation settings to the `validationSettings` key in the format `{validation_id}-{setting_name}`""" + settings_to_add = {} + + for validation_id, validation in getValidations(): + settings = validation.settings + if not isinstance(settings, dict) or not settings: + # We don't have any settings, skip including it + continue + cleaned_settings = { + setting_name: val + for setting_name, val in settings.items() + if setting_name not in IGNORED_VALIDATION_DEFINITION_ARGUMENTS + } + + for setting_id, setting_value in cleaned_settings.items(): + settings_to_add[f"{validation_id}-{setting_id}"] = { + "validation_title": getattr(settings, "title", validation_id), + "title": setting_id, + "type": PYTHON_TYPE_SCHEMA_TYPE_MAPPING.get( + type(setting_value).__name__, "string" + ), + "default": setting_value, + } + + return settings_to_add + + +@provider(IVocabularyFactory) +def ValidatorsVocabularyFactory(context, **rest): + """Field validators vocabulary""" + return SimpleVocabulary( + [SimpleVocabulary.createTerm(i, i, i) for i, u in getValidations()] + ) diff --git a/src/collective/volto/formsupport/validation/custom_validators/CharactersValidator.py b/src/collective/volto/formsupport/validation/custom_validators/CharactersValidator.py new file mode 100644 index 00000000..4a2b4bb6 --- /dev/null +++ b/src/collective/volto/formsupport/validation/custom_validators/CharactersValidator.py @@ -0,0 +1,35 @@ +from Products.validation.interfaces.IValidator import IValidator +from zope.interface import implementer + + +@implementer(IValidator) +class CharactersValidator: + def __init__(self, name, title="", description="", characters=0, _internal_type=""): + """ "Unused properties are for default values and type information""" + self.name = name + self.title = title or name + self.description = description + self._internal_type = _internal_type + # Default values + self.characters = characters + + def __call__(self, value="", *args, **kwargs): + characters = kwargs.get("characters", self.characters) + characters = int(characters) if isinstance(characters, str) else characters + + if self._internal_type == "max": + if not value: + return + if len(value) > characters: + # TODO: i18n + msg = f"Validation failed({self.name}): is more than {characters} characters long" + return msg + elif self._internal_type == "min": + if not value or len(value) < characters: + # TODO: i18n + msg = f"Validation failed({self.name}): is less than {characters} characters long" + return msg + else: + # TODO: i18n + msg = f"Validation failed({self.name}): Unknown characters validator type" + return msg diff --git a/src/collective/volto/formsupport/validation/custom_validators/WordsValidator.py b/src/collective/volto/formsupport/validation/custom_validators/WordsValidator.py new file mode 100644 index 00000000..8aae370d --- /dev/null +++ b/src/collective/volto/formsupport/validation/custom_validators/WordsValidator.py @@ -0,0 +1,47 @@ +import re + +from Products.validation.interfaces.IValidator import IValidator +from zope.interface import implementer + + +@implementer(IValidator) +class WordsValidator: + def __init__( + self, + name, + title="", + description="", + words=0, + _internal_type="", + ): + """ "Unused properties are for default values and type information""" + self.name = name + self.title = title or name + self.description = description + self._internal_type = _internal_type + # Default values + self.words = words + + def __call__(self, value="", *args, **kwargs): + words = kwargs.get("words", self.words) + words = int(words) + count = len(re.findall(r"\w+", value)) + + if self._internal_type == "max": + if not value: + return + if count > words: + # TODO: i18n + msg = f"Validation failed({self.name}): is more than {words} words long" + return msg + elif self._internal_type == "min": + if not value or count < words: + # TODO: i18n + msg = f"Validation failed({self.name}): is less than {words} words long" + return msg + elif self._internal_type == "test": + pass + else: + # TODO: i18n + msg = f"Validation failed({self.name}): Unknown words validator type" + return msg diff --git a/src/collective/volto/formsupport/validation/custom_validators/__init__.py b/src/collective/volto/formsupport/validation/custom_validators/__init__.py new file mode 100644 index 00000000..4aa56914 --- /dev/null +++ b/src/collective/volto/formsupport/validation/custom_validators/__init__.py @@ -0,0 +1,12 @@ +from collective.volto.formsupport.validation.custom_validators.CharactersValidator import ( + CharactersValidator, +) +from collective.volto.formsupport.validation.custom_validators.WordsValidator import ( + WordsValidator, +) + +maxCharacters = CharactersValidator("maxCharacters", _internal_type="max") +minCharacters = CharactersValidator("minCharacters", _internal_type="min") +maxWords = WordsValidator("maxWords", _internal_type="max") +minWords = WordsValidator("minWords", _internal_type="min") +custom_validators = [maxCharacters, minCharacters, maxWords, minWords] diff --git a/src/collective/volto/formsupport/validation/definition.py b/src/collective/volto/formsupport/validation/definition.py new file mode 100644 index 00000000..1ab07b64 --- /dev/null +++ b/src/collective/volto/formsupport/validation/definition.py @@ -0,0 +1,30 @@ +from Products.validation import validation + + +class ValidationDefinition: + def __init__(self, validator): + self._name = validator.name + self._settings = vars(validator) + # Make sure the validation service has the validator in it. + if not validation._validator.get(validator.name): + validation.register(validator) + + def __call__(self, value, **kwargs): + """Allow using the class directly as a validator""" + return self.validate(value=value, **kwargs) + + @property + def settings(self): + return self._settings + + @settings.setter + def settings(self, value): + self._settings = value + + def validate(self, value, **kwargs): + if value is None: + # Let the system for required take care of None values + return + res = validation(self._name, value, **kwargs) + if res != 1: + return res diff --git a/src/collective/volto/formsupport/validation/validation.zcml b/src/collective/volto/formsupport/validation/validation.zcml new file mode 100644 index 00000000..ed430f42 --- /dev/null +++ b/src/collective/volto/formsupport/validation/validation.zcml @@ -0,0 +1,10 @@ + + + + + + +