From 4e9c9f9394b5e0762543a80888ee639392fb860b Mon Sep 17 00:00:00 2001 From: Andrea Cecchi Date: Fri, 25 Mar 2022 11:31:45 +0100 Subject: [PATCH 01/70] Support for user_as_bcc field in volto-form-block: send a separate mail for each email field with that flag --- CHANGES.rst | 3 +- .../restapi/services/submit_form/post.py | 23 +++++++++ .../tests/test_send_action_form.py | 47 +++++++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index a4b6f960..f40c33ce 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,7 +4,8 @@ Changelog 2.0.4 (unreleased) ------------------ -- Nothing changed yet. +- Support for user_as_bcc field in volto-form-block: send a separate mail for each email field with that flag. + [cekk] 2.0.3 (2021-10-25) 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 97b368d2..cb8390c1 100644 --- a/src/collective/volto/formsupport/restapi/services/submit_form/post.py +++ b/src/collective/volto/formsupport/restapi/services/submit_form/post.py @@ -129,6 +129,23 @@ def get_reply_to(self): return self.form_data.get("from", "") or self.block.get("default_from", "") + def get_bcc(self): + bcc = [] + 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) + bcc = [] + for data in self.form_data.get("data", []): + value = data.get("value", "") + if not value: + continue + if data.get("field_id", "") in bcc_fields: + bcc.append(data["value"]) + return bcc + def send_data(self): subject = self.form_data.get("subject", "") or self.block.get( "default_subject", "" @@ -167,12 +184,18 @@ def send_data(self): msg["From"] = mfrom msg["To"] = mto msg["Reply-To"] = mreply_to + msg.replace_header("Content-Type", 'text/html; charset="utf-8"') self.manage_attachments(msg=msg) self.send_mail(msg=msg, encoding=encoding) + for bcc in self.get_bcc(): + # send a copy also to the fields with bcc flag + msg.replace_header("To", bcc) + self.send_mail(msg=msg, encoding=encoding) + def prepare_message(self): message_template = api.content.get_view( name="send_mail_template", 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 b6864cc2..adbaebd6 100644 --- a/src/collective/volto/formsupport/tests/test_send_action_form.py +++ b/src/collective/volto/formsupport/tests/test_send_action_form.py @@ -366,3 +366,50 @@ def test_email_with_use_as_reply_to( self.assertIn("Reply-To: smith@doe.com", msg) self.assertIn("Message: just want to say hi", msg) self.assertIn("Name: Smith", msg) + + def test_email_field_used_as_bcc( + self, + ): + + self.document.blocks = { + "text-id": {"@type": "text"}, + "form-id": { + "@type": "form", + "default_subject": "block subject", + "default_from": "john@doe.com", + "send": True, + "subblocks": [ + { + "field_id": "contact", + "field_type": "from", + "use_as_bcc": True, + }, + ], + }, + } + transaction.commit() + + response = self.submit_form( + data={ + "data": [ + {"label": "Message", "value": "just want to say hi"}, + {"label": "Name", "value": "Smith"}, + {"field_id": "contact", "label": "Email", "value": "smith@doe.com"}, + ], + "block_id": "form-id", + }, + ) + transaction.commit() + + self.assertEqual(response.status_code, 204) + self.assertEqual(len(self.mailhost.messages), 2) + msg = self.mailhost.messages[0] + bcc_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") + bcc_msg = bcc_msg.decode("utf-8") + self.assertIn("To: site_addr@plone.com", msg) + self.assertNotIn("To: smith@doe.com", msg) + self.assertNotIn("To: site_addr@plone.com", bcc_msg) + self.assertIn("To: smith@doe.com", bcc_msg) From fd89c1fb33aecc70f7fea15f38eccc08f63f7f2c Mon Sep 17 00:00:00 2001 From: Andrea Cecchi Date: Fri, 25 Mar 2022 13:55:51 +0100 Subject: [PATCH 02/70] Preparing release 2.1.0 --- CHANGES.rst | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index f40c33ce..74747dd1 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,7 +1,7 @@ Changelog ========= -2.0.4 (unreleased) +2.1.0 (2022-03-25) ------------------ - Support for user_as_bcc field in volto-form-block: send a separate mail for each email field with that flag. diff --git a/setup.py b/setup.py index 801da9f4..3a1ec5ca 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ setup( name="collective.volto.formsupport", - version="2.0.4.dev0", + version="2.1.0", description="Add support for customizable forms in Volto", long_description=long_description, # Get more from https://pypi.org/classifiers/ From 20ed007abaf06ae59ab43ac1e232111e22463747 Mon Sep 17 00:00:00 2001 From: Andrea Cecchi Date: Fri, 25 Mar 2022 13:56:36 +0100 Subject: [PATCH 03/70] Back to development: 2.1.1 --- CHANGES.rst | 6 ++++++ setup.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 74747dd1..2520bf6e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,12 @@ Changelog ========= +2.1.1 (unreleased) +------------------ + +- Nothing changed yet. + + 2.1.0 (2022-03-25) ------------------ diff --git a/setup.py b/setup.py index 3a1ec5ca..e24505b4 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ setup( name="collective.volto.formsupport", - version="2.1.0", + version="2.1.1.dev0", description="Add support for customizable forms in Volto", long_description=long_description, # Get more from https://pypi.org/classifiers/ From f2441c9969871f3bf476fd5314fa982063487459 Mon Sep 17 00:00:00 2001 From: mamico Date: Tue, 29 Mar 2022 13:52:20 +0200 Subject: [PATCH 04/70] event --- CHANGES.rst | 4 ++-- setup.py | 2 +- src/collective/volto/formsupport/interfaces.py | 6 ++++++ .../formsupport/restapi/services/submit_form/post.py | 12 ++++++++++++ 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 2520bf6e..5c5c3692 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,10 +1,10 @@ Changelog ========= -2.1.1 (unreleased) +2.2.0 (unreleased) ------------------ -- Nothing changed yet. +- Notify an event on sumbit. 2.1.0 (2022-03-25) diff --git a/setup.py b/setup.py index e24505b4..fbfc3fb3 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ setup( name="collective.volto.formsupport", - version="2.1.1.dev0", + version="2.2.0.dev0", description="Add support for customizable forms in Volto", long_description=long_description, # Get more from https://pypi.org/classifiers/ diff --git a/src/collective/volto/formsupport/interfaces.py b/src/collective/volto/formsupport/interfaces.py index cdd22fc2..b2e5e745 100644 --- a/src/collective/volto/formsupport/interfaces.py +++ b/src/collective/volto/formsupport/interfaces.py @@ -24,3 +24,9 @@ def search(self, query): """ @return: items that match query """ + + +class IPostEvent(Interface): + """ + Event fired when a form is submitted (before actions) + """ \ No newline at end of file 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 cb8390c1..a6c65a44 100644 --- a/src/collective/volto/formsupport/restapi/services/submit_form/post.py +++ b/src/collective/volto/formsupport/restapi/services/submit_form/post.py @@ -9,15 +9,25 @@ from zExceptions import BadRequest from zope.component import getMultiAdapter from zope.component import getUtility +from zope.interface import implementer from zope.interface import alsoProvides +from zope.event import notify from collective.volto.formsupport import _ from zope.i18n import translate from collective.volto.formsupport.interfaces import IFormDataStore +from collective.volto.formsupport.interfaces import IPostEvent import codecs import six +@implementer(IPostEvent) +class PostEventService(object): + def __init__(self, context, data): + self.context = context + self.data = data + + class SubmitPost(Service): def __init__(self, context, request): super(SubmitPost, self).__init__(context, request) @@ -37,6 +47,8 @@ def reply(self): # Disable CSRF protection alsoProvides(self.request, IDisableCSRFProtection) + notify(PostEventService(self.context, self.form_data)) + if store_action: self.store_data() if send_action: From 916d650cfa55df463014eff8834acf8897ae5810 Mon Sep 17 00:00:00 2001 From: mamico Date: Tue, 29 Mar 2022 14:00:15 +0200 Subject: [PATCH 05/70] black --- src/collective/volto/formsupport/interfaces.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/collective/volto/formsupport/interfaces.py b/src/collective/volto/formsupport/interfaces.py index b2e5e745..3178d800 100644 --- a/src/collective/volto/formsupport/interfaces.py +++ b/src/collective/volto/formsupport/interfaces.py @@ -29,4 +29,4 @@ def search(self, query): class IPostEvent(Interface): """ Event fired when a form is submitted (before actions) - """ \ No newline at end of file + """ From 8d2a4d323eefc24bc0953b69d4718f847a280460 Mon Sep 17 00:00:00 2001 From: mamico Date: Wed, 6 Apr 2022 01:17:43 +0200 Subject: [PATCH 06/70] test --- .../volto/formsupport/tests/test_event.py | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 src/collective/volto/formsupport/tests/test_event.py diff --git a/src/collective/volto/formsupport/tests/test_event.py b/src/collective/volto/formsupport/tests/test_event.py new file mode 100644 index 00000000..1d679cee --- /dev/null +++ b/src/collective/volto/formsupport/tests/test_event.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- +from collective.volto.formsupport.testing import ( # noqa: E501, + VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING, +) +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.registry.interfaces import IRegistry +from plone.restapi.testing import RelativeSession +from Products.MailHost.interfaces import IMailHost +import transaction +import unittest +from zope.component import getUtility +from zope.configuration import xmlconfig + + +def event_handler(event): + event.data["data"].append( + {"label": "Reply", "value": "hello"}, + ) + + +class TestEvent(unittest.TestCase): + + layer = VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING + + def setUp(self): + xmlconfig.string( + """ + + + + """, + context=self.layer["configurationContext"], + ) + 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"}, + } + 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_trigger_event( + self, + ): + + self.document.blocks = { + "text-id": {"@type": "text"}, + "form-id": { + "@type": "form", + "default_subject": "block subject", + "default_from": "john@doe.com", + "send": True, + "subblocks": [ + { + "field_id": "contact", + "field_type": "from", + "use_as_bcc": True, + }, + ], + }, + } + transaction.commit() + + response = self.submit_form( + data={ + "data": [ + {"label": "Message", "value": "just want to say hi"}, + ], + "block_id": "form-id", + }, + ) + transaction.commit() + + self.assertEqual(response.status_code, 204) + self.assertEqual(len(self.mailhost.messages), 1) + 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("To: site_addr@plone.com", msg) + self.assertNotIn("To: smith@doe.com", msg) + self.assertIn("Message: just want to say hi", msg) + self.assertIn("Reply: hello", msg) From 6232a358037bd013dc496254ad7d8d8d5954259c Mon Sep 17 00:00:00 2001 From: mamico Date: Wed, 6 Apr 2022 01:20:09 +0200 Subject: [PATCH 07/70] changes --- CHANGES.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGES.rst b/CHANGES.rst index 5c5c3692..6822aae8 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,7 @@ Changelog ------------------ - Notify an event on sumbit. + [mamico] 2.1.0 (2022-03-25) From cf619c05c48fc8bcff10862398e3e2c5103bf20f Mon Sep 17 00:00:00 2001 From: mamico Date: Thu, 7 Apr 2022 18:49:37 +0200 Subject: [PATCH 08/70] Preparing release 2.2.0 --- CHANGES.rst | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 6822aae8..b0393ed9 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,7 +1,7 @@ Changelog ========= -2.2.0 (unreleased) +2.2.0 (2022-04-07) ------------------ - Notify an event on sumbit. diff --git a/setup.py b/setup.py index fbfc3fb3..0d2dfc55 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ setup( name="collective.volto.formsupport", - version="2.2.0.dev0", + version="2.2.0", description="Add support for customizable forms in Volto", long_description=long_description, # Get more from https://pypi.org/classifiers/ From a88d6ad8afa81e1397da70022de2fe1c7d8b5740 Mon Sep 17 00:00:00 2001 From: mamico Date: Thu, 7 Apr 2022 18:52:05 +0200 Subject: [PATCH 09/70] Back to development: 2.2.1 --- CHANGES.rst | 6 ++++++ setup.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index b0393ed9..e7f44168 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,12 @@ Changelog ========= +2.2.1 (unreleased) +------------------ + +- Nothing changed yet. + + 2.2.0 (2022-04-07) ------------------ diff --git a/setup.py b/setup.py index 0d2dfc55..fe136e00 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ setup( name="collective.volto.formsupport", - version="2.2.0", + version="2.2.1.dev0", description="Add support for customizable forms in Volto", long_description=long_description, # Get more from https://pypi.org/classifiers/ From 24846b30f29e67ab54a82926ec082cc13d21498b Mon Sep 17 00:00:00 2001 From: mamico Date: Tue, 12 Apr 2022 19:31:15 +0200 Subject: [PATCH 10/70] captcha support --- .gitignore | 2 ++ CONTRIBUTORS.rst | 1 + README.rst | 4 ++- buildout.cfg | 8 ++++++ setup.py | 9 ++++++- .../volto/formsupport/captcha/__init__.py | 11 ++++++++ .../volto/formsupport/captcha/configure.zcml | 21 +++++++++++++++ .../volto/formsupport/captcha/hcaptcha.py | 27 +++++++++++++++++++ .../volto/formsupport/captcha/recaptcha.py | 27 +++++++++++++++++++ .../volto/formsupport/configure.zcml | 1 + .../volto/formsupport/interfaces.py | 10 +++++++ .../restapi/services/submit_form/post.py | 20 +++++--------- test_plone52.cfg | 6 +++++ 13 files changed, 131 insertions(+), 16 deletions(-) create mode 100644 src/collective/volto/formsupport/captcha/__init__.py create mode 100644 src/collective/volto/formsupport/captcha/configure.zcml create mode 100644 src/collective/volto/formsupport/captcha/hcaptcha.py create mode 100644 src/collective/volto/formsupport/captcha/recaptcha.py diff --git a/.gitignore b/.gitignore index 29bcf677..5a7f9a23 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ htmlcov/ include/ lib/ local/ +bin/ node_modules/ parts/ dist/* @@ -30,4 +31,5 @@ report.html .vscode/ .tox/ reports/ +pyvenv.cfg # excludes diff --git a/CONTRIBUTORS.rst b/CONTRIBUTORS.rst index 83798a99..6a11d607 100644 --- a/CONTRIBUTORS.rst +++ b/CONTRIBUTORS.rst @@ -2,3 +2,4 @@ Contributors ============ - RedTurtle Technology, sviluppo@redturtle.it +- Mauro Amico, mauro.amico@gmail.com \ No newline at end of file diff --git a/README.rst b/README.rst index aaa932fb..549e56c1 100644 --- a/README.rst +++ b/README.rst @@ -19,6 +19,8 @@ collective.volto.formsupport Add some helper routes and functionalities for Volto sites with ``form`` blocks provided by `volto-form-block `_ Volto plugin. +For captcha support `volto-form-block` version >= 2.4.0 is required. + plone.restapi endpoints ======================= @@ -45,7 +47,7 @@ Calling this endpoint, it will do some actions (based on block settings) and ret This is an expansion component. -There is a rule that returns a ``form-data`` item into "components" slot if the user can edit the +There is a rule that returns a ``form-data`` item into "components" slot if the user can edit the context (**Modify portal content** permission) and there is a block that can store data. Calling with "expand=true", this endpoint returns the stored data:: diff --git a/buildout.cfg b/buildout.cfg index 80d7a7fe..2303d6c3 100644 --- a/buildout.cfg +++ b/buildout.cfg @@ -7,3 +7,11 @@ extends = # test_plone50.cfg # test_plone51.cfg test_plone52.cfg + +[versions] +flake8 = 4.0.1 +pyflakes = 2.4.0 +pycodestyle = 2.8.0 +multipart = 0.2.4 +pyScss = 1.4.0 + diff --git a/setup.py b/setup.py index fe136e00..c0bee088 100644 --- a/setup.py +++ b/setup.py @@ -26,8 +26,9 @@ "Framework :: Plone :: Addon", "Framework :: Plone :: 5.2", "Programming Language :: Python", - "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", "Operating System :: OS Independent", "License :: OSI Approved :: GNU General Public License v2 (GPLv2)", ], @@ -58,6 +59,12 @@ "souper.plone", ], extras_require={ + "hcaptcha": [ + "plone.formwidget.hcaptcha", + ], + "recaptcha": [ + "plone.formwidget.recaptcha", + ], "test": [ "plone.app.testing", # Plone KGS does not use this version, because it would break diff --git a/src/collective/volto/formsupport/captcha/__init__.py b/src/collective/volto/formsupport/captcha/__init__.py new file mode 100644 index 00000000..5410e86a --- /dev/null +++ b/src/collective/volto/formsupport/captcha/__init__.py @@ -0,0 +1,11 @@ +class CaptchaSupport(object): + + def __init__(self, context, request) -> None: + self.context = context + self.request = request + + def verify(self) -> bool: + """ + Verify the captcha + """ + raise NotImplementedError diff --git a/src/collective/volto/formsupport/captcha/configure.zcml b/src/collective/volto/formsupport/captcha/configure.zcml new file mode 100644 index 00000000..6ddffcf7 --- /dev/null +++ b/src/collective/volto/formsupport/captcha/configure.zcml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/src/collective/volto/formsupport/captcha/hcaptcha.py b/src/collective/volto/formsupport/captcha/hcaptcha.py new file mode 100644 index 00000000..e36c2214 --- /dev/null +++ b/src/collective/volto/formsupport/captcha/hcaptcha.py @@ -0,0 +1,27 @@ +from . import CaptchaSupport +from plone.formwidget.hcaptcha.interfaces import IHCaptchaSettings +from plone.formwidget.hcaptcha.nohcaptcha import submit +from plone.formwidget.hcaptcha.validator import WrongCaptchaCode +from plone.registry.interfaces import IRegistry +from zope.component import queryUtility + + +class HCaptchaSupport(CaptchaSupport): + def __init__(self, context, request): + super().__init__(context, request) + registry = queryUtility(IRegistry) + self.settings = registry.forInterface(IHCaptchaSettings) + + def verify(self, data) -> bool: + if not self.settings.private_key: + raise ValueError( + "No hcaptcha private key configured. Go to " + "path/to/site/@@hcaptcha-settings to configure." + ) + token = data["token"] + remote_addr = self.request.get("HTTP_X_FORWARDED_FOR", "").split(",")[0] + if not remote_addr: + remote_addr = self.request.get("REMOTE_ADDR") + res = submit(token, self.settings.private_key, remote_addr) + if not res.is_valid: + raise WrongCaptchaCode diff --git a/src/collective/volto/formsupport/captcha/recaptcha.py b/src/collective/volto/formsupport/captcha/recaptcha.py new file mode 100644 index 00000000..aa9e8ff3 --- /dev/null +++ b/src/collective/volto/formsupport/captcha/recaptcha.py @@ -0,0 +1,27 @@ +from . import CaptchaSupport +from plone.formwidget.recaptcha.interfaces import IReCaptchaSettings +from plone.formwidget.recaptcha.norecaptcha import submit +from plone.formwidget.recaptcha.validator import WrongCaptchaCode +from plone.registry.interfaces import IRegistry +from zope.component import queryUtility + + +class RecaptchaSupport(CaptchaSupport): + def __init__(self, context, request): + super().__init__(context, request) + registry = queryUtility(IRegistry) + self.settings = registry.forInterface(IReCaptchaSettings) + + def verify(self, data) -> bool: + if not self.settings.private_key: + raise ValueError( + "No recaptcha private key configured. Go to " + "path/to/site/@@recaptcha-settings to configure." + ) + token = data["token"] + remote_addr = self.request.get("HTTP_X_FORWARDED_FOR", "").split(",")[0] + if not remote_addr: + remote_addr = self.request.get("REMOTE_ADDR") + res = submit(token, self.settings.private_key, remote_addr) + if not res.is_valid: + raise WrongCaptchaCode diff --git a/src/collective/volto/formsupport/configure.zcml b/src/collective/volto/formsupport/configure.zcml index 2554b59f..1a9f5236 100644 --- a/src/collective/volto/formsupport/configure.zcml +++ b/src/collective/volto/formsupport/configure.zcml @@ -16,6 +16,7 @@ + diff --git a/src/collective/volto/formsupport/interfaces.py b/src/collective/volto/formsupport/interfaces.py index 3178d800..332d56fa 100644 --- a/src/collective/volto/formsupport/interfaces.py +++ b/src/collective/volto/formsupport/interfaces.py @@ -30,3 +30,13 @@ class IPostEvent(Interface): """ Event fired when a form is submitted (before actions) """ + + +class ICaptchaSupport(Interface): + def __init__(self, context, request): + """Initialize adpater""" + + def verify(self, data): + """Verify the captcha + @return: True if verified, Raise exception otherwise + """ 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 a6c65a44..4579977f 100644 --- a/src/collective/volto/formsupport/restapi/services/submit_form/post.py +++ b/src/collective/volto/formsupport/restapi/services/submit_form/post.py @@ -16,11 +16,10 @@ from zope.i18n import translate from collective.volto.formsupport.interfaces import IFormDataStore from collective.volto.formsupport.interfaces import IPostEvent - +from collective.volto.formsupport.interfaces import ICaptchaSupport import codecs import six - @implementer(IPostEvent) class PostEventService(object): def __init__(self, context, data): @@ -104,6 +103,11 @@ def validate_form(self): ) ) + if self.block.get("captcha", False): + getMultiAdapter( + (self.context, self.request), ICaptchaSupport, name=self.block["captcha"] + ).verify(self.form_data["captcha"]) + def get_block_data(self, block_id): blocks = getattr(self.context, "blocks", {}) if not blocks: @@ -238,20 +242,8 @@ def filter_parameters(self): def send_mail(self, msg, encoding): host = api.portal.get_tool(name="MailHost") - # try: - host.send(msg, charset=encoding) - # except (SMTPException, RuntimeError): - # plone_utils = api.portal.get_tool(name="plone_utils") - # exception = plone_utils.exceptionString() - # message = "Unable to send mail: {}".format(exception) - - # self.request.response.setStatus(500) - # return dict( - # error=dict(type="InternalServerError", message=message) - # ) - def manage_attachments(self, msg): attachments = self.form_data.get("attachments", {}) if not attachments: diff --git a/test_plone52.cfg b/test_plone52.cfg index 96d21e88..a0bbb28e 100644 --- a/test_plone52.cfg +++ b/test_plone52.cfg @@ -43,3 +43,9 @@ mccabe = 0.6.1 plone.recipe.codeanalysis = 3.0.1 pycodestyle = 2.6.0 pyflakes = 2.2.0 + +# Added by buildout at 2022-04-12 19:30:36.779813 + +# Required by: +# python-dotenv==0.15.0 +typing = 3.10.0.0 From 2fe4f59dfdd03dc9440c6c9156836cf8f9af00cb Mon Sep 17 00:00:00 2001 From: mamico Date: Tue, 12 Apr 2022 19:39:01 +0200 Subject: [PATCH 11/70] black --- .gitignore | 1 - .../volto/formsupport/restapi/services/submit_form/post.py | 5 ++++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 5a7f9a23..f24e0133 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,6 @@ htmlcov/ include/ lib/ local/ -bin/ node_modules/ parts/ dist/* 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 4579977f..22aabcf1 100644 --- a/src/collective/volto/formsupport/restapi/services/submit_form/post.py +++ b/src/collective/volto/formsupport/restapi/services/submit_form/post.py @@ -20,6 +20,7 @@ import codecs import six + @implementer(IPostEvent) class PostEventService(object): def __init__(self, context, data): @@ -105,7 +106,9 @@ def validate_form(self): if self.block.get("captcha", False): getMultiAdapter( - (self.context, self.request), ICaptchaSupport, name=self.block["captcha"] + (self.context, self.request), + ICaptchaSupport, + name=self.block["captcha"], ).verify(self.form_data["captcha"]) def get_block_data(self, block_id): From 8be73137f912e3de14eec809c086a415882a327f Mon Sep 17 00:00:00 2001 From: mamico Date: Thu, 14 Apr 2022 09:44:42 +0200 Subject: [PATCH 12/70] tests --- buildout.cfg | 3 +- setup.py | 2 + .../volto/formsupport/captcha/__init__.py | 4 +- .../volto/formsupport/captcha/hcaptcha.py | 19 +- .../volto/formsupport/captcha/recaptcha.py | 20 +- .../restapi/services/submit_form/post.py | 2 +- .../volto/formsupport/tests/test_captcha.py | 281 ++++++++++++++++++ test_plone52.cfg | 4 + 8 files changed, 326 insertions(+), 9 deletions(-) create mode 100644 src/collective/volto/formsupport/tests/test_captcha.py diff --git a/buildout.cfg b/buildout.cfg index 2303d6c3..60ead826 100644 --- a/buildout.cfg +++ b/buildout.cfg @@ -14,4 +14,5 @@ pyflakes = 2.4.0 pycodestyle = 2.8.0 multipart = 0.2.4 pyScss = 1.4.0 - +robotframework = 5.0 +plone.rest = 2.0.0a5 diff --git a/setup.py b/setup.py index c0bee088..1bee1c97 100644 --- a/setup.py +++ b/setup.py @@ -74,6 +74,8 @@ "plone.app.contenttypes", "plone.app.robotframework[debug]", "collective.MockMailHost", + "plone.formwidget.hcaptcha", + "plone.formwidget.recaptcha", ], }, entry_points=""" diff --git a/src/collective/volto/formsupport/captcha/__init__.py b/src/collective/volto/formsupport/captcha/__init__.py index 5410e86a..6d2e442f 100644 --- a/src/collective/volto/formsupport/captcha/__init__.py +++ b/src/collective/volto/formsupport/captcha/__init__.py @@ -1,10 +1,10 @@ class CaptchaSupport(object): - def __init__(self, context, request) -> None: + def __init__(self, context, request): self.context = context self.request = request - def verify(self) -> bool: + def verify(self): """ Verify the captcha """ diff --git a/src/collective/volto/formsupport/captcha/hcaptcha.py b/src/collective/volto/formsupport/captcha/hcaptcha.py index e36c2214..54fb3355 100644 --- a/src/collective/volto/formsupport/captcha/hcaptcha.py +++ b/src/collective/volto/formsupport/captcha/hcaptcha.py @@ -1,9 +1,12 @@ from . import CaptchaSupport +from collective.volto.formsupport import _ from plone.formwidget.hcaptcha.interfaces import IHCaptchaSettings from plone.formwidget.hcaptcha.nohcaptcha import submit from plone.formwidget.hcaptcha.validator import WrongCaptchaCode from plone.registry.interfaces import IRegistry +from zExceptions import BadRequest from zope.component import queryUtility +from zope.i18n import translate class HCaptchaSupport(CaptchaSupport): @@ -12,16 +15,28 @@ def __init__(self, context, request): registry = queryUtility(IRegistry) self.settings = registry.forInterface(IHCaptchaSettings) - def verify(self, data) -> bool: + def verify(self, data): if not self.settings.private_key: raise ValueError( "No hcaptcha private key configured. Go to " "path/to/site/@@hcaptcha-settings to configure." ) + if not data or not data.get("token"): + raise BadRequest( + translate( + _("No captcha token provided."), + context=self.request, + ) + ) token = data["token"] remote_addr = self.request.get("HTTP_X_FORWARDED_FOR", "").split(",")[0] if not remote_addr: remote_addr = self.request.get("REMOTE_ADDR") res = submit(token, self.settings.private_key, remote_addr) if not res.is_valid: - raise WrongCaptchaCode + raise BadRequest( + translate( + _("The code you entered was wrong, please enter the new one."), + context=self.request, + ) + ) diff --git a/src/collective/volto/formsupport/captcha/recaptcha.py b/src/collective/volto/formsupport/captcha/recaptcha.py index aa9e8ff3..f9f441e3 100644 --- a/src/collective/volto/formsupport/captcha/recaptcha.py +++ b/src/collective/volto/formsupport/captcha/recaptcha.py @@ -1,9 +1,11 @@ from . import CaptchaSupport +from collective.volto.formsupport import _ from plone.formwidget.recaptcha.interfaces import IReCaptchaSettings from plone.formwidget.recaptcha.norecaptcha import submit -from plone.formwidget.recaptcha.validator import WrongCaptchaCode from plone.registry.interfaces import IRegistry +from zExceptions import BadRequest from zope.component import queryUtility +from zope.i18n import translate class RecaptchaSupport(CaptchaSupport): @@ -12,16 +14,28 @@ def __init__(self, context, request): registry = queryUtility(IRegistry) self.settings = registry.forInterface(IReCaptchaSettings) - def verify(self, data) -> bool: + def verify(self, data): if not self.settings.private_key: raise ValueError( "No recaptcha private key configured. Go to " "path/to/site/@@recaptcha-settings to configure." ) + if not data or not data.get("token"): + raise BadRequest( + translate( + _("No captcha token provided."), + context=self.request, + ) + ) token = data["token"] remote_addr = self.request.get("HTTP_X_FORWARDED_FOR", "").split(",")[0] if not remote_addr: remote_addr = self.request.get("REMOTE_ADDR") res = submit(token, self.settings.private_key, remote_addr) if not res.is_valid: - raise WrongCaptchaCode + raise BadRequest( + translate( + _("The code you entered was wrong, please enter the new one."), + context=self.request, + ) + ) 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 22aabcf1..1c2326b6 100644 --- a/src/collective/volto/formsupport/restapi/services/submit_form/post.py +++ b/src/collective/volto/formsupport/restapi/services/submit_form/post.py @@ -109,7 +109,7 @@ def validate_form(self): (self.context, self.request), ICaptchaSupport, name=self.block["captcha"], - ).verify(self.form_data["captcha"]) + ).verify(self.form_data.get("captcha")) def get_block_data(self, block_id): blocks = getattr(self.context, "blocks", {}) diff --git a/src/collective/volto/formsupport/tests/test_captcha.py b/src/collective/volto/formsupport/tests/test_captcha.py new file mode 100644 index 00000000..5d971fe5 --- /dev/null +++ b/src/collective/volto/formsupport/tests/test_captcha.py @@ -0,0 +1,281 @@ +# -*- coding: utf-8 -*- +from collective.volto.formsupport.testing import ( # noqa: E501, + VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING, +) +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.formwidget.recaptcha.interfaces import IReCaptchaSettings +from plone.formwidget.hcaptcha.interfaces import IHCaptchaSettings +from plone.registry.interfaces import IRegistry +from plone.restapi.testing import RelativeSession +from Products.MailHost.interfaces import IMailHost +import transaction +import unittest +from unittest.mock import Mock +from unittest.mock import patch +from zope.component import getUtility + + +class TestCaptcha(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) + + self.registry = getUtility(IRegistry) + self.registry["plone.email_from_address"] = "site_addr@plone.com" + self.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"}, + } + 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_recaptcha_no_settings(self): + self.document.blocks = { + "text-id": {"@type": "text"}, + "form-id": { + "@type": "form", + "default_subject": "block subject", + "default_from": "john@doe.com", + "send": True, + "subblocks": [ + { + "field_id": "contact", + "field_type": "from", + "use_as_bcc": True, + }, + ], + "captcha": "recaptcha", + }, + } + transaction.commit() + response = self.submit_form( + data={ + "data": [ + {"label": "Message", "value": "just want to say hi"}, + ], + "block_id": "form-id", + }, + ) + transaction.commit() + self.assertEqual(response.status_code, 503) + self.assertEqual( + response.json()["message"], + "'Interface `plone.formwidget.recaptcha.interfaces.IReCaptchaSettings` " + "defines a field `public_key`, for which there is no record.'" + ) + self.assertEqual( + response.json()["type"], + "KeyError" + ) + + self.registry.registerInterface(IReCaptchaSettings) + transaction.commit() + response = self.submit_form( + data={ + "data": [ + {"label": "Message", "value": "just want to say hi"}, + ], + "block_id": "form-id", + }, + ) + transaction.commit() + self.assertEqual(response.status_code, 500) + self.assertEqual( + response.json()["message"], + "No recaptcha private key configured. Go to path/to/site/@@recaptcha-settings " + "to configure." + ) + + def test_recaptcha(self): + self.registry.registerInterface(IReCaptchaSettings) + settings = self.registry.forInterface(IReCaptchaSettings) + settings.public_key = "public" + settings.private_key = "private" + + self.document.blocks = { + "text-id": {"@type": "text"}, + "form-id": { + "@type": "form", + "default_subject": "block subject", + "default_from": "john@doe.com", + "send": True, + "subblocks": [ + { + "field_id": "contact", + "field_type": "from", + "use_as_bcc": True, + }, + ], + "captcha": "recaptcha", + }, + } + transaction.commit() + + response = self.submit_form( + data={ + "data": [ + {"label": "Message", "value": "just want to say hi"}, + ], + "block_id": "form-id", + }, + ) + transaction.commit() + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.json()["message"], + "No captcha token provided." + ) + + with patch("collective.volto.formsupport.captcha.recaptcha.submit") as mock_submit: + mock_submit.return_value = Mock(is_valid=False) + response = self.submit_form( + data={ + "data": [ + {"label": "Message", "value": "just want to say hi"}, + ], + "block_id": "form-id", + "captcha": {"token": "12345"}, + }, + ) + transaction.commit() + mock_submit.assert_called_once_with('12345', 'private', '127.0.0.1') + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.json()["message"], + "The code you entered was wrong, please enter the new one." + ) + + with patch("collective.volto.formsupport.captcha.recaptcha.submit") as mock_submit: + mock_submit.return_value = Mock(is_valid=True) + response = self.submit_form( + data={ + "data": [ + {"label": "Message", "value": "just want to say hi"}, + ], + "block_id": "form-id", + "captcha": {"token": "12345"}, + }, + ) + transaction.commit() + mock_submit.assert_called_once_with('12345', 'private', '127.0.0.1') + self.assertEqual(response.status_code, 204) + + def test_hcaptcha( + self, + ): + self.registry.registerInterface(IHCaptchaSettings) + settings = self.registry.forInterface(IHCaptchaSettings) + settings.public_key = "public" + settings.private_key = "private" + + self.document.blocks = { + "text-id": {"@type": "text"}, + "form-id": { + "@type": "form", + "default_subject": "block subject", + "default_from": "john@doe.com", + "send": True, + "subblocks": [ + { + "field_id": "contact", + "field_type": "from", + "use_as_bcc": True, + }, + ], + "captcha": "hcaptcha", + }, + } + transaction.commit() + + response = self.submit_form( + data={ + "data": [ + {"label": "Message", "value": "just want to say hi"}, + ], + "block_id": "form-id", + }, + ) + transaction.commit() + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.json()["message"], + "No captcha token provided." + ) + + with patch("collective.volto.formsupport.captcha.hcaptcha.submit") as mock_submit: + mock_submit.return_value = Mock(is_valid=False) + response = self.submit_form( + data={ + "data": [ + {"label": "Message", "value": "just want to say hi"}, + ], + "block_id": "form-id", + "captcha": {"token": "12345"}, + }, + ) + transaction.commit() + mock_submit.assert_called_once_with('12345', 'private', '127.0.0.1') + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.json()["message"], + "The code you entered was wrong, please enter the new one." + ) + + with patch("collective.volto.formsupport.captcha.hcaptcha.submit") as mock_submit: + mock_submit.return_value = Mock(is_valid=True) + response = self.submit_form( + data={ + "data": [ + {"label": "Message", "value": "just want to say hi"}, + ], + "block_id": "form-id", + "captcha": {"token": "12345"}, + }, + ) + transaction.commit() + mock_submit.assert_called_once_with('12345', 'private', '127.0.0.1') + self.assertEqual(response.status_code, 204) diff --git a/test_plone52.cfg b/test_plone52.cfg index a0bbb28e..79416854 100644 --- a/test_plone52.cfg +++ b/test_plone52.cfg @@ -49,3 +49,7 @@ pyflakes = 2.2.0 # Required by: # python-dotenv==0.15.0 typing = 3.10.0.0 + +# Added by buildout at 2022-04-13 01:17:35.761915 +plone.formwidget.hcaptcha = 1.0.0 +plone.formwidget.recaptcha = 2.3.0 From d9a44b7f000abb05d6fd2bda20ec975dc5017d79 Mon Sep 17 00:00:00 2001 From: mamico Date: Thu, 14 Apr 2022 09:45:00 +0200 Subject: [PATCH 13/70] tests --- src/collective/volto/formsupport/captcha/hcaptcha.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/collective/volto/formsupport/captcha/hcaptcha.py b/src/collective/volto/formsupport/captcha/hcaptcha.py index 54fb3355..091fc2ed 100644 --- a/src/collective/volto/formsupport/captcha/hcaptcha.py +++ b/src/collective/volto/formsupport/captcha/hcaptcha.py @@ -2,7 +2,7 @@ from collective.volto.formsupport import _ from plone.formwidget.hcaptcha.interfaces import IHCaptchaSettings from plone.formwidget.hcaptcha.nohcaptcha import submit -from plone.formwidget.hcaptcha.validator import WrongCaptchaCode +#from plone.formwidget.hcaptcha.validator import WrongCaptchaCode from plone.registry.interfaces import IRegistry from zExceptions import BadRequest from zope.component import queryUtility From ff13344f26d141104bc014ea2bb6239519541bef Mon Sep 17 00:00:00 2001 From: mamico Date: Thu, 14 Apr 2022 09:45:10 +0200 Subject: [PATCH 14/70] tests --- src/collective/volto/formsupport/captcha/hcaptcha.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/collective/volto/formsupport/captcha/hcaptcha.py b/src/collective/volto/formsupport/captcha/hcaptcha.py index 091fc2ed..2ea8d6aa 100644 --- a/src/collective/volto/formsupport/captcha/hcaptcha.py +++ b/src/collective/volto/formsupport/captcha/hcaptcha.py @@ -2,7 +2,7 @@ from collective.volto.formsupport import _ from plone.formwidget.hcaptcha.interfaces import IHCaptchaSettings from plone.formwidget.hcaptcha.nohcaptcha import submit -#from plone.formwidget.hcaptcha.validator import WrongCaptchaCode +# from plone.formwidget.hcaptcha.validator import WrongCaptchaCode from plone.registry.interfaces import IRegistry from zExceptions import BadRequest from zope.component import queryUtility From 67c6c608a62246649e582b4930046a52547dddab Mon Sep 17 00:00:00 2001 From: mamico Date: Thu, 14 Apr 2022 11:04:48 +0200 Subject: [PATCH 15/70] coverage --- .github/workflows/tests.yml | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0f470938..bd1824b6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -36,4 +36,24 @@ jobs: bin/code-analysis - name: Run tests run: | - bin/test + bin/test-coverage + - name: Coverage Badge + uses: tj-actions/coverage-badge-py@v1.8 + - name: Verify Changed files + uses: tj-actions/verify-changed-files@v9 + id: changed_files + with: + files: coverage.svg + - name: Commit files + if: steps.changed_files.outputs.files_changed == 'true' + run: | + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + git add coverage.svg + git commit -m "Updated coverage.svg" + - name: Push changes + if: steps.changed_files.outputs.files_changed == 'true' + uses: ad-m/github-push-action@master + with: + github_token: ${{ secrets.github_token }} + branch: ${{ github.ref }} From 516b346af84656dbea6224df7351d826a6aa3169 Mon Sep 17 00:00:00 2001 From: mamico Date: Thu, 14 Apr 2022 11:08:27 +0200 Subject: [PATCH 16/70] coverage --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bd1824b6..516ce3a5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,7 +8,7 @@ jobs: strategy: max-parallel: 4 matrix: - python: ["3.7"] + python: ["3.7", "3.8", "3.9"] plone: ["52"] # exclude: # - python: "3.7" From 2da3418e51d76c24ff3b31af4c07fb74122d5600 Mon Sep 17 00:00:00 2001 From: mamico Date: Thu, 14 Apr 2022 16:50:21 +0200 Subject: [PATCH 17/70] use metadata from schema --- .github/workflows/tests.yml | 2 +- src/collective/volto/formsupport/configure.zcml | 2 ++ .../volto/formsupport/datamanager/catalog.py | 12 ++++++------ src/collective/volto/formsupport/testing.py | 1 + src/collective/volto/formsupport/upgrades.py | 2 +- 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 516ce3a5..895555ec 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,7 +8,7 @@ jobs: strategy: max-parallel: 4 matrix: - python: ["3.7", "3.8", "3.9"] + python: ["3.9"] plone: ["52"] # exclude: # - python: "3.7" diff --git a/src/collective/volto/formsupport/configure.zcml b/src/collective/volto/formsupport/configure.zcml index 1a9f5236..36f6b34e 100644 --- a/src/collective/volto/formsupport/configure.zcml +++ b/src/collective/volto/formsupport/configure.zcml @@ -13,6 +13,8 @@ --> + + diff --git a/src/collective/volto/formsupport/datamanager/catalog.py b/src/collective/volto/formsupport/datamanager/catalog.py index bfe0a507..1ad0a88e 100644 --- a/src/collective/volto/formsupport/datamanager/catalog.py +++ b/src/collective/volto/formsupport/datamanager/catalog.py @@ -75,15 +75,15 @@ def add(self, data): ) return None - form_ids = [x.get("field_id", "") for x in form_fields] + fields = {x["field_id"]: x.get("label", x["field_id"]) for x in form_fields} record = Record() # record.attrs["metadata"] = {} normalizer = getUtility(IIDNormalizer) - for field in data: - field_id = field.get("field_id", "") - id = normalizer.normalize(field.get("label", "")) - value = field.get("value", "") - if field_id in form_ids: + for field_data in data: + field_id = field_data.get("field_id", "") + value = field_data.get("value", "") + if field_id in fields: + id = normalizer.normalize(fields[field_id]) record.attrs[id] = value # record.attrs["metadata"][id] = { # "field_id": field_id, diff --git a/src/collective/volto/formsupport/testing.py b/src/collective/volto/formsupport/testing.py index b55c8da6..c2ed4aa2 100644 --- a/src/collective/volto/formsupport/testing.py +++ b/src/collective/volto/formsupport/testing.py @@ -13,6 +13,7 @@ import collective.MockMailHost import collective.volto.formsupport import plone.restapi +import souper.plone class VoltoFormsupportLayer(PloneSandboxLayer): diff --git a/src/collective/volto/formsupport/upgrades.py b/src/collective/volto/formsupport/upgrades.py index 30feb9fd..f3f2431d 100644 --- a/src/collective/volto/formsupport/upgrades.py +++ b/src/collective/volto/formsupport/upgrades.py @@ -20,7 +20,7 @@ DEFAULT_PROFILE = "profile-collective.volto.formsupport:default" -def to_1100(context): # noqa: C901 +def to_1100(context): # noqa: C901 # pragma: no cover logger.info("### START CONVERSION FORM BLOCKS ###") def fix_block(blocks, url): From acfa375af2d94dd8c3ef6fee1b445535177795f1 Mon Sep 17 00:00:00 2001 From: mamico Date: Thu, 14 Apr 2022 16:50:36 +0200 Subject: [PATCH 18/70] use metadata from schema --- src/collective/volto/formsupport/testing.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/collective/volto/formsupport/testing.py b/src/collective/volto/formsupport/testing.py index c2ed4aa2..b55c8da6 100644 --- a/src/collective/volto/formsupport/testing.py +++ b/src/collective/volto/formsupport/testing.py @@ -13,7 +13,6 @@ import collective.MockMailHost import collective.volto.formsupport import plone.restapi -import souper.plone class VoltoFormsupportLayer(PloneSandboxLayer): From b834116eecdd5de479854fdf8cffedd244eaed25 Mon Sep 17 00:00:00 2001 From: mamico Date: Thu, 14 Apr 2022 16:56:33 +0200 Subject: [PATCH 19/70] test clear data --- .../tests/test_store_action_form.py | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 src/collective/volto/formsupport/tests/test_store_action_form.py diff --git a/src/collective/volto/formsupport/tests/test_store_action_form.py b/src/collective/volto/formsupport/tests/test_store_action_form.py new file mode 100644 index 00000000..d6942b31 --- /dev/null +++ b/src/collective/volto/formsupport/tests/test_store_action_form.py @@ -0,0 +1,173 @@ +# -*- coding: utf-8 -*- +from collective.volto.formsupport.testing import ( # noqa: E501, + VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING, +) +import csv +from io import StringIO +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.restapi.testing import RelativeSession +from Products.MailHost.interfaces import IMailHost +import transaction +import unittest +from zope.component import getUtility + + +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) + + 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"}, + } + 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 export_csv(self): + url = "{}/@form-data-export".format(self.document_url) + response = self.api_session.get(url) + # transaction.commit() + return response + + def clear_data(self): + url = "{}/@form-data-clear".format(self.document_url) + response = self.api_session.get(url) + # transaction.commit() + return response + + def test_unable_to_store_data(self): + """form schema not defined, unable to store data + """ + self.document.blocks = { + "form-id": {"@type": "form", "store": True}, + } + transaction.commit() + + response = self.submit_form( + data={ + "from": "john@doe.com", + "data": [ + {"label": "Message", "value": "just want to say hi"}, + {"label": "Name", "value": "John"}, + ], + "subject": "test subject", + "block_id": "form-id", + }, + ) + transaction.commit() + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["message"], "Unable to store data") + response = self.export_csv() + + def test_store_data(self): + self.document.blocks = { + "form-id": { + "@type": "form", + "store": True, + "subblocks": [ + { + "label": "Message", + "field_id": "message", + "field_type": "text", + }, + { + "label": "Name", + "field_id": "name", + "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": "name", "value": "John"}, + {"field_id": "foo", "value": "skip this"}, + ], + "subject": "test subject", + "block_id": "form-id", + }, + ) + transaction.commit() + self.assertEqual(response.status_code, 204) + response = self.export_csv() + data = [*csv.reader(StringIO(response.text), delimiter=',')] + self.assertEqual(len(data), 2) + self.assertEqual(data[0], ["message", "name", "date"]) + self.assertEqual(data[1][:-1], ["just want to say hi", "John"]) + response = self.submit_form( + data={ + "from": "sally@doe.com", + "data": [ + {"field_id": "message", "value": "bye"}, + {"field_id": "name", "value": "Sally"}, + ], + "subject": "test subject", + "block_id": "form-id", + }, + ) + transaction.commit() + self.assertEqual(response.status_code, 204) + response = self.export_csv() + data = [*csv.reader(StringIO(response.text), delimiter=',')] + self.assertEqual(len(data), 3) + self.assertEqual(data[0], ["message", "name", "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"]) + + # clear data + response = self.clear_data() + self.assertEqual(response.status_code, 204) + response = self.export_csv() + data = [*csv.reader(StringIO(response.text), delimiter=',')] + self.assertEqual(len(data), 1) + self.assertEqual(data[0], ["date"]) From 347c635431ceb3443a70f578e38714d0dbec1d49 Mon Sep 17 00:00:00 2001 From: mamico Date: Thu, 14 Apr 2022 17:00:26 +0200 Subject: [PATCH 20/70] badge --- README.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.rst b/README.rst index 549e56c1..0bf4b6c7 100644 --- a/README.rst +++ b/README.rst @@ -12,6 +12,9 @@ :target: https://pypi.python.org/pypi/collective.volto.formsupport/ :alt: License +.. image:: ./coverage.svg + :alt: coverage + ============================ collective.volto.formsupport From c0e0a90008c89857c2ec2b7c7809d093e9bfdda9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 14 Apr 2022 15:05:27 +0000 Subject: [PATCH 21/70] Updated coverage.svg --- coverage.svg | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 coverage.svg diff --git a/coverage.svg b/coverage.svg new file mode 100644 index 00000000..c1490035 --- /dev/null +++ b/coverage.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + coverage + coverage + 90% + 90% + + From acf8e02b2f3be44b5c52bc58c2df2cf48185cd3c Mon Sep 17 00:00:00 2001 From: mamico Date: Thu, 14 Apr 2022 17:51:50 +0200 Subject: [PATCH 22/70] tests --- .../tests/test_store_action_form.py | 89 ++++++++++++++++--- 1 file changed, 76 insertions(+), 13 deletions(-) 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 d6942b31..763122f7 100644 --- a/src/collective/volto/formsupport/tests/test_store_action_form.py +++ b/src/collective/volto/formsupport/tests/test_store_action_form.py @@ -67,10 +67,14 @@ def submit_form(self, data): transaction.commit() return response + def export_data(self): + url = "{}/@form-data".format(self.document_url) + response = self.api_session.get(url) + return response + def export_csv(self): url = "{}/@form-data-export".format(self.document_url) response = self.api_session.get(url) - # transaction.commit() return response def clear_data(self): @@ -138,11 +142,12 @@ def test_store_data(self): ) transaction.commit() self.assertEqual(response.status_code, 204) - response = self.export_csv() - data = [*csv.reader(StringIO(response.text), delimiter=',')] - self.assertEqual(len(data), 2) - self.assertEqual(data[0], ["message", "name", "date"]) - self.assertEqual(data[1][:-1], ["just want to say hi", "John"]) + response = self.export_data() + data = response.json() + self.assertEqual(len(data['items']), 1) + self.assertEqual(sorted(data["items"][0].keys()), ["block_id", "date", "id", "message", "name"]) + self.assertEqual(data["items"][0]["message"], "just want to say hi") + self.assertEqual(data["items"][0]["name"], "John") response = self.submit_form( data={ "from": "sally@doe.com", @@ -156,13 +161,16 @@ def test_store_data(self): ) transaction.commit() self.assertEqual(response.status_code, 204) - response = self.export_csv() - data = [*csv.reader(StringIO(response.text), delimiter=',')] - self.assertEqual(len(data), 3) - self.assertEqual(data[0], ["message", "name", "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"]) + response = self.export_data() + data = response.json() + self.assertEqual(len(data["items"]), 2) + self.assertEqual(sorted(data["items"][0].keys()), ["block_id", "date", "id", "message", "name"]) + self.assertEqual(sorted(data["items"][1].keys()), ["block_id", "date", "id", "message", "name"]) + sorted_data = sorted(data["items"], key=lambda x: x["name"]) + self.assertEqual(sorted_data[0]["name"], "John") + self.assertEqual(sorted_data[0]["message"], "just want to say hi") + self.assertEqual(sorted_data[1]["name"], "Sally") + self.assertEqual(sorted_data[1]["message"], "bye") # clear data response = self.clear_data() @@ -171,3 +179,58 @@ def test_store_data(self): data = [*csv.reader(StringIO(response.text), delimiter=',')] self.assertEqual(len(data), 1) self.assertEqual(data[0], ["date"]) + + def test_export_csv(self): + self.document.blocks = { + "form-id": { + "@type": "form", + "store": True, + "subblocks": [ + { + "label": "Message", + "field_id": "message", + "field_type": "text", + }, + { + "label": "Name", + "field_id": "name", + "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": "name", "value": "John"}, + {"field_id": "foo", "value": "skip this"}, + ], + "subject": "test subject", + "block_id": "form-id", + }, + ) + + response = self.submit_form( + data={ + "from": "sally@doe.com", + "data": [ + {"field_id": "message", "value": "bye"}, + {"field_id": "name", "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) + self.assertEqual(data[0], ["message", "name", "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"]) From d81f389153d63c2d005d1e74618c0aba4e30d06a Mon Sep 17 00:00:00 2001 From: Mauro Amico Date: Fri, 15 Apr 2022 08:15:34 +0200 Subject: [PATCH 23/70] increase code coverage (#14) --- .github/workflows/tests.yml | 24 +- README.rst | 3 + coverage.svg | 21 ++ .../volto/formsupport/configure.zcml | 2 + .../volto/formsupport/datamanager/catalog.py | 12 +- .../tests/test_store_action_form.py | 236 ++++++++++++++++++ src/collective/volto/formsupport/upgrades.py | 2 +- 7 files changed, 291 insertions(+), 9 deletions(-) create mode 100644 coverage.svg create mode 100644 src/collective/volto/formsupport/tests/test_store_action_form.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0f470938..895555ec 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,7 +8,7 @@ jobs: strategy: max-parallel: 4 matrix: - python: ["3.7"] + python: ["3.9"] plone: ["52"] # exclude: # - python: "3.7" @@ -36,4 +36,24 @@ jobs: bin/code-analysis - name: Run tests run: | - bin/test + bin/test-coverage + - name: Coverage Badge + uses: tj-actions/coverage-badge-py@v1.8 + - name: Verify Changed files + uses: tj-actions/verify-changed-files@v9 + id: changed_files + with: + files: coverage.svg + - name: Commit files + if: steps.changed_files.outputs.files_changed == 'true' + run: | + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + git add coverage.svg + git commit -m "Updated coverage.svg" + - name: Push changes + if: steps.changed_files.outputs.files_changed == 'true' + uses: ad-m/github-push-action@master + with: + github_token: ${{ secrets.github_token }} + branch: ${{ github.ref }} diff --git a/README.rst b/README.rst index 549e56c1..0bf4b6c7 100644 --- a/README.rst +++ b/README.rst @@ -12,6 +12,9 @@ :target: https://pypi.python.org/pypi/collective.volto.formsupport/ :alt: License +.. image:: ./coverage.svg + :alt: coverage + ============================ collective.volto.formsupport diff --git a/coverage.svg b/coverage.svg new file mode 100644 index 00000000..59d64b37 --- /dev/null +++ b/coverage.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + coverage + coverage + 93% + 93% + + diff --git a/src/collective/volto/formsupport/configure.zcml b/src/collective/volto/formsupport/configure.zcml index 1a9f5236..36f6b34e 100644 --- a/src/collective/volto/formsupport/configure.zcml +++ b/src/collective/volto/formsupport/configure.zcml @@ -13,6 +13,8 @@ --> + + diff --git a/src/collective/volto/formsupport/datamanager/catalog.py b/src/collective/volto/formsupport/datamanager/catalog.py index bfe0a507..1ad0a88e 100644 --- a/src/collective/volto/formsupport/datamanager/catalog.py +++ b/src/collective/volto/formsupport/datamanager/catalog.py @@ -75,15 +75,15 @@ def add(self, data): ) return None - form_ids = [x.get("field_id", "") for x in form_fields] + fields = {x["field_id"]: x.get("label", x["field_id"]) for x in form_fields} record = Record() # record.attrs["metadata"] = {} normalizer = getUtility(IIDNormalizer) - for field in data: - field_id = field.get("field_id", "") - id = normalizer.normalize(field.get("label", "")) - value = field.get("value", "") - if field_id in form_ids: + for field_data in data: + field_id = field_data.get("field_id", "") + value = field_data.get("value", "") + if field_id in fields: + id = normalizer.normalize(fields[field_id]) record.attrs[id] = value # record.attrs["metadata"][id] = { # "field_id": field_id, diff --git a/src/collective/volto/formsupport/tests/test_store_action_form.py b/src/collective/volto/formsupport/tests/test_store_action_form.py new file mode 100644 index 00000000..763122f7 --- /dev/null +++ b/src/collective/volto/formsupport/tests/test_store_action_form.py @@ -0,0 +1,236 @@ +# -*- coding: utf-8 -*- +from collective.volto.formsupport.testing import ( # noqa: E501, + VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING, +) +import csv +from io import StringIO +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.restapi.testing import RelativeSession +from Products.MailHost.interfaces import IMailHost +import transaction +import unittest +from zope.component import getUtility + + +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) + + 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"}, + } + 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 export_data(self): + url = "{}/@form-data".format(self.document_url) + response = self.api_session.get(url) + return response + + def export_csv(self): + url = "{}/@form-data-export".format(self.document_url) + response = self.api_session.get(url) + return response + + def clear_data(self): + url = "{}/@form-data-clear".format(self.document_url) + response = self.api_session.get(url) + # transaction.commit() + return response + + def test_unable_to_store_data(self): + """form schema not defined, unable to store data + """ + self.document.blocks = { + "form-id": {"@type": "form", "store": True}, + } + transaction.commit() + + response = self.submit_form( + data={ + "from": "john@doe.com", + "data": [ + {"label": "Message", "value": "just want to say hi"}, + {"label": "Name", "value": "John"}, + ], + "subject": "test subject", + "block_id": "form-id", + }, + ) + transaction.commit() + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["message"], "Unable to store data") + response = self.export_csv() + + def test_store_data(self): + self.document.blocks = { + "form-id": { + "@type": "form", + "store": True, + "subblocks": [ + { + "label": "Message", + "field_id": "message", + "field_type": "text", + }, + { + "label": "Name", + "field_id": "name", + "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": "name", "value": "John"}, + {"field_id": "foo", "value": "skip this"}, + ], + "subject": "test subject", + "block_id": "form-id", + }, + ) + transaction.commit() + self.assertEqual(response.status_code, 204) + response = self.export_data() + data = response.json() + self.assertEqual(len(data['items']), 1) + self.assertEqual(sorted(data["items"][0].keys()), ["block_id", "date", "id", "message", "name"]) + self.assertEqual(data["items"][0]["message"], "just want to say hi") + self.assertEqual(data["items"][0]["name"], "John") + response = self.submit_form( + data={ + "from": "sally@doe.com", + "data": [ + {"field_id": "message", "value": "bye"}, + {"field_id": "name", "value": "Sally"}, + ], + "subject": "test subject", + "block_id": "form-id", + }, + ) + transaction.commit() + self.assertEqual(response.status_code, 204) + response = self.export_data() + data = response.json() + self.assertEqual(len(data["items"]), 2) + self.assertEqual(sorted(data["items"][0].keys()), ["block_id", "date", "id", "message", "name"]) + self.assertEqual(sorted(data["items"][1].keys()), ["block_id", "date", "id", "message", "name"]) + sorted_data = sorted(data["items"], key=lambda x: x["name"]) + self.assertEqual(sorted_data[0]["name"], "John") + self.assertEqual(sorted_data[0]["message"], "just want to say hi") + self.assertEqual(sorted_data[1]["name"], "Sally") + self.assertEqual(sorted_data[1]["message"], "bye") + + # clear data + response = self.clear_data() + self.assertEqual(response.status_code, 204) + response = self.export_csv() + data = [*csv.reader(StringIO(response.text), delimiter=',')] + self.assertEqual(len(data), 1) + self.assertEqual(data[0], ["date"]) + + def test_export_csv(self): + self.document.blocks = { + "form-id": { + "@type": "form", + "store": True, + "subblocks": [ + { + "label": "Message", + "field_id": "message", + "field_type": "text", + }, + { + "label": "Name", + "field_id": "name", + "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": "name", "value": "John"}, + {"field_id": "foo", "value": "skip this"}, + ], + "subject": "test subject", + "block_id": "form-id", + }, + ) + + response = self.submit_form( + data={ + "from": "sally@doe.com", + "data": [ + {"field_id": "message", "value": "bye"}, + {"field_id": "name", "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) + self.assertEqual(data[0], ["message", "name", "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/upgrades.py b/src/collective/volto/formsupport/upgrades.py index 30feb9fd..f3f2431d 100644 --- a/src/collective/volto/formsupport/upgrades.py +++ b/src/collective/volto/formsupport/upgrades.py @@ -20,7 +20,7 @@ DEFAULT_PROFILE = "profile-collective.volto.formsupport:default" -def to_1100(context): # noqa: C901 +def to_1100(context): # noqa: C901 # pragma: no cover logger.info("### START CONVERSION FORM BLOCKS ###") def fix_block(blocks, url): From 86f49081c183d1076e4c8c71e4c7417c8283628f Mon Sep 17 00:00:00 2001 From: mamico Date: Mon, 18 Apr 2022 23:41:35 +0200 Subject: [PATCH 24/70] black + serialize captcha props --- src/collective/volto/formsupport/restapi/serializer/blocks.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/collective/volto/formsupport/restapi/serializer/blocks.py b/src/collective/volto/formsupport/restapi/serializer/blocks.py index 2523acdc..23f030dd 100644 --- a/src/collective/volto/formsupport/restapi/serializer/blocks.py +++ b/src/collective/volto/formsupport/restapi/serializer/blocks.py @@ -1,5 +1,4 @@ # -*- coding: utf-8 -*- -import pdb from collective.volto.formsupport.interfaces import ICaptchaSupport from collective.volto.formsupport.interfaces import ICollectiveVoltoFormsupportLayer from plone import api From 02bc92060ebd5a91f760a7bb43fdc49249d8fa40 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 18 Apr 2022 21:43:38 +0000 Subject: [PATCH 25/70] Updated coverage.svg --- coverage.svg | 5 ----- 1 file changed, 5 deletions(-) diff --git a/coverage.svg b/coverage.svg index 596ca476..59d64b37 100644 --- a/coverage.svg +++ b/coverage.svg @@ -15,12 +15,7 @@ coverage coverage -<<<<<<< HEAD - 90% - 90% -======= 93% 93% ->>>>>>> d81f389153d63c2d005d1e74618c0aba4e30d06a From c61f6132b56bd463cd0b25ddd556b4002ddb145e Mon Sep 17 00:00:00 2001 From: mamico Date: Tue, 19 Apr 2022 00:01:57 +0200 Subject: [PATCH 26/70] coveralls --- .coveragerc | 14 ++++++++- .github/workflows/tests.yml | 58 +++++++++++++++++++++++++------------ 2 files changed, 52 insertions(+), 20 deletions(-) diff --git a/.coveragerc b/.coveragerc index f66a2b6c..35be63d8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,6 +1,18 @@ +[run] +relative_files = True + [report] include = src/collective/* omit = */test* - */upgrades/* + /home/*/.buildout/eggs/* + /home/travis/buildout-cache/eggs/* + /home/travis/virtualenv/* + /usr/* + bin/test + eggs/* + parts/* + */lib/* + *.txt + *.rst diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 895555ec..e3e2ba61 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -36,24 +36,44 @@ jobs: bin/code-analysis - name: Run tests run: | - bin/test-coverage - - name: Coverage Badge - uses: tj-actions/coverage-badge-py@v1.8 - - name: Verify Changed files - uses: tj-actions/verify-changed-files@v9 - id: changed_files - with: - files: coverage.svg - - name: Commit files - if: steps.changed_files.outputs.files_changed == 'true' + bin/test + # bin/test-coverage + - name: createcoverage run: | - git config --local user.email "github-actions[bot]@users.noreply.github.com" - git config --local user.name "github-actions[bot]" - git add coverage.svg - git commit -m "Updated coverage.svg" - - name: Push changes - if: steps.changed_files.outputs.files_changed == 'true' - uses: ad-m/github-push-action@master + bin/createcoverage -t '--all' + bin/coverage json -i + - name: Coveralls + uses: AndreMiras/coveralls-python-action@develop with: - github_token: ${{ secrets.github_token }} - branch: ${{ github.ref }} + parallel: true + flag-name: ${{ matrix.plone }}-${{ matrix.python }} + #- name: Coverage Badge + # uses: tj-actions/coverage-badge-py@v1.8 + #- name: Verify Changed files + # uses: tj-actions/verify-changed-files@v9 + # id: changed_files + # with: + # files: coverage.svg + #- name: Commit files + # if: steps.changed_files.outputs.files_changed == 'true' + # run: | + # git config --local user.email "github-actions[bot]@users.noreply.github.com" + # git config --local user.name "github-actions[bot]" + # git add coverage.svg + # git commit -m "Updated coverage.svg" + #- name: Push changes + # if: steps.changed_files.outputs.files_changed == 'true' + # uses: ad-m/github-push-action@master + # with: + # github_token: ${{ secrets.github_token }} + # branch: ${{ github.ref }} + # + +coveralls_finish: + needs: build + runs-on: ubuntu-latest + steps: + - name: Coveralls Finished + uses: AndreMiras/coveralls-python-action@develop + with: + parallel-finished: true From 53a96c1b4ead790cf804df14f634250c61a422a1 Mon Sep 17 00:00:00 2001 From: mamico Date: Tue, 19 Apr 2022 00:07:09 +0200 Subject: [PATCH 27/70] typo --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e3e2ba61..9545d3a1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -69,7 +69,7 @@ jobs: # branch: ${{ github.ref }} # -coveralls_finish: + coveralls_finish: needs: build runs-on: ubuntu-latest steps: From 5d56a5279de262538fd45a22829851230d40e833 Mon Sep 17 00:00:00 2001 From: mamico Date: Tue, 19 Apr 2022 00:12:54 +0200 Subject: [PATCH 28/70] coveralls --- .github/workflows/tests.yml | 21 --------------------- README.rst | 4 ++-- coverage.json | 1 + coverage.svg | 21 --------------------- 4 files changed, 3 insertions(+), 44 deletions(-) create mode 100644 coverage.json delete mode 100644 coverage.svg diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9545d3a1..ad1a9c70 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -47,27 +47,6 @@ jobs: with: parallel: true flag-name: ${{ matrix.plone }}-${{ matrix.python }} - #- name: Coverage Badge - # uses: tj-actions/coverage-badge-py@v1.8 - #- name: Verify Changed files - # uses: tj-actions/verify-changed-files@v9 - # id: changed_files - # with: - # files: coverage.svg - #- name: Commit files - # if: steps.changed_files.outputs.files_changed == 'true' - # run: | - # git config --local user.email "github-actions[bot]@users.noreply.github.com" - # git config --local user.name "github-actions[bot]" - # git add coverage.svg - # git commit -m "Updated coverage.svg" - #- name: Push changes - # if: steps.changed_files.outputs.files_changed == 'true' - # uses: ad-m/github-push-action@master - # with: - # github_token: ${{ secrets.github_token }} - # branch: ${{ github.ref }} - # coveralls_finish: needs: build diff --git a/README.rst b/README.rst index 567267b0..df2629f2 100644 --- a/README.rst +++ b/README.rst @@ -12,8 +12,8 @@ :target: https://pypi.python.org/pypi/collective.volto.formsupport/ :alt: License -.. image:: ./coverage.svg - :alt: coverage +.. |Coverage| image:: https://coveralls.io/repos/github/collective/collective.volto.formsupport/badge.svg + :target: https://coveralls.io/github/collective/collective.volto.formsupport ============================ diff --git a/coverage.json b/coverage.json new file mode 100644 index 00000000..750a46c7 --- /dev/null +++ b/coverage.json @@ -0,0 +1 @@ +{"meta": {"version": "5.5", "timestamp": "2022-04-18T23:54:46.173205", "branch_coverage": false, "show_contexts": false}, "files": {"src/collective/__init__.py": {"executed_lines": [2], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "src/collective/volto/__init__.py": {"executed_lines": [2], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "src/collective/volto/formsupport/__init__.py": {"executed_lines": [2, 3, 6], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "src/collective/volto/formsupport/browser/__init__.py": {"executed_lines": [1], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "src/collective/volto/formsupport/captcha/__init__.py": {"executed_lines": [1, 2, 3, 4, 6], "summary": {"covered_lines": 5, "num_statements": 6, "percent_covered": 83.33333333333333, "missing_lines": 1, "excluded_lines": 0}, "missing_lines": [10], "excluded_lines": []}, "src/collective/volto/formsupport/captcha/hcaptcha.py": {"executed_lines": [1, 2, 3, 4, 7, 8, 9, 10, 13, 14, 15, 16, 17, 19, 20, 25, 30, 31, 36, 37, 43, 44, 45, 46, 47, 48, 49], "summary": {"covered_lines": 27, "num_statements": 29, "percent_covered": 93.10344827586206, "missing_lines": 2, "excluded_lines": 0}, "missing_lines": [21, 32], "excluded_lines": []}, "src/collective/volto/formsupport/captcha/recaptcha.py": {"executed_lines": [1, 2, 3, 4, 5, 6, 7, 8, 11, 12, 13, 14, 15, 17, 18, 23, 28, 29, 30, 34, 35, 41, 42, 43, 44, 45, 46, 47], "summary": {"covered_lines": 28, "num_statements": 29, "percent_covered": 96.55172413793103, "missing_lines": 1, "excluded_lines": 0}, "missing_lines": [19], "excluded_lines": []}, "src/collective/volto/formsupport/datamanager/__init__.py": {"executed_lines": [1], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "src/collective/volto/formsupport/datamanager/catalog.py": {"executed_lines": [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 20, 22, 25, 26, 27, 29, 30, 31, 32, 35, 36, 37, 38, 39, 40, 42, 43, 44, 46, 47, 48, 49, 51, 53, 54, 55, 57, 58, 59, 61, 62, 63, 64, 66, 68, 69, 70, 71, 76, 78, 79, 81, 82, 83, 84, 85, 86, 87, 92, 93, 94, 96, 99, 100, 101, 106, 108, 112, 113], "summary": {"covered_lines": 75, "num_statements": 82, "percent_covered": 91.46341463414635, "missing_lines": 7, "excluded_lines": 0}, "missing_lines": [50, 56, 60, 65, 97, 109, 110], "excluded_lines": []}, "src/collective/volto/formsupport/interfaces.py": {"executed_lines": [2, 3, 6, 7, 10, 11, 18, 23, 29, 30, 35, 36, 39], "summary": {"covered_lines": 11, "num_statements": 11, "percent_covered": 100.0, "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "src/collective/volto/formsupport/restapi/__init__.py": {"executed_lines": [1], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "src/collective/volto/formsupport/restapi/serializer/__init__.py": {"executed_lines": [1], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "src/collective/volto/formsupport/restapi/serializer/blocks.py": {"executed_lines": [2, 3, 4, 5, 6, 7, 8, 9, 10, 13, 14, 16, 17, 19, 20, 21, 23, 29, 30, 35, 36, 37, 40, 41, 42, 43, 46, 47, 48, 49], "summary": {"covered_lines": 27, "num_statements": 27, "percent_covered": 100.0, "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "src/collective/volto/formsupport/restapi/services/__init__.py": {"executed_lines": [1], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "src/collective/volto/formsupport/restapi/services/form_data/__init__.py": {"executed_lines": [1], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "src/collective/volto/formsupport/restapi/services/form_data/clear.py": {"executed_lines": [2, 3, 4, 5, 6, 9, 10, 11, 12, 13, 15], "summary": {"covered_lines": 11, "num_statements": 11, "percent_covered": 100.0, "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "src/collective/volto/formsupport/restapi/services/form_data/csv.py": {"executed_lines": [2, 3, 4, 5, 6, 8, 9, 12, 14, 15, 17, 21, 22, 23, 24, 25, 27, 28, 29, 30, 31, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50], "summary": {"covered_lines": 39, "num_statements": 39, "percent_covered": 100.0, "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "src/collective/volto/formsupport/restapi/services/form_data/form_data.py": {"executed_lines": [2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 13, 16, 17, 18, 19, 20, 21, 23, 24, 25, 27, 30, 33, 34, 35, 36, 42, 43, 45, 46, 47, 48, 49, 51, 53, 54, 55, 56, 58, 59, 60, 61, 64, 65, 66, 67], "summary": {"covered_lines": 46, "num_statements": 49, "percent_covered": 93.87755102040816, "missing_lines": 3, "excluded_lines": 0}, "missing_lines": [31, 50, 52], "excluded_lines": []}, "src/collective/volto/formsupport/restapi/services/submit_form/__init__.py": {"executed_lines": [1], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "src/collective/volto/formsupport/restapi/services/submit_form/post.py": {"executed_lines": [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 24, 25, 26, 27, 28, 31, 32, 33, 35, 36, 37, 38, 39, 41, 42, 44, 45, 48, 50, 52, 53, 54, 55, 57, 59, 63, 64, 70, 71, 85, 86, 96, 97, 107, 108, 114, 115, 116, 118, 119, 120, 121, 122, 123, 124, 125, 127, 139, 140, 141, 142, 143, 144, 145, 146, 147, 149, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 164, 165, 166, 168, 169, 173, 174, 176, 177, 187, 188, 191, 194, 195, 196, 197, 198, 200, 201, 202, 203, 204, 205, 207, 209, 211, 213, 215, 216, 218, 219, 224, 229, 231, 235, 240, 246, 247, 248, 250, 251, 252, 253, 278, 279, 280, 281, 282], "summary": {"covered_lines": 137, "num_statements": 157, "percent_covered": 87.26114649681529, "missing_lines": 20, "excluded_lines": 0}, "missing_lines": [117, 163, 192, 254, 255, 256, 257, 258, 259, 260, 261, 262, 263, 264, 265, 266, 267, 268, 270, 271], "excluded_lines": []}, "src/collective/volto/formsupport/setuphandlers.py": {"executed_lines": [2, 3, 6, 7, 8, 10, 15, 20], "summary": {"covered_lines": 8, "num_statements": 8, "percent_covered": 100.0, "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "src/collective/volto/formsupport/upgrades.py": {"executed_lines": [2, 3, 4, 5, 6, 8, 9, 12, 13, 15, 16, 18, 20, 23], "summary": {"covered_lines": 13, "num_statements": 14, "percent_covered": 92.85714285714286, "missing_lines": 1, "excluded_lines": 49}, "missing_lines": [11], "excluded_lines": [23, 24, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 42, 43, 44, 45, 46, 47, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 77, 78, 79, 80]}}, "totals": {"covered_lines": 432, "num_statements": 467, "percent_covered": 92.50535331905782, "missing_lines": 35, "excluded_lines": 49}} \ No newline at end of file diff --git a/coverage.svg b/coverage.svg deleted file mode 100644 index 59d64b37..00000000 --- a/coverage.svg +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - - coverage - coverage - 93% - 93% - - From e0d71dbbd163c986956e61bb1325a7c918496de8 Mon Sep 17 00:00:00 2001 From: Mauro Amico Date: Tue, 19 Apr 2022 00:15:17 +0200 Subject: [PATCH 29/70] Update README.rst --- README.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index df2629f2..fecccd4a 100644 --- a/README.rst +++ b/README.rst @@ -12,8 +12,9 @@ :target: https://pypi.python.org/pypi/collective.volto.formsupport/ :alt: License -.. |Coverage| image:: https://coveralls.io/repos/github/collective/collective.volto.formsupport/badge.svg +.. image:: https://coveralls.io/repos/github/collective/collective.volto.formsupport/badge.svg :target: https://coveralls.io/github/collective/collective.volto.formsupport + :alt: Coverage ============================ From beebeefa3e9c10a94c7d4d465f5b2be0ad9de853 Mon Sep 17 00:00:00 2001 From: Ihor Sychevskyi Date: Sun, 1 May 2022 06:56:10 +0300 Subject: [PATCH 30/70] update link (#16) --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index fecccd4a..e081e902 100644 --- a/README.rst +++ b/README.rst @@ -202,4 +202,4 @@ This product was developed by **RedTurtle Technology** team. .. image:: https://avatars1.githubusercontent.com/u/1087171?s=100&v=4 :alt: RedTurtle Technology Site - :target: http://www.redturtle.it/ + :target: https://www.redturtle.it/ From c9cc1297453b40d9388c516d09689b4a41b473e9 Mon Sep 17 00:00:00 2001 From: mamico Date: Thu, 19 May 2022 10:18:15 +0200 Subject: [PATCH 31/70] captcha vocabulary --- CHANGES.rst | 3 +- .../volto/formsupport/captcha/__init__.py | 3 ++ .../volto/formsupport/captcha/configure.zcml | 13 +++++ .../volto/formsupport/captcha/hcaptcha.py | 9 ++++ .../volto/formsupport/captcha/recaptcha.py | 5 ++ .../volto/formsupport/captcha/vocabularies.py | 14 +++++ .../volto/formsupport/datamanager/catalog.py | 14 ++--- .../volto/formsupport/interfaces.py | 17 +++--- .../formsupport/restapi/serializer/blocks.py | 9 ++-- .../restapi/services/form_data/csv.py | 1 - .../restapi/services/form_data/form_data.py | 6 +-- .../restapi/services/submit_form/post.py | 17 +++--- .../volto/formsupport/tests/test_captcha.py | 53 ++++++++++++++++--- .../volto/formsupport/tests/test_event.py | 15 +++--- .../tests/test_send_action_form.py | 14 ++--- .../formsupport/tests/test_serialize_block.py | 13 +++-- .../volto/formsupport/tests/test_setup.py | 4 +- .../tests/test_store_action_form.py | 15 +++--- src/collective/volto/formsupport/upgrades.py | 4 +- 19 files changed, 162 insertions(+), 67 deletions(-) create mode 100644 src/collective/volto/formsupport/captcha/vocabularies.py diff --git a/CHANGES.rst b/CHANGES.rst index e7f44168..71aa3a08 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,7 +4,8 @@ Changelog 2.2.1 (unreleased) ------------------ -- Nothing changed yet. +- Captcha support. + [mamico] 2.2.0 (2022-04-07) diff --git a/src/collective/volto/formsupport/captcha/__init__.py b/src/collective/volto/formsupport/captcha/__init__.py index b8cf2737..0ee92c67 100644 --- a/src/collective/volto/formsupport/captcha/__init__.py +++ b/src/collective/volto/formsupport/captcha/__init__.py @@ -3,6 +3,9 @@ def __init__(self, context, request): self.context = context self.request = request + def isEnabled(self): + return True + def verify(self): """ Verify the captcha diff --git a/src/collective/volto/formsupport/captcha/configure.zcml b/src/collective/volto/formsupport/captcha/configure.zcml index 6ddffcf7..4eb24ac5 100644 --- a/src/collective/volto/formsupport/captcha/configure.zcml +++ b/src/collective/volto/formsupport/captcha/configure.zcml @@ -10,6 +10,14 @@ name="hcaptcha" /> + + + + diff --git a/src/collective/volto/formsupport/captcha/hcaptcha.py b/src/collective/volto/formsupport/captcha/hcaptcha.py index 6b2185bf..3d562711 100644 --- a/src/collective/volto/formsupport/captcha/hcaptcha.py +++ b/src/collective/volto/formsupport/captcha/hcaptcha.py @@ -11,11 +11,16 @@ class HCaptchaSupport(CaptchaSupport): + name = _("HCaptcha") + def __init__(self, context, request): super().__init__(context, request) registry = queryUtility(IRegistry) self.settings = registry.forInterface(IHCaptchaSettings, check=False) + def isEnabled(self): + return self.settings and self.settings.public_key and self.settings.private_key + def serialize(self): if not self.settings.public_key: raise ValueError( @@ -52,3 +57,7 @@ def verify(self, data): context=self.request, ) ) + + +class HCaptchaInvisibleSupport(HCaptchaSupport): + name = _("HCaptcha Invisible") diff --git a/src/collective/volto/formsupport/captcha/recaptcha.py b/src/collective/volto/formsupport/captcha/recaptcha.py index d6695462..c801ad9f 100644 --- a/src/collective/volto/formsupport/captcha/recaptcha.py +++ b/src/collective/volto/formsupport/captcha/recaptcha.py @@ -9,11 +9,16 @@ class RecaptchaSupport(CaptchaSupport): + name = _("Google ReCaptcha") + def __init__(self, context, request): super().__init__(context, request) registry = queryUtility(IRegistry) self.settings = registry.forInterface(IReCaptchaSettings, check=False) + def isEnabled(self): + return self.settings and self.settings.public_key and self.settings.private_key + def serialize(self): if not self.settings.public_key: raise ValueError( diff --git a/src/collective/volto/formsupport/captcha/vocabularies.py b/src/collective/volto/formsupport/captcha/vocabularies.py new file mode 100644 index 00000000..aa37e1ac --- /dev/null +++ b/src/collective/volto/formsupport/captcha/vocabularies.py @@ -0,0 +1,14 @@ +from ..interfaces import ICaptchaSupport +from zope.component import getAdapters +from zope.interface import provider +from zope.schema.interfaces import IVocabularyFactory +from zope.schema.vocabulary import SimpleTerm, SimpleVocabulary + + +@provider(IVocabularyFactory) +def captcha_providers_vocabulary_factory(context): + terms = [] + for name, adapter in getAdapters((context, context.REQUEST), ICaptchaSupport): + if adapter.isEnabled(): + terms.append(SimpleTerm(value=name, token=name, title=adapter.name)) + return SimpleVocabulary(terms) diff --git a/src/collective/volto/formsupport/datamanager/catalog.py b/src/collective/volto/formsupport/datamanager/catalog.py index 55656b2d..5434ca0e 100644 --- a/src/collective/volto/formsupport/datamanager/catalog.py +++ b/src/collective/volto/formsupport/datamanager/catalog.py @@ -3,22 +3,18 @@ from copy import deepcopy from datetime import datetime from plone.dexterity.interfaces import IDexterityContent +from plone.i18n.normalizer.interfaces import IIDNormalizer from plone.restapi.deserializer import json_body from repoze.catalog.catalog import Catalog from repoze.catalog.indexes.field import CatalogFieldIndex from souper.interfaces import ICatalogFactory -from souper.soup import get_soup -from souper.soup import NodeAttributeIndexer -from souper.soup import Record -from zope.component import adapter -from zope.interface import implementer -from zope.interface import Interface -from zope.component import getUtility -from plone.i18n.normalizer.interfaces import IIDNormalizer - +from souper.soup import get_soup, NodeAttributeIndexer, Record +from zope.component import adapter, getUtility +from zope.interface import implementer, Interface import logging + logger = logging.getLogger(__name__) diff --git a/src/collective/volto/formsupport/interfaces.py b/src/collective/volto/formsupport/interfaces.py index 332d56fa..df5eba27 100644 --- a/src/collective/volto/formsupport/interfaces.py +++ b/src/collective/volto/formsupport/interfaces.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -from zope.publisher.interfaces.browser import IDefaultBrowserLayer from zope.interface import Interface +from zope.publisher.interfaces.browser import IDefaultBrowserLayer class ICollectiveVoltoFormsupportLayer(IDefaultBrowserLayer): @@ -8,19 +8,19 @@ class ICollectiveVoltoFormsupportLayer(IDefaultBrowserLayer): class IFormDataStore(Interface): - def add(self, data): + def add(data): """ Add data to the store @return: record id """ - def length(self): + def length(): """ @return: number of items stored into store """ - def search(self, query): + def search(query): """ @return: items that match query """ @@ -33,10 +33,15 @@ class IPostEvent(Interface): class ICaptchaSupport(Interface): - def __init__(self, context, request): + def __init__(context, request): """Initialize adpater""" - def verify(self, data): + def is_enabled(): + """Captcha method enabled + @return: True if the method is enabled/configured + """ + + def verify(data): """Verify the captcha @return: True if verified, Raise exception otherwise """ diff --git a/src/collective/volto/formsupport/restapi/serializer/blocks.py b/src/collective/volto/formsupport/restapi/serializer/blocks.py index 23f030dd..eb81338f 100644 --- a/src/collective/volto/formsupport/restapi/serializer/blocks.py +++ b/src/collective/volto/formsupport/restapi/serializer/blocks.py @@ -1,12 +1,13 @@ # -*- coding: utf-8 -*- -from collective.volto.formsupport.interfaces import ICaptchaSupport -from collective.volto.formsupport.interfaces import ICollectiveVoltoFormsupportLayer +from collective.volto.formsupport.interfaces import ( + ICaptchaSupport, + ICollectiveVoltoFormsupportLayer, +) 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 diff --git a/src/collective/volto/formsupport/restapi/services/form_data/csv.py b/src/collective/volto/formsupport/restapi/services/form_data/csv.py index 1deb4ef7..850dfac2 100644 --- a/src/collective/volto/formsupport/restapi/services/form_data/csv.py +++ b/src/collective/volto/formsupport/restapi/services/form_data/csv.py @@ -10,7 +10,6 @@ class FormDataExportGet(Service): - def render(self): self.check_permission() diff --git a/src/collective/volto/formsupport/restapi/services/form_data/form_data.py b/src/collective/volto/formsupport/restapi/services/form_data/form_data.py index c87ded0b..520647d3 100644 --- a/src/collective/volto/formsupport/restapi/services/form_data/form_data.py +++ b/src/collective/volto/formsupport/restapi/services/form_data/form_data.py @@ -4,10 +4,8 @@ from plone.restapi.interfaces import IExpandableElement from plone.restapi.serializer.converters import json_compatible from plone.restapi.services import Service -from zope.component import adapter -from zope.component import getMultiAdapter -from zope.interface import implementer -from zope.interface import Interface +from zope.component import adapter, getMultiAdapter +from zope.interface import implementer, Interface import json import six 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 1c2326b6..71fd8eaa 100644 --- a/src/collective/volto/formsupport/restapi/services/submit_form/post.py +++ b/src/collective/volto/formsupport/restapi/services/submit_form/post.py @@ -1,4 +1,10 @@ # -*- coding: utf-8 -*- +from collective.volto.formsupport import _ +from collective.volto.formsupport.interfaces import ( + ICaptchaSupport, + IFormDataStore, + IPostEvent, +) from email.message import EmailMessage from plone import api from plone.protect.interfaces import IDisableCSRFProtection @@ -7,16 +13,11 @@ from plone.restapi.services import Service from Products.CMFPlone.interfaces.controlpanel import IMailSchema from zExceptions import BadRequest -from zope.component import getMultiAdapter -from zope.component import getUtility -from zope.interface import implementer -from zope.interface import alsoProvides +from zope.component import getMultiAdapter, getUtility from zope.event import notify -from collective.volto.formsupport import _ from zope.i18n import translate -from collective.volto.formsupport.interfaces import IFormDataStore -from collective.volto.formsupport.interfaces import IPostEvent -from collective.volto.formsupport.interfaces import ICaptchaSupport +from zope.interface import alsoProvides, implementer + import codecs import six diff --git a/src/collective/volto/formsupport/tests/test_captcha.py b/src/collective/volto/formsupport/tests/test_captcha.py index 1085efe5..89bc8ab8 100644 --- a/src/collective/volto/formsupport/tests/test_captcha.py +++ b/src/collective/volto/formsupport/tests/test_captcha.py @@ -3,20 +3,22 @@ VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING, ) 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.formwidget.recaptcha.interfaces import IReCaptchaSettings +from plone.app.testing import ( + setRoles, + SITE_OWNER_NAME, + SITE_OWNER_PASSWORD, + TEST_USER_ID, +) from plone.formwidget.hcaptcha.interfaces import IHCaptchaSettings +from plone.formwidget.recaptcha.interfaces import IReCaptchaSettings from plone.registry.interfaces import IRegistry from plone.restapi.testing import RelativeSession from Products.MailHost.interfaces import IMailHost +from unittest.mock import Mock, patch +from zope.component import getUtility + import transaction import unittest -from unittest.mock import Mock -from unittest.mock import patch -from zope.component import getUtility class TestCaptcha(unittest.TestCase): @@ -260,3 +262,38 @@ def test_hcaptcha( transaction.commit() mock_submit.assert_called_once_with("12345", "private", "127.0.0.1") self.assertEqual(response.status_code, 204) + + def test_get_vocabulary(self): + response = self.api_session.get( + "/@vocabularies/collective.volto.formsupport.captcha.providers" + ) + self.assertEqual(response.status_code, 200) + data = response.json() + # no adapters configured + self.assertTrue( + data["@id"].endswith( + "@vocabularies/collective.volto.formsupport.captcha.providers" + ) + ) + self.assertEqual(data["items_total"], 0) + self.assertEqual(data["items"], []) + + # configure recaptcha + self.registry.registerInterface(IReCaptchaSettings) + settings = self.registry.forInterface(IReCaptchaSettings) + settings.public_key = "public" + settings.private_key = "private" + transaction.commit() + response = self.api_session.get( + "/@vocabularies/collective.volto.formsupport.captcha.providers" + ) + self.assertEqual(response.status_code, 200) + data = response.json() + # no adapters configured + self.assertTrue( + data["@id"].endswith( + "@vocabularies/collective.volto.formsupport.captcha.providers" + ) + ) + self.assertEqual(data["items_total"], 1) + self.assertEqual(data["items"], [{"title": "Google ReCaptcha", "token": "recaptcha"}]) diff --git a/src/collective/volto/formsupport/tests/test_event.py b/src/collective/volto/formsupport/tests/test_event.py index 1d679cee..99145b67 100644 --- a/src/collective/volto/formsupport/tests/test_event.py +++ b/src/collective/volto/formsupport/tests/test_event.py @@ -3,18 +3,21 @@ VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING, ) 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 ( + setRoles, + SITE_OWNER_NAME, + SITE_OWNER_PASSWORD, + TEST_USER_ID, +) from plone.registry.interfaces import IRegistry from plone.restapi.testing import RelativeSession from Products.MailHost.interfaces import IMailHost -import transaction -import unittest from zope.component import getUtility from zope.configuration import xmlconfig +import transaction +import unittest + def event_handler(event): event.data["data"].append( 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 ff8faa6a..90f3fe81 100644 --- a/src/collective/volto/formsupport/tests/test_send_action_form.py +++ b/src/collective/volto/formsupport/tests/test_send_action_form.py @@ -1,12 +1,14 @@ # -*- coding: utf-8 -*- -from collective.volto.formsupport.testing import ( - VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING, # noqa: E501, +from collective.volto.formsupport.testing import ( # noqa: E501, + VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING, ) 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 ( + setRoles, + SITE_OWNER_NAME, + SITE_OWNER_PASSWORD, + TEST_USER_ID, +) from plone.registry.interfaces import IRegistry from plone.restapi.testing import RelativeSession from Products.MailHost.interfaces import IMailHost diff --git a/src/collective/volto/formsupport/tests/test_serialize_block.py b/src/collective/volto/formsupport/tests/test_serialize_block.py index bc966ec1..51999c7b 100644 --- a/src/collective/volto/formsupport/tests/test_serialize_block.py +++ b/src/collective/volto/formsupport/tests/test_serialize_block.py @@ -3,17 +3,20 @@ VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING, ) 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 ( + setRoles, + SITE_OWNER_NAME, + SITE_OWNER_PASSWORD, + TEST_USER_ID, +) from plone.formwidget.hcaptcha.interfaces import IHCaptchaSettings from plone.formwidget.recaptcha.interfaces import IReCaptchaSettings from plone.registry.interfaces import IRegistry from plone.restapi.testing import RelativeSession +from zope.component import getUtility + import transaction import unittest -from zope.component import getUtility class TestBlockSerialization(unittest.TestCase): diff --git a/src/collective/volto/formsupport/tests/test_setup.py b/src/collective/volto/formsupport/tests/test_setup.py index 49a138d5..4a7e03ec 100644 --- a/src/collective/volto/formsupport/tests/test_setup.py +++ b/src/collective/volto/formsupport/tests/test_setup.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """Setup tests for this package.""" -from collective.volto.formsupport.testing import ( - VOLTO_FORMSUPPORT_INTEGRATION_TESTING, # noqa: E501, +from collective.volto.formsupport.testing import ( # noqa: E501, + VOLTO_FORMSUPPORT_INTEGRATION_TESTING, ) from plone import api from plone.app.testing import setRoles, TEST_USER_ID 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 c64b4631..111555f2 100644 --- a/src/collective/volto/formsupport/tests/test_store_action_form.py +++ b/src/collective/volto/formsupport/tests/test_store_action_form.py @@ -2,18 +2,21 @@ from collective.volto.formsupport.testing import ( # noqa: E501, VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING, ) -import csv from io import StringIO 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 ( + setRoles, + SITE_OWNER_NAME, + SITE_OWNER_PASSWORD, + TEST_USER_ID, +) from plone.restapi.testing import RelativeSession from Products.MailHost.interfaces import IMailHost +from zope.component import getUtility + +import csv import transaction import unittest -from zope.component import getUtility class TestMailSend(unittest.TestCase): diff --git a/src/collective/volto/formsupport/upgrades.py b/src/collective/volto/formsupport/upgrades.py index f3f2431d..bea0e249 100644 --- a/src/collective/volto/formsupport/upgrades.py +++ b/src/collective/volto/formsupport/upgrades.py @@ -5,6 +5,7 @@ from plone.dexterity.utils import iterSchemata from zope.schema import getFields + try: from collective.volto.blocksfield.field import BlocksField @@ -12,8 +13,9 @@ except ImportError: HAS_BLOCKSFIELD = False -import logging import json +import logging + logger = logging.getLogger(__name__) From 6a5e284d7ab99375d717a332c7f555f4c74186d2 Mon Sep 17 00:00:00 2001 From: mamico Date: Thu, 19 May 2022 10:29:50 +0200 Subject: [PATCH 32/70] isort --- CHANGES.rst | 2 +- setup.cfg | 1 + src/collective/volto/formsupport/__init__.py | 3 +++ .../volto/formsupport/captcha/hcaptcha.py | 1 - .../volto/formsupport/captcha/vocabularies.py | 3 ++- .../volto/formsupport/datamanager/catalog.py | 16 ++++++++-------- .../formsupport/restapi/serializer/blocks.py | 9 ++++----- .../restapi/services/form_data/form_data.py | 6 ++++-- .../restapi/services/submit_form/post.py | 14 +++++++------- src/collective/volto/formsupport/testing.py | 12 +++++------- .../volto/formsupport/tests/test_captcha.py | 13 ++++++------- .../volto/formsupport/tests/test_event.py | 10 ++++------ .../formsupport/tests/test_send_action_form.py | 10 ++++------ .../formsupport/tests/test_serialize_block.py | 10 ++++------ .../volto/formsupport/tests/test_setup.py | 3 ++- .../formsupport/tests/test_store_action_form.py | 10 ++++------ src/collective/volto/formsupport/upgrades.py | 5 ++--- 17 files changed, 61 insertions(+), 67 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 71aa3a08..99083ac9 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,7 +4,7 @@ Changelog 2.2.1 (unreleased) ------------------ -- Captcha support. +- Captcha support #13. [mamico] diff --git a/setup.cfg b/setup.cfg index 5029d67f..4db9721e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -8,6 +8,7 @@ ignore = [isort] # black compatible isort rules: force_alphabetical_sort = True +force_single_line = True multi_line_output=3 include_trailing_comma=True force_grid_wrap=0 diff --git a/src/collective/volto/formsupport/__init__.py b/src/collective/volto/formsupport/__init__.py index 8a268869..248bf3c9 100644 --- a/src/collective/volto/formsupport/__init__.py +++ b/src/collective/volto/formsupport/__init__.py @@ -2,5 +2,8 @@ """Init and utils.""" from zope.i18nmessageid import MessageFactory +import logging + +logger = logging.getLogger(__name__) _ = MessageFactory("collective.volto.formsupport") diff --git a/src/collective/volto/formsupport/captcha/hcaptcha.py b/src/collective/volto/formsupport/captcha/hcaptcha.py index 3d562711..b383aa52 100644 --- a/src/collective/volto/formsupport/captcha/hcaptcha.py +++ b/src/collective/volto/formsupport/captcha/hcaptcha.py @@ -2,7 +2,6 @@ from collective.volto.formsupport import _ from plone.formwidget.hcaptcha.interfaces import IHCaptchaSettings from plone.formwidget.hcaptcha.nohcaptcha import submit - # from plone.formwidget.hcaptcha.validator import WrongCaptchaCode from plone.registry.interfaces import IRegistry from zExceptions import BadRequest diff --git a/src/collective/volto/formsupport/captcha/vocabularies.py b/src/collective/volto/formsupport/captcha/vocabularies.py index aa37e1ac..8e91e53d 100644 --- a/src/collective/volto/formsupport/captcha/vocabularies.py +++ b/src/collective/volto/formsupport/captcha/vocabularies.py @@ -2,7 +2,8 @@ from zope.component import getAdapters from zope.interface import provider from zope.schema.interfaces import IVocabularyFactory -from zope.schema.vocabulary import SimpleTerm, SimpleVocabulary +from zope.schema.vocabulary import SimpleTerm +from zope.schema.vocabulary import SimpleVocabulary @provider(IVocabularyFactory) diff --git a/src/collective/volto/formsupport/datamanager/catalog.py b/src/collective/volto/formsupport/datamanager/catalog.py index 5434ca0e..4c5ff78e 100644 --- a/src/collective/volto/formsupport/datamanager/catalog.py +++ b/src/collective/volto/formsupport/datamanager/catalog.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +from collective.volto.formsupport import logger from collective.volto.formsupport.interfaces import IFormDataStore from copy import deepcopy from datetime import datetime @@ -8,14 +9,13 @@ from repoze.catalog.catalog import Catalog from repoze.catalog.indexes.field import CatalogFieldIndex from souper.interfaces import ICatalogFactory -from souper.soup import get_soup, NodeAttributeIndexer, Record -from zope.component import adapter, getUtility -from zope.interface import implementer, Interface - -import logging - - -logger = logging.getLogger(__name__) +from souper.soup import get_soup +from souper.soup import NodeAttributeIndexer +from souper.soup import Record +from zope.component import adapter +from zope.component import getUtility +from zope.interface import implementer +from zope.interface import Interface @implementer(ICatalogFactory) diff --git a/src/collective/volto/formsupport/restapi/serializer/blocks.py b/src/collective/volto/formsupport/restapi/serializer/blocks.py index eb81338f..23f030dd 100644 --- a/src/collective/volto/formsupport/restapi/serializer/blocks.py +++ b/src/collective/volto/formsupport/restapi/serializer/blocks.py @@ -1,13 +1,12 @@ # -*- coding: utf-8 -*- -from collective.volto.formsupport.interfaces import ( - ICaptchaSupport, - ICollectiveVoltoFormsupportLayer, -) +from collective.volto.formsupport.interfaces import ICaptchaSupport +from collective.volto.formsupport.interfaces import ICollectiveVoltoFormsupportLayer 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, getMultiAdapter +from zope.component import adapter +from zope.component import getMultiAdapter from zope.interface import implementer diff --git a/src/collective/volto/formsupport/restapi/services/form_data/form_data.py b/src/collective/volto/formsupport/restapi/services/form_data/form_data.py index 520647d3..c87ded0b 100644 --- a/src/collective/volto/formsupport/restapi/services/form_data/form_data.py +++ b/src/collective/volto/formsupport/restapi/services/form_data/form_data.py @@ -4,8 +4,10 @@ from plone.restapi.interfaces import IExpandableElement from plone.restapi.serializer.converters import json_compatible from plone.restapi.services import Service -from zope.component import adapter, getMultiAdapter -from zope.interface import implementer, Interface +from zope.component import adapter +from zope.component import getMultiAdapter +from zope.interface import implementer +from zope.interface import Interface import json import six 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 71fd8eaa..2062fb4c 100644 --- a/src/collective/volto/formsupport/restapi/services/submit_form/post.py +++ b/src/collective/volto/formsupport/restapi/services/submit_form/post.py @@ -1,10 +1,8 @@ # -*- coding: utf-8 -*- from collective.volto.formsupport import _ -from collective.volto.formsupport.interfaces import ( - ICaptchaSupport, - IFormDataStore, - IPostEvent, -) +from collective.volto.formsupport.interfaces import ICaptchaSupport +from collective.volto.formsupport.interfaces import IFormDataStore +from collective.volto.formsupport.interfaces import IPostEvent from email.message import EmailMessage from plone import api from plone.protect.interfaces import IDisableCSRFProtection @@ -13,10 +11,12 @@ from plone.restapi.services import Service from Products.CMFPlone.interfaces.controlpanel import IMailSchema from zExceptions import BadRequest -from zope.component import getMultiAdapter, getUtility +from zope.component import getMultiAdapter +from zope.component import getUtility from zope.event import notify from zope.i18n import translate -from zope.interface import alsoProvides, implementer +from zope.interface import alsoProvides +from zope.interface import implementer import codecs import six diff --git a/src/collective/volto/formsupport/testing.py b/src/collective/volto/formsupport/testing.py index 7f590126..db26e50b 100644 --- a/src/collective/volto/formsupport/testing.py +++ b/src/collective/volto/formsupport/testing.py @@ -1,12 +1,10 @@ # -*- coding: utf-8 -*- from plone.app.contenttypes.testing import PLONE_APP_CONTENTTYPES_FIXTURE -from plone.app.testing import ( - applyProfile, - FunctionalTesting, - IntegrationTesting, - PloneSandboxLayer, - quickInstallProduct, -) +from plone.app.testing import applyProfile +from plone.app.testing import FunctionalTesting +from plone.app.testing import IntegrationTesting +from plone.app.testing import PloneSandboxLayer +from plone.app.testing import quickInstallProduct from plone.restapi.testing import PloneRestApiDXLayer from plone.testing import z2 diff --git a/src/collective/volto/formsupport/tests/test_captcha.py b/src/collective/volto/formsupport/tests/test_captcha.py index 89bc8ab8..61946ee1 100644 --- a/src/collective/volto/formsupport/tests/test_captcha.py +++ b/src/collective/volto/formsupport/tests/test_captcha.py @@ -3,18 +3,17 @@ VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING, ) from plone import api -from plone.app.testing import ( - setRoles, - SITE_OWNER_NAME, - SITE_OWNER_PASSWORD, - TEST_USER_ID, -) +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.formwidget.hcaptcha.interfaces import IHCaptchaSettings from plone.formwidget.recaptcha.interfaces import IReCaptchaSettings from plone.registry.interfaces import IRegistry from plone.restapi.testing import RelativeSession from Products.MailHost.interfaces import IMailHost -from unittest.mock import Mock, patch +from unittest.mock import Mock +from unittest.mock import patch from zope.component import getUtility import transaction diff --git a/src/collective/volto/formsupport/tests/test_event.py b/src/collective/volto/formsupport/tests/test_event.py index 99145b67..aba1eb14 100644 --- a/src/collective/volto/formsupport/tests/test_event.py +++ b/src/collective/volto/formsupport/tests/test_event.py @@ -3,12 +3,10 @@ VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING, ) from plone import api -from plone.app.testing import ( - setRoles, - SITE_OWNER_NAME, - SITE_OWNER_PASSWORD, - TEST_USER_ID, -) +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.registry.interfaces import IRegistry from plone.restapi.testing import RelativeSession from Products.MailHost.interfaces import IMailHost 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 90f3fe81..e7d4d443 100644 --- a/src/collective/volto/formsupport/tests/test_send_action_form.py +++ b/src/collective/volto/formsupport/tests/test_send_action_form.py @@ -3,12 +3,10 @@ VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING, ) from plone import api -from plone.app.testing import ( - setRoles, - SITE_OWNER_NAME, - SITE_OWNER_PASSWORD, - TEST_USER_ID, -) +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.registry.interfaces import IRegistry from plone.restapi.testing import RelativeSession from Products.MailHost.interfaces import IMailHost diff --git a/src/collective/volto/formsupport/tests/test_serialize_block.py b/src/collective/volto/formsupport/tests/test_serialize_block.py index 51999c7b..f7847da2 100644 --- a/src/collective/volto/formsupport/tests/test_serialize_block.py +++ b/src/collective/volto/formsupport/tests/test_serialize_block.py @@ -3,12 +3,10 @@ VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING, ) from plone import api -from plone.app.testing import ( - setRoles, - SITE_OWNER_NAME, - SITE_OWNER_PASSWORD, - TEST_USER_ID, -) +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.formwidget.hcaptcha.interfaces import IHCaptchaSettings from plone.formwidget.recaptcha.interfaces import IReCaptchaSettings from plone.registry.interfaces import IRegistry diff --git a/src/collective/volto/formsupport/tests/test_setup.py b/src/collective/volto/formsupport/tests/test_setup.py index 4a7e03ec..fcf2ff68 100644 --- a/src/collective/volto/formsupport/tests/test_setup.py +++ b/src/collective/volto/formsupport/tests/test_setup.py @@ -4,7 +4,8 @@ VOLTO_FORMSUPPORT_INTEGRATION_TESTING, ) from plone import api -from plone.app.testing import setRoles, TEST_USER_ID +from plone.app.testing import setRoles +from plone.app.testing import TEST_USER_ID import unittest 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 111555f2..aaaedbcf 100644 --- a/src/collective/volto/formsupport/tests/test_store_action_form.py +++ b/src/collective/volto/formsupport/tests/test_store_action_form.py @@ -4,12 +4,10 @@ ) from io import StringIO from plone import api -from plone.app.testing import ( - setRoles, - SITE_OWNER_NAME, - SITE_OWNER_PASSWORD, - TEST_USER_ID, -) +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.restapi.testing import RelativeSession from Products.MailHost.interfaces import IMailHost from zope.component import getUtility diff --git a/src/collective/volto/formsupport/upgrades.py b/src/collective/volto/formsupport/upgrades.py index bea0e249..710c067a 100644 --- a/src/collective/volto/formsupport/upgrades.py +++ b/src/collective/volto/formsupport/upgrades.py @@ -13,11 +13,10 @@ except ImportError: HAS_BLOCKSFIELD = False -import json -import logging +from collective.volto.formsupport import logger +import json -logger = logging.getLogger(__name__) DEFAULT_PROFILE = "profile-collective.volto.formsupport:default" From adac16017fce6c864e9d262935f0aa8a829359aa Mon Sep 17 00:00:00 2001 From: mamico Date: Thu, 19 May 2022 10:38:29 +0200 Subject: [PATCH 33/70] typo --- src/collective/volto/formsupport/captcha/configure.zcml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/collective/volto/formsupport/captcha/configure.zcml b/src/collective/volto/formsupport/captcha/configure.zcml index 4eb24ac5..207ee42c 100644 --- a/src/collective/volto/formsupport/captcha/configure.zcml +++ b/src/collective/volto/formsupport/captcha/configure.zcml @@ -15,7 +15,7 @@ factory=".hcaptcha.HCaptchaInvisibleSupport" provides="..interfaces.ICaptchaSupport" for="* zope.publisher.interfaces.browser.IBrowserRequest" - name="hcaptcha_invisble" + name="hcaptcha_invisible" /> Date: Thu, 26 May 2022 13:41:56 +0200 Subject: [PATCH 34/70] Fix stored data (#17) * fix how to store Record keys and csv export * fix tests * flake8 * flake8 * update README --- CHANGES.rst | 6 ++ README.rst | 7 ++ .../volto/formsupport/datamanager/catalog.py | 17 ++-- .../formsupport/profiles/default/metadata.xml | 2 +- .../restapi/services/form_data/csv.py | 54 ++++++++++-- .../restapi/services/form_data/form_data.py | 28 ++++-- .../tests/test_store_action_form.py | 19 ++-- src/collective/volto/formsupport/upgrades.py | 87 +++++++++++++++++++ .../volto/formsupport/upgrades.zcml | 9 ++ 9 files changed, 197 insertions(+), 32 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 99083ac9..7dd4b3ba 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,6 +4,12 @@ Changelog 2.2.1 (unreleased) ------------------ +- Breaking change: changed the way to store data keys. Now we use field_id as key for Records. + [cekk] +- Fix quoting in csv export. + [cekk] +- Generate csv columns with proper field labels, and keep the form order. + [cekk] - Captcha support #13. [mamico] diff --git a/README.rst b/README.rst index e081e902..31498380 100644 --- a/README.rst +++ b/README.rst @@ -113,6 +113,13 @@ The store is an adapter registered for *IFormDataStore* interface, so you can ov Only fields that are also in block settings are stored. Missing ones will be skipped. +Each Record stores also two *service* attributes: + +- **fields_labels**: a mapping of field ids to field labels. This is useful when we export csv files, so we can labels for the columns. +- **fields_order**: sorted list of field ids. This can be used in csv export to keep the order of fields. + +We store these attributes because the form can change over time and we want to have a snapshot of the fields in the Record. + Block serializer ================ diff --git a/src/collective/volto/formsupport/datamanager/catalog.py b/src/collective/volto/formsupport/datamanager/catalog.py index 4c5ff78e..66c33c6f 100644 --- a/src/collective/volto/formsupport/datamanager/catalog.py +++ b/src/collective/volto/formsupport/datamanager/catalog.py @@ -4,7 +4,6 @@ from copy import deepcopy from datetime import datetime from plone.dexterity.interfaces import IDexterityContent -from plone.i18n.normalizer.interfaces import IIDNormalizer from plone.restapi.deserializer import json_body from repoze.catalog.catalog import Catalog from repoze.catalog.indexes.field import CatalogFieldIndex @@ -13,7 +12,6 @@ from souper.soup import NodeAttributeIndexer from souper.soup import Record from zope.component import adapter -from zope.component import getUtility from zope.interface import implementer from zope.interface import Interface @@ -73,18 +71,17 @@ def add(self, data): fields = {x["field_id"]: x.get("label", x["field_id"]) for x in form_fields} record = Record() - # record.attrs["metadata"] = {} - normalizer = getUtility(IIDNormalizer) + fields_labels = {} + fields_order = [] for field_data in data: field_id = field_data.get("field_id", "") value = field_data.get("value", "") if field_id in fields: - id = normalizer.normalize(fields[field_id]) - record.attrs[id] = value - # record.attrs["metadata"][id] = { - # "field_id": field_id, - # "label": field.get("label", ""), - # } + record.attrs[field_id] = value + fields_labels[field_id] = fields[field_id] + fields_order.append(field_id) + record.attrs["fields_labels"] = fields_labels + record.attrs["fields_order"] = fields_order record.attrs["date"] = datetime.now() record.attrs["block_id"] = self.block_id return self.soup.add(record) diff --git a/src/collective/volto/formsupport/profiles/default/metadata.xml b/src/collective/volto/formsupport/profiles/default/metadata.xml index bfc704f7..328bd794 100644 --- a/src/collective/volto/formsupport/profiles/default/metadata.xml +++ b/src/collective/volto/formsupport/profiles/default/metadata.xml @@ -1,6 +1,6 @@ - 1100 + 1200 diff --git a/src/collective/volto/formsupport/restapi/services/form_data/csv.py b/src/collective/volto/formsupport/restapi/services/form_data/csv.py index 850dfac2..7bf397d3 100644 --- a/src/collective/volto/formsupport/restapi/services/form_data/csv.py +++ b/src/collective/volto/formsupport/restapi/services/form_data/csv.py @@ -8,8 +8,47 @@ import csv import six +SKIP_ATTRS = ["block_id", "fields_labels", "fields_order"] + class FormDataExportGet(Service): + def __init__(self, context, request): + super().__init__(context, request) + self.form_fields_order = [] + self.form_block = {} + + blocks = getattr(context, "blocks", {}) + if not blocks: + return + for id, block in blocks.items(): + block_type = block.get("@type", "") + if block_type == "form": + self.form_block = block + + if self.form_block: + for field in self.form_block.get("subblocks", []): + field_id = field["field_id"] + self.form_fields_order.append(field_id) + + def get_ordered_keys(self, record): + """ + We need this method because we want to maintain the fields order set in the form. + The form can also change during time, and each record can have different fields stored in it. + """ + record_order = record.attrs.get("fields_order", []) + if record_order: + return record_order + order = [] + # first add the keys that are currently in the form + for k in self.form_fields_order: + if k in record.attrs: + order.append(k) + # finally append the keys stored in the record but that are not in the form (maybe the form changed during time) + for k in record.attrs.keys(): + if k not in order and k not in SKIP_ATTRS: + order.append(k) + return order + def render(self): self.check_permission() @@ -32,15 +71,18 @@ def get_data(self): rows = [] for item in store.search(): data = {} - for k, v in item.attrs.items(): - if k == "block_id": + fields_labels = item.attrs.get("fields_labels", {}) + for k in self.get_ordered_keys(item): + if k in SKIP_ATTRS: continue - if k not in columns and k not in fixed_columns: - columns.append(k) - data[k] = json_compatible(v) + value = item.attrs.get(k, None) + label = fields_labels.get(k, k) + if label not in columns and label not in fixed_columns: + columns.append(label) + data[label] = json_compatible(value) rows.append(data) columns.extend(fixed_columns) - writer = csv.DictWriter(sbuf, fieldnames=columns, delimiter=",") + writer = csv.DictWriter(sbuf, fieldnames=columns, quoting=csv.QUOTE_ALL) writer.writeheader() for row in rows: writer.writerow(row) diff --git a/src/collective/volto/formsupport/restapi/services/form_data/form_data.py b/src/collective/volto/formsupport/restapi/services/form_data/form_data.py index c87ded0b..9dca2839 100644 --- a/src/collective/volto/formsupport/restapi/services/form_data/form_data.py +++ b/src/collective/volto/formsupport/restapi/services/form_data/form_data.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from collective.volto.formsupport.interfaces import IFormDataStore from plone import api +from plone.memoize import view from plone.restapi.interfaces import IExpandableElement from plone.restapi.serializer.converters import json_compatible from plone.restapi.services import Service @@ -42,21 +43,34 @@ def __call__(self, expand=False): result["form_data"] = data return result - def show_component(self): - if not api.user.has_permission("Modify portal content", obj=self.context): - return False + @property + @view.memoize + def form_block(self): blocks = getattr(self.context, "blocks", {}) if isinstance(blocks, six.text_type): blocks = json.loads(blocks) if not blocks: - return False + return {} for block in blocks.values(): if block.get("@type", "") == "form" and block.get("store", False): - return True - return False + return block + return {} + + def show_component(self): + if not api.user.has_permission("Modify portal content", obj=self.context): + return False + return self.form_block and True or False def expand_records(self, record): - data = {k: json_compatible(v) for k, v in record.attrs.items()} + fields_labels = record.attrs.get("fields_labels", {}) + data = {} + for k, v in record.attrs.items(): + if k in ["fields_labels", "fields_order"]: + continue + data[k] = { + "value": json_compatible(v), + "label": fields_labels.get(k, k), + } data["id"] = record.intid return data 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 aaaedbcf..34316fac 100644 --- a/src/collective/volto/formsupport/tests/test_store_action_form.py +++ b/src/collective/volto/formsupport/tests/test_store_action_form.py @@ -149,8 +149,11 @@ def test_store_data(self): sorted(data["items"][0].keys()), ["block_id", "date", "id", "message", "name"], ) - self.assertEqual(data["items"][0]["message"], "just want to say hi") - self.assertEqual(data["items"][0]["name"], "John") + self.assertEqual( + data["items"][0]["message"], + {"label": "Message", "value": "just want to say hi"}, + ) + self.assertEqual(data["items"][0]["name"], {"label": "Name", "value": "John"}) response = self.submit_form( data={ "from": "sally@doe.com", @@ -175,11 +178,11 @@ def test_store_data(self): sorted(data["items"][1].keys()), ["block_id", "date", "id", "message", "name"], ) - sorted_data = sorted(data["items"], key=lambda x: x["name"]) - self.assertEqual(sorted_data[0]["name"], "John") - self.assertEqual(sorted_data[0]["message"], "just want to say hi") - self.assertEqual(sorted_data[1]["name"], "Sally") - self.assertEqual(sorted_data[1]["message"], "bye") + sorted_data = sorted(data["items"], key=lambda x: x["name"]["value"]) + self.assertEqual(sorted_data[0]["name"]["value"], "John") + self.assertEqual(sorted_data[0]["message"]["value"], "just want to say hi") + self.assertEqual(sorted_data[1]["name"]["value"], "Sally") + self.assertEqual(sorted_data[1]["message"]["value"], "bye") # clear data response = self.clear_data() @@ -239,7 +242,7 @@ def test_export_csv(self): response = self.export_csv() data = [*csv.reader(StringIO(response.text), delimiter=",")] self.assertEqual(len(data), 3) - self.assertEqual(data[0], ["message", "name", "date"]) + self.assertEqual(data[0], ["Message", "Name", "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/upgrades.py b/src/collective/volto/formsupport/upgrades.py index 710c067a..1ae3aff8 100644 --- a/src/collective/volto/formsupport/upgrades.py +++ b/src/collective/volto/formsupport/upgrades.py @@ -4,6 +4,12 @@ from plone import api from plone.dexterity.utils import iterSchemata from zope.schema import getFields +from collective.volto.formsupport.interfaces import IFormDataStore +from zope.component import getMultiAdapter +from zope.globalrequest import getRequest +from souper.soup import Record +from zope.component import getUtility +from plone.i18n.normalizer.interfaces import IIDNormalizer try: @@ -79,3 +85,84 @@ def fix_block(blocks, url): if blocks: fix_block(blocks, brain.getURL()) setattr(item, name, value) + + +def to_1200(context): # noqa: C901 # pragma: no cover + logger.info("### START CONVERSION STORED DATA ###") + + def get_field_info_from_block(block, field_id): + normalizer = getUtility(IIDNormalizer) + for field in block.get("subblocks", []): + normalized_label = normalizer.normalize(field.get("label", "")) + if field_id == normalized_label: + return {"id": field["field_id"], "label": field.get("label", "")} + elif field_id == field["field_id"]: + return {"id": field["field_id"], "label": field.get("label", "")} + return {"id": field_id, "label": field_id} + + def fix_data(blocks, context): + fixed = False + for block in blocks.values(): + if block.get("@type", "") != "form": + continue + if not block.get("store", False): + continue + store = getMultiAdapter((context, getRequest()), IFormDataStore) + fixed = True + data = store.search() + for record in data: + labels = {} + new_record = Record() + for k, v in record.attrs.items(): + new_id = get_field_info_from_block(block=block, field_id=k) + new_record.attrs[new_id["id"]] = v + labels.update({new_id["id"]: new_id["label"]}) + new_record.attrs["fields_labels"] = labels + # create new entry + store.soup.add(new_record) + # remove old one + store.delete(record.intid) + return fixed + + fixed_contents = [] + # fix root + portal = api.portal.get() + portal_blocks = getattr(portal, "blocks", "") + if portal_blocks: + blocks = json.loads(portal_blocks) + res = fix_data(blocks, portal) + if res: + fixed_contents.append("/") + + # fix blocks in contents + pc = api.portal.get_tool(name="portal_catalog") + brains = pc() + tot = len(brains) + i = 0 + for brain in brains: + i += 1 + if i % 100 == 0: + logger.info("Progress: {}/{}".format(i, tot)) + item = brain.getObject() + for schema in iterSchemata(item.aq_base): + for name, field in getFields(schema).items(): + if name == "blocks": + blocks = getattr(item, "blocks", {}) + if blocks: + res = fix_data(blocks, item) + if res: + fixed_contents.append(brain.getPath()) + elif HAS_BLOCKSFIELD and isinstance(field, BlocksField): + value = field.get(item) + if not value: + continue + if isinstance(value, str): + continue + blocks = value.get("blocks", {}) + if blocks: + res = fix_data(blocks, item) + if res: + fixed_contents.append(brain.getPath()) + logger.info("Fixed {} contents:".format(len(fixed_contents))) + for path in fixed_contents: + logger.info("- {}".format(path)) diff --git a/src/collective/volto/formsupport/upgrades.zcml b/src/collective/volto/formsupport/upgrades.zcml index 57767fc6..fe14c5f1 100644 --- a/src/collective/volto/formsupport/upgrades.zcml +++ b/src/collective/volto/formsupport/upgrades.zcml @@ -11,4 +11,13 @@ handler=".upgrades.to_1100" /> + + + From 6ea61360e87f44e4c22d99e08d9194828f855447 Mon Sep 17 00:00:00 2001 From: Andrea Cecchi Date: Thu, 26 May 2022 13:44:29 +0200 Subject: [PATCH 35/70] fix manifest --- MANIFEST.in | 1 + 1 file changed, 1 insertion(+) diff --git a/MANIFEST.in b/MANIFEST.in index 33602c79..f8882c07 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,3 +4,4 @@ include *.rst include *.GPL include *.txt global-exclude *.pyc +exclude coverage.json From 073fb3f4bfc5d6b123fb03b974b915a39f0c0cb3 Mon Sep 17 00:00:00 2001 From: Andrea Cecchi Date: Thu, 26 May 2022 13:45:04 +0200 Subject: [PATCH 36/70] Preparing release 2.3.0 --- CHANGES.rst | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 7dd4b3ba..94ba30f2 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,7 +1,7 @@ Changelog ========= -2.2.1 (unreleased) +2.3.0 (2022-05-26) ------------------ - Breaking change: changed the way to store data keys. Now we use field_id as key for Records. diff --git a/setup.py b/setup.py index 1bee1c97..5c598fdb 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ setup( name="collective.volto.formsupport", - version="2.2.1.dev0", + version="2.3.0", description="Add support for customizable forms in Volto", long_description=long_description, # Get more from https://pypi.org/classifiers/ From 87c3e0282368af375a31505c918b4ec5c34195a9 Mon Sep 17 00:00:00 2001 From: Andrea Cecchi Date: Thu, 26 May 2022 13:45:32 +0200 Subject: [PATCH 37/70] Back to development: 2.3.1 --- CHANGES.rst | 6 ++++++ setup.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 94ba30f2..53039247 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,12 @@ Changelog ========= +2.3.1 (unreleased) +------------------ + +- Nothing changed yet. + + 2.3.0 (2022-05-26) ------------------ diff --git a/setup.py b/setup.py index 5c598fdb..79523322 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ setup( name="collective.volto.formsupport", - version="2.3.0", + version="2.3.1.dev0", description="Add support for customizable forms in Volto", long_description=long_description, # Get more from https://pypi.org/classifiers/ From bca468aa0525270ebe68dfe8e828ff9f3d377698 Mon Sep 17 00:00:00 2001 From: Mikel Larreategi Date: Fri, 8 Jul 2022 15:12:37 +0200 Subject: [PATCH 38/70] add norobots support (#18) --- CHANGES.rst | 4 +- README.rst | 6 +- setup.py | 4 + .../volto/formsupport/captcha/configure.zcml | 8 ++ .../volto/formsupport/captcha/norobots.py | 74 ++++++++++++ .../restapi/services/submit_form/post.py | 1 - .../volto/formsupport/tests/test_captcha.py | 105 ++++++++++++++++++ test_plone52.cfg | 3 + 8 files changed, 199 insertions(+), 6 deletions(-) create mode 100644 src/collective/volto/formsupport/captcha/norobots.py diff --git a/CHANGES.rst b/CHANGES.rst index 53039247..b47a0c64 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,8 +4,8 @@ Changelog 2.3.1 (unreleased) ------------------ -- Nothing changed yet. - +- Add collective.z3cform.norobots support + [erral] 2.3.0 (2022-05-26) ------------------ diff --git a/README.rst b/README.rst index 31498380..ce9d4345 100644 --- a/README.rst +++ b/README.rst @@ -149,9 +149,9 @@ Captcha support =============== Captcha support requires a specific name adapter that implements ``ICaptchaSupport``. -This product contains implementations for HCaptcha and Google ReCaptcha, -using plone.formwidget.hcaptcha and plone.formwidget.recaptcha respectively, -which must be included, installed and configured separately. +This product contains implementations for HCaptcha, Google ReCaptcha and questions and answers +captcha using plone.formwidget.hcaptcha, plone.formwidget.recaptcha and collective.z3cform.norobots +respectively, which must be included, installed and configured separately. During the form post, the token captcha will be verified with the defined captcha method. diff --git a/setup.py b/setup.py index 79523322..5508c453 100644 --- a/setup.py +++ b/setup.py @@ -65,6 +65,9 @@ "recaptcha": [ "plone.formwidget.recaptcha", ], + "norobots": [ + "collective.z3cform.norobots", + ], "test": [ "plone.app.testing", # Plone KGS does not use this version, because it would break @@ -76,6 +79,7 @@ "collective.MockMailHost", "plone.formwidget.hcaptcha", "plone.formwidget.recaptcha", + "collective.z3cform.norobots", ], }, entry_points=""" diff --git a/src/collective/volto/formsupport/captcha/configure.zcml b/src/collective/volto/formsupport/captcha/configure.zcml index 207ee42c..48df9679 100644 --- a/src/collective/volto/formsupport/captcha/configure.zcml +++ b/src/collective/volto/formsupport/captcha/configure.zcml @@ -26,6 +26,14 @@ name="recaptcha" /> + + Date: Fri, 8 Jul 2022 15:16:42 +0200 Subject: [PATCH 39/70] p.f.hcaptcha first working release --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 79523322..c71215f4 100644 --- a/setup.py +++ b/setup.py @@ -60,7 +60,7 @@ ], extras_require={ "hcaptcha": [ - "plone.formwidget.hcaptcha", + "plone.formwidget.hcaptcha>=1.0.1", ], "recaptcha": [ "plone.formwidget.recaptcha", From 3175afb3052e3bc0048bc92e13b4f971f8b37246 Mon Sep 17 00:00:00 2001 From: Andrea Cecchi Date: Thu, 8 Sep 2022 08:52:22 +0200 Subject: [PATCH 40/70] Preparing release 2.4.0 --- CHANGES.rst | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index b47a0c64..1a943ac4 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,7 +1,7 @@ Changelog ========= -2.3.1 (unreleased) +2.4.0 (2022-09-08) ------------------ - Add collective.z3cform.norobots support diff --git a/setup.py b/setup.py index b6dae870..5f68720f 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ setup( name="collective.volto.formsupport", - version="2.3.1.dev0", + version="2.4.0", description="Add support for customizable forms in Volto", long_description=long_description, # Get more from https://pypi.org/classifiers/ From 28f22205bf9170a141f2a16cd527712c714fd9b2 Mon Sep 17 00:00:00 2001 From: Andrea Cecchi Date: Thu, 8 Sep 2022 08:52:37 +0200 Subject: [PATCH 41/70] Back to development: 2.4.1 --- CHANGES.rst | 6 ++++++ setup.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 1a943ac4..20db79b5 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,12 @@ Changelog ========= +2.4.1 (unreleased) +------------------ + +- Nothing changed yet. + + 2.4.0 (2022-09-08) ------------------ diff --git a/setup.py b/setup.py index 5f68720f..d76fdb52 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ setup( name="collective.volto.formsupport", - version="2.4.0", + version="2.4.1.dev0", description="Add support for customizable forms in Volto", long_description=long_description, # Get more from https://pypi.org/classifiers/ From 1d05e5000194300ccd3f01e503bab2d58fff6c82 Mon Sep 17 00:00:00 2001 From: Andrea Cecchi Date: Mon, 3 Oct 2022 15:56:11 +0200 Subject: [PATCH 42/70] =?UTF-8?q?Add=20limit=20attachments=20validation.?= =?UTF-8?q?=20Can=20be=20configured=20with=20environment=20=E2=80=A6=20(#2?= =?UTF-8?q?0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add limit attachments validation. Can be configured with environment variable * fix readme * add comment for immediate send decision * fix typo * fix typo --- CHANGES.rst | 3 +- README.rst | 19 ++++ .../locales/collective.volto.formsupport.pot | 64 +++++++++--- .../collective.volto.formsupport.po | 58 +++++++++-- .../collective.volto.formsupport.po | 60 +++++++++--- .../formsupport/restapi/serializer/blocks.py | 5 + .../restapi/services/submit_form/post.py | 60 +++++++++++- .../volto/formsupport/tests/file.pdf | Bin 0 -> 74429 bytes .../tests/test_send_action_form.py | 91 ++++++++++++++++++ .../formsupport/tests/test_serialize_block.py | 50 ++++++++++ 10 files changed, 371 insertions(+), 39 deletions(-) create mode 100644 src/collective/volto/formsupport/tests/file.pdf diff --git a/CHANGES.rst b/CHANGES.rst index 20db79b5..a4e24860 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,7 +4,8 @@ Changelog 2.4.1 (unreleased) ------------------ -- Nothing changed yet. +- Add limit attachments validation. Can be configured with environment variable. + [cekk] 2.4.0 (2022-09-08) diff --git a/README.rst b/README.rst index ce9d4345..cd199f95 100644 --- a/README.rst +++ b/README.rst @@ -157,6 +157,25 @@ During the form post, the token captcha will be verified with the defined captch For captcha support `volto-form-block` version >= 2.4.0 is required. + +Attachments upload limits +========================= + +Forms can have one or more attachment field to allow users to upload some files. + +These files will be sent via mail, so it could be a good idea setting a limit to them. +For example if you use Gmail as mail server, you can't send messages with attachments > 25MB. + +There is an environment variable that you can use to set that limit (in MB):: + + [instance] + environment-vars = + FORM_ATTACHMENTS_LIMIT 25 + +By default this is not set. + +The upload limit is also passed to the frontend in the form data with the `attachments_limit` key. + Examples ======== diff --git a/src/collective/volto/formsupport/locales/collective.volto.formsupport.pot b/src/collective/volto/formsupport/locales/collective.volto.formsupport.pot index 90c7fbeb..ef5b93d9 100644 --- a/src/collective/volto/formsupport/locales/collective.volto.formsupport.pot +++ b/src/collective/volto/formsupport/locales/collective.volto.formsupport.pot @@ -1,10 +1,10 @@ -# --- PLEASE EDIT THE LINES BELOW CORRECTLY --- -# SOME DESCRIPTIVE TITLE. -# FIRST AUTHOR , YEAR. +#--- PLEASE EDIT THE LINES BELOW CORRECTLY --- +#SOME DESCRIPTIVE TITLE. +#FIRST AUTHOR , YEAR. msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" -"POT-Creation-Date: 2021-03-10 13:50+0000\n" +"POT-Creation-Date: 2022-09-20 08:23+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI +ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,39 +17,77 @@ msgstr "" "Preferred-Encodings: utf-8 latin1\n" "Domain: collective.volto.formsupport\n" -#: collective/volto/formsupport/configure.zcml:29 +#: collective/volto/formsupport/captcha/recaptcha.py:12 +msgid "Google ReCaptcha" +msgstr "" + +#: collective/volto/formsupport/captcha/hcaptcha.py:13 +msgid "HCaptcha" +msgstr "" + +#: collective/volto/formsupport/captcha/hcaptcha.py:62 +msgid "HCaptcha Invisible" +msgstr "" + +#: collective/volto/formsupport/configure.zcml:33 msgid "Installs the collective.volto.formsupport add-on." msgstr "" -#: collective/volto/formsupport/configure.zcml:38 +#: collective/volto/formsupport/captcha/hcaptcha.py:43 +#: collective/volto/formsupport/captcha/norobots.py:53 +#: collective/volto/formsupport/captcha/recaptcha.py:42 +msgid "No captcha token provided." +msgstr "" + +#: collective/volto/formsupport/captcha/norobots.py:16 +msgid "NoRobots ReCaptcha Support" +msgstr "" + +#: collective/volto/formsupport/captcha/hcaptcha.py:55 +#: collective/volto/formsupport/captcha/norobots.py:69 +#: collective/volto/formsupport/captcha/recaptcha.py:54 +msgid "The code you entered was wrong, please enter the new one." +msgstr "" + +#: collective/volto/formsupport/configure.zcml:42 msgid "Uninstalls the collective.volto.formsupport add-on." msgstr "" -#: collective/volto/formsupport/configure.zcml:29 +#: collective/volto/formsupport/configure.zcml:33 msgid "Volto: Form support" msgstr "" -#: collective/volto/formsupport/configure.zcml:38 +#: collective/volto/formsupport/configure.zcml:42 msgid "Volto: Form support (uninstall)" msgstr "" +#. Default: "Attachments too big. You uploaded ${uploaded_str}, but limit is ${max} MB. Try to compress files." +#: collective/volto/formsupport/restapi/services/submit_form/post.py:152 +msgid "attachments_too_big" +msgstr "" + #. Default: "Block with @type \"form\" and id \"$block\" not found in this context: $context" -#: collective/volto/formsupport/restapi/services/submit_form/post.py:54 +#: collective/volto/formsupport/restapi/services/submit_form/post.py:93 msgid "block_form_not_found_label" msgstr "" #. Default: "Empty form data." -#: collective/volto/formsupport/restapi/services/submit_form/post.py:80 +#: collective/volto/formsupport/restapi/services/submit_form/post.py:119 msgid "empty_form_data" msgstr "" +#. Default: "Unable to send confirm email. Please retry later or contact site administator." +#: collective/volto/formsupport/restapi/services/submit_form/post.py:66 +msgid "mail_send_exception" +msgstr "" + #. Default: "You need to set at least one form action between \"send\" and \"store\"." -#: collective/volto/formsupport/restapi/services/submit_form/post.py:69 +#: collective/volto/formsupport/restapi/services/submit_form/post.py:108 msgid "missing_action" msgstr "" #. Default: "Missing block_id" -#: collective/volto/formsupport/restapi/services/submit_form/post.py:46 +#: collective/volto/formsupport/restapi/services/submit_form/post.py:86 msgid "missing_blockid_label" msgstr "" @@ -59,6 +97,6 @@ msgid "send_mail_text" msgstr "" #. Default: "Missing required field: subject or from." -#: collective/volto/formsupport/restapi/services/submit_form/post.py:104 +#: collective/volto/formsupport/restapi/services/submit_form/post.py:230 msgid "send_required_field_missing" msgstr "" diff --git a/src/collective/volto/formsupport/locales/it/LC_MESSAGES/collective.volto.formsupport.po b/src/collective/volto/formsupport/locales/it/LC_MESSAGES/collective.volto.formsupport.po index 804814d6..9db4915f 100644 --- a/src/collective/volto/formsupport/locales/it/LC_MESSAGES/collective.volto.formsupport.po +++ b/src/collective/volto/formsupport/locales/it/LC_MESSAGES/collective.volto.formsupport.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" -"POT-Creation-Date: 2021-03-10 13:49+0000\n" +"POT-Creation-Date: 2022-09-20 08:23+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI +ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -14,39 +14,77 @@ msgstr "" "Preferred-Encodings: utf-8 latin1\n" "Domain: DOMAIN\n" -#: collective/volto/formsupport/configure.zcml:29 +#: collective/volto/formsupport/captcha/recaptcha.py:12 +msgid "Google ReCaptcha" +msgstr "" + +#: collective/volto/formsupport/captcha/hcaptcha.py:13 +msgid "HCaptcha" +msgstr "" + +#: collective/volto/formsupport/captcha/hcaptcha.py:62 +msgid "HCaptcha Invisible" +msgstr "" + +#: collective/volto/formsupport/configure.zcml:33 msgid "Installs the collective.volto.formsupport add-on." msgstr "" -#: collective/volto/formsupport/configure.zcml:38 +#: collective/volto/formsupport/captcha/hcaptcha.py:43 +#: collective/volto/formsupport/captcha/norobots.py:53 +#: collective/volto/formsupport/captcha/recaptcha.py:42 +msgid "No captcha token provided." +msgstr "Nessun token captcha fornito." + +#: collective/volto/formsupport/captcha/norobots.py:16 +msgid "NoRobots ReCaptcha Support" +msgstr "" + +#: collective/volto/formsupport/captcha/hcaptcha.py:55 +#: collective/volto/formsupport/captcha/norobots.py:69 +#: collective/volto/formsupport/captcha/recaptcha.py:54 +msgid "The code you entered was wrong, please enter the new one." +msgstr "Il codice che hai inserito è sbagliato, per favore prova con un altro." + +#: collective/volto/formsupport/configure.zcml:42 msgid "Uninstalls the collective.volto.formsupport add-on." msgstr "" -#: collective/volto/formsupport/configure.zcml:29 +#: collective/volto/formsupport/configure.zcml:33 msgid "Volto: Form support" msgstr "" -#: collective/volto/formsupport/configure.zcml:38 +#: collective/volto/formsupport/configure.zcml:42 msgid "Volto: Form support (uninstall)" msgstr "" +#. Default: "Attachments too big. You uploaded ${uploaded_str}, but limit is ${max} MB. Try to compress files." +#: collective/volto/formsupport/restapi/services/submit_form/post.py:152 +msgid "attachments_too_big" +msgstr "Allegati troppo grandi. Hai caricato ${uploaded_str}, ma il limite è di ${max} MB. Prova a comprimerli." + #. Default: "Block with @type \"form\" and id \"$block\" not found in this context: $context" -#: collective/volto/formsupport/restapi/services/submit_form/post.py:54 +#: collective/volto/formsupport/restapi/services/submit_form/post.py:93 msgid "block_form_not_found_label" msgstr "Blocco con @type \"form\" e id \"$block\" non trovato nel contesto: $context" #. Default: "Empty form data." -#: collective/volto/formsupport/restapi/services/submit_form/post.py:80 +#: collective/volto/formsupport/restapi/services/submit_form/post.py:119 msgid "empty_form_data" msgstr "Form senza dati." +#. Default: "Unable to send confirm email. Please retry later or contact site administator." +#: collective/volto/formsupport/restapi/services/submit_form/post.py:66 +msgid "mail_send_exception" +msgstr "Impossibile inviare la mail di conferma. Per favore riprova più tardi o contatta l'amministratore del sito." + #. Default: "You need to set at least one form action between \"send\" and \"store\"." -#: collective/volto/formsupport/restapi/services/submit_form/post.py:69 +#: collective/volto/formsupport/restapi/services/submit_form/post.py:108 msgid "missing_action" msgstr "Devi selezionare almeno un'azione tra \"salva\" e \"invia\"." #. Default: "Missing block_id" -#: collective/volto/formsupport/restapi/services/submit_form/post.py:46 +#: collective/volto/formsupport/restapi/services/submit_form/post.py:86 msgid "missing_blockid_label" msgstr "Campo block_id mancante." @@ -56,6 +94,6 @@ msgid "send_mail_text" msgstr "Un nuovo form è stato compilato da ${url}:" #. Default: "Missing required field: subject or from." -#: collective/volto/formsupport/restapi/services/submit_form/post.py:104 +#: collective/volto/formsupport/restapi/services/submit_form/post.py:230 msgid "send_required_field_missing" msgstr "Campo obbligatorio mancante: subject o from." diff --git a/src/collective/volto/formsupport/locales/pt_BR/LC_MESSAGES/collective.volto.formsupport.po b/src/collective/volto/formsupport/locales/pt_BR/LC_MESSAGES/collective.volto.formsupport.po index 24229841..55f7914f 100644 --- a/src/collective/volto/formsupport/locales/pt_BR/LC_MESSAGES/collective.volto.formsupport.po +++ b/src/collective/volto/formsupport/locales/pt_BR/LC_MESSAGES/collective.volto.formsupport.po @@ -1,10 +1,9 @@ msgid "" msgstr "" "Project-Id-Version: \n" -"POT-Creation-Date: 2021-03-10 13:49+0000\n" +"POT-Creation-Date: 2022-09-20 08:23+0000\n" "PO-Revision-Date: 2021-05-11 18:49-0300\n" "Last-Translator: Érico Andrei , 2021\n" -"Language: pt_BR\n" "Language-Team: Portuguese (https://www.transifex.com/plone/teams/14552/pt/)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" @@ -14,41 +13,80 @@ msgstr "" "Language-Name: Português do Brasil\n" "Preferred-Encodings: utf-8 latin1\n" "Domain: collective.volto.formsupport\n" +"Language: pt_BR\n" "X-Generator: Poedit 2.4.3\n" -#: collective/volto/formsupport/configure.zcml:29 +#: collective/volto/formsupport/captcha/recaptcha.py:12 +msgid "Google ReCaptcha" +msgstr "" + +#: collective/volto/formsupport/captcha/hcaptcha.py:13 +msgid "HCaptcha" +msgstr "" + +#: collective/volto/formsupport/captcha/hcaptcha.py:62 +msgid "HCaptcha Invisible" +msgstr "" + +#: collective/volto/formsupport/configure.zcml:33 msgid "Installs the collective.volto.formsupport add-on." msgstr "Instala o complemento collective.volto.formsupport." -#: collective/volto/formsupport/configure.zcml:38 +#: collective/volto/formsupport/captcha/hcaptcha.py:43 +#: collective/volto/formsupport/captcha/norobots.py:53 +#: collective/volto/formsupport/captcha/recaptcha.py:42 +msgid "No captcha token provided." +msgstr "" + +#: collective/volto/formsupport/captcha/norobots.py:16 +msgid "NoRobots ReCaptcha Support" +msgstr "" + +#: collective/volto/formsupport/captcha/hcaptcha.py:55 +#: collective/volto/formsupport/captcha/norobots.py:69 +#: collective/volto/formsupport/captcha/recaptcha.py:54 +msgid "The code you entered was wrong, please enter the new one." +msgstr "" + +#: collective/volto/formsupport/configure.zcml:42 msgid "Uninstalls the collective.volto.formsupport add-on." msgstr "Desinstala o complemento collective.volto.formsupport." -#: collective/volto/formsupport/configure.zcml:29 +#: collective/volto/formsupport/configure.zcml:33 msgid "Volto: Form support" msgstr "Volto: Suporte a formulários" -#: collective/volto/formsupport/configure.zcml:38 +#: collective/volto/formsupport/configure.zcml:42 msgid "Volto: Form support (uninstall)" msgstr "Volto: Suporte a formulários (Desinstalar)" +#. Default: "Attachments too big. You uploaded ${uploaded_str}, but limit is ${max} MB. Try to compress files." +#: collective/volto/formsupport/restapi/services/submit_form/post.py:152 +msgid "attachments_too_big" +msgstr "" + #. Default: "Block with @type \"form\" and id \"$block\" not found in this context: $context" -#: collective/volto/formsupport/restapi/services/submit_form/post.py:54 +#: collective/volto/formsupport/restapi/services/submit_form/post.py:93 msgid "block_form_not_found_label" msgstr "Bloco com @type \"form\" e id \"${block}\" não encontrado no contexto: $context." #. Default: "Empty form data." -#: collective/volto/formsupport/restapi/services/submit_form/post.py:80 +#: collective/volto/formsupport/restapi/services/submit_form/post.py:119 msgid "empty_form_data" msgstr "Formulário sem dados." +#. Default: "Unable to send confirm email. Please retry later or contact site administator." +#: collective/volto/formsupport/restapi/services/submit_form/post.py:66 +msgid "mail_send_exception" +msgstr "" + #. Default: "You need to set at least one form action between \"send\" and \"store\"." -#: collective/volto/formsupport/restapi/services/submit_form/post.py:69 +#: collective/volto/formsupport/restapi/services/submit_form/post.py:108 msgid "missing_action" msgstr "Você deve selecionar pelo menos uma ação entre \"salvar\" e \"enviar\"." #. Default: "Missing block_id" -#: collective/volto/formsupport/restapi/services/submit_form/post.py:46 +#: collective/volto/formsupport/restapi/services/submit_form/post.py:86 msgid "missing_blockid_label" msgstr "Campo block_id não informado" @@ -58,6 +96,6 @@ msgid "send_mail_text" msgstr "Um novo formulário foi preenchido em ${url}:" #. Default: "Missing required field: subject or from." -#: collective/volto/formsupport/restapi/services/submit_form/post.py:104 +#: collective/volto/formsupport/restapi/services/submit_form/post.py:230 msgid "send_required_field_missing" msgstr "Campo obrigatório não presente: Assunto ou remetente." diff --git a/src/collective/volto/formsupport/restapi/serializer/blocks.py b/src/collective/volto/formsupport/restapi/serializer/blocks.py index 23f030dd..90771487 100644 --- a/src/collective/volto/formsupport/restapi/serializer/blocks.py +++ b/src/collective/volto/formsupport/restapi/serializer/blocks.py @@ -9,6 +9,8 @@ from zope.component import getMultiAdapter from zope.interface import implementer +import os + class FormSerializer(object): """ """ @@ -32,6 +34,9 @@ def __call__(self, value): ICaptchaSupport, name=value["captcha"], ).serialize() + attachments_limit = os.environ.get("FORM_ATTACHMENTS_LIMIT", "") + if attachments_limit: + value["attachments_limit"] = attachments_limit 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/post.py b/src/collective/volto/formsupport/restapi/services/submit_form/post.py index 81f98111..c59ca525 100644 --- a/src/collective/volto/formsupport/restapi/services/submit_form/post.py +++ b/src/collective/volto/formsupport/restapi/services/submit_form/post.py @@ -20,6 +20,11 @@ import codecs import six +import os +import logging +import math + +logger = logging.getLogger(__name__) @implementer(IPostEvent) @@ -50,10 +55,24 @@ def reply(self): notify(PostEventService(self.context, self.form_data)) + if send_action: + try: + self.send_data() + except BadRequest as e: + raise e + except Exception as e: + logger.exception(e) + message = translate( + _( + "mail_send_exception", + default="Unable to send confirm email. Please retry later or contact site administator.", + ), + context=self.request, + ) + self.request.response.setStatus(500) + return dict(type="InternalServerError", message=message) if store_action: self.store_data() - if send_action: - self.send_data() return self.reply_no_content() @@ -104,6 +123,8 @@ def validate_form(self): context=self.request, ) ) + + self.validate_attachments() if self.block.get("captcha", False): getMultiAdapter( (self.context, self.request), @@ -111,6 +132,36 @@ def validate_form(self): name=self.block["captcha"], ).verify(self.form_data.get("captcha")) + def validate_attachments(self): + attachments_limit = os.environ.get("FORM_ATTACHMENTS_LIMIT", "") + if not attachments_limit: + return + attachments = self.form_data.get("attachments", {}) + attachments_len = 0 + for attachment in attachments.values(): + data = attachment.get("data", "") + attachments_len += (len(data) * 3) / 4 - data.count("=", -2) + if attachments_len > float(attachments_limit) * pow(1024, 2): + size_name = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB") + i = int(math.floor(math.log(attachments_len, 1024))) + p = math.pow(1024, i) + s = round(attachments_len / p, 2) + uploaded_str = "{} {}".format(s, size_name[i]) + raise BadRequest( + translate( + _( + "attachments_too_big", + default="Attachments too big. You uploaded ${uploaded_str}," + " but limit is ${max} MB. Try to compress files.", + mapping={ + "max": attachments_limit, + "uploaded_str": uploaded_str, + }, + ), + context=self.request, + ) + ) + def get_block_data(self, block_id): blocks = getattr(self.context, "blocks", {}) if not blocks: @@ -207,7 +258,6 @@ def send_data(self): msg.replace_header("Content-Type", 'text/html; charset="utf-8"') self.manage_attachments(msg=msg) - self.send_mail(msg=msg, encoding=encoding) for bcc in self.get_bcc(): @@ -245,7 +295,9 @@ def filter_parameters(self): def send_mail(self, msg, encoding): host = api.portal.get_tool(name="MailHost") - host.send(msg, charset=encoding) + # we set immediate=True because we need to catch exceptions. + # by default (False) exceptions are handled by MailHost and we can't catch them. + host.send(msg, charset=encoding, immediate=True) def manage_attachments(self, msg): attachments = self.form_data.get("attachments", {}) diff --git a/src/collective/volto/formsupport/tests/file.pdf b/src/collective/volto/formsupport/tests/file.pdf new file mode 100644 index 0000000000000000000000000000000000000000..ef9c79819496798012ceabadd92415c3b1923c26 GIT binary patch literal 74429 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

?R4wK8QIEP1Kg{=O1nOJ{EcekN1_8U3@io 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 z783�WC8d!?&@Fg^_@RiSysxSlH2wyk#3(;O_X-XKLqjzfP|yJ?3+X{WVA8HbDS-WN+Uc2Ttl!%P zM%MZ;aWxEJt6YF30EI3w0B2t`0D~@#WN2Tz`@&DQK#i6BVn@DV>0GNT07fDzAOrgc z$^d!2_f)+ALR!dvQT==;jgzw4-<#tHCnvv3W6-wWyHXlf5PfL|C`)0&zA zgUCaztt_{30OJDIx{&dUX?1tiyE@iC58d&}3t>ZB#(w?0{t^-A!u})<`CYNXcgy%b zWV}OSvIEDWP;K`qW@{>jzU{ky+WmDeie_f8V{n(_w9S|CKBt1rt@V9%+a6!RyCz2+!06=HvH7vtZC8wMC@Ku?C$qp;-+?a~RQL1OVr|G| z4B91tHCU@}G(=xl=U0u>SD<1;gPk3qWCVaZX1e-v`pVbO%DA}fwk3KpFtToX6E-ja zVrFP!zrgUhqpk=B7kej=Rj#i#)Gy^{zBAug41{AimJl7SM&?7fMrlhp+B?(_W-T}Y zSSq7MB>JW4wrNoG`V0UIo%KJ4)Vc@h=}6{M?UBu-mI=-M4Xhyy2WTXVmSmMSajsZb zcOMOTVs94OG-pYgV@BLUZRD7h1{xNc?G#Gl@$=KdeCTwlQut=2;(vk|LOY56ZGKVkOgQ+oG9Zt93Qz z_~Yh6ZZM4!sExzzue=wAwcdT~@hgDYpZRqIm_lZ(*GG6+2li22&2}NJabF4d2iCea z6OarakoguwyoHf7&zKZ>WAHB16!TaU-5-ShxFqgsqEW?ODe`XzgoJt>i1A9E+rXh- z)aAjuvRyX+9gdAK@|e#OI!#-!e`+9O=^diQY^^E6Lz@b zGB@)mBn)k3OMX_9#ldM7oHzU=6}C@SoL+KlTUPm??Tayug%LY9G6>J)N%5?5!&X#P zr$mNMe^zEh4N`VXu8+k_W9bcotFA@=tCk<}gl4^Y1p7FZcyjh#s(V z>-XAnxf}W^ttKLn(fYnO+=1n}t%7@wv@x>oKdbBRzbmnRz=*2=uvpr=4hD#*k7DQ~D35BpxwEa={y zXJKb~p|Hgh(j-IdC4C_Tx*N|X{5e6a{o)BeD2e&lX|WOP$Y*ypRH$*8UE;5rnyK4n z1VX$Gh;8ElJASS_n0D_u{mq9i39F#is^K8R`N)zB_FXLp?qH|VSmexBzDaQD1yZ6HE z{ChhyW3ojvgw%mSA6nkc^dg9aANbGd;IA%(mc%f#R%}V6HAG6RYJOT?xKc_oyijm7 z;Tw-iO2^VV=CA%z?@*7ChZ{nvCR#fp#A#0$LQt5Tct3QhORru~erR*?zduWIXAp@g z*|m%57u3j%H~W4OQqKP{mU&8F zd6)g-lJ_~rwr6({W;^QO-i0q0DTXSZEXSLx%+r6h#oI}sTTinG^tG(2o~$fnz(gg$ z&}L6WOgw*fn!kKjGfhqm#`sLMqBWI0z&@DIn1VWB9WKwr5|jELpXn$md=Ke`B^Y9>vZ@8?B7zl-i4N zw^D~F-Gh#WD)A%3I)3L}sq!TQ>kU>rqiIh;Web0Ka>g2hz0f!0D+pL_f~sXE?h9_N zg8%Ct7o5qC6=-wn^IpAki%cm9HKWUZk&Frb$ksfrRaA^wh5}%l|Kk)W<`v0F7Sp42 zu@>EEhQfS^bwXu8M6q~3P2wk%@hg48T0%g{p!YtaiV=TC9vcH!4g4exQ?XnrD@C}r zN1y!3P_Sxo3&jl0SQ%Gyzh$)nm^Juf0!vhV-;wNYicwu0o`&0D zzK-aEKBZM{*^V3$b)nDBq25pXHZF#E7)ZwOW9Mb6oMiY_cZth=(MB@FT~bI6EtmHYC};rj@NWh zMUjV|Z9lJO+_i=^o;vJD(5UWs#OXVI5`L~)wSjJkj_+-X(Pi%n5O7xs0aP~RqZf>p z<*rzwv2uUo%@O237?mNhCY zu)rGQvu)O8WSLozsW=X&=u5i6GTi@Ov(R4Vejp&`mb>Q*bR&;tkOwTabN{km%IbL? zWN15R`6|sR+jGu?6znoI$$o^;NvmsP+rzVW_)Aq5mrf)=17iSi<0jcr1RA`{@MMoy z6N=QUyPaqx5qV_!b8%<(?N1q#=K|EOGI=`Iny*!V{CB6YD!b+-Mq4ULL^V*G@Wvjt z^I0Feya+kezP`!tAXqHP>^BYe1J&)a*_j^B}tISE%EmIWoK;*LjC)h~u0bohesapPu>BUvu`UK#;9c5(c9 z`Zg3Q*8MuLNecXrNdX zK1^YS-%D2!zBXUGnrW|ow5Qe^yc#gki%qUc^ z89&nQ+?O-=7}`S@nBMsjnDxD;j=_apd9QS;+i4hi{8)O;D~Kmc$|2BPd6p?gP6~D+ z2UTYJ3YKqGOjVY?^qHh*=a+BsIoD$w%chVkOWHXLqIVuMn+DhxI&rfJ-I!y0@LJnS ziXYRPJP;gwG7d+4G(oQv`k~to@y;%BdWM$Hd85h5p{vbs+56lSC;>{NzTBGM)I_A; z8|_A1(sh^BR{4 zny_LU7E@6W&f8-&RsRgX?vrjL9p*Mft}gVjJUl-ZPUO?V=PVw8L`ej9^`7#0oV!V* z^H%U+ZYli%gPlunqs-y)YxjNi=tBqcWc>qnt^&-$jj2ZTmycj+yDIC&{_C~WNazkz zsh7Y^UiTahLd2nwx+2$pm-O}HaX&XNAsVTy6<;-d^F_2YS~rgNSr@c3#TRbHj;w}7 zc2cYy=lsZM7$TNrp)~ zs$>^isNHXo|3Gw7hyX>CV=9A3msWK>?`2^;A;d!o_H{omU8x2F8X6=D?3KY@>J zTp{%!U>6go;zxz)De5N=+$fdU8RwcMd+_JyI-xuA z`kP0Y>Vg9AQ)*RW)x(83x~jC7fOEMKA8oMm+*d#{=CsmDUG|X>4Yv0Z<@EaW@95)= zsvvW43sgLSOX%)gVelUd`E2ivih7iZYbUdDy)!j9!kO~^2LKyDpSK|Ui*uZD@oKk2 z{yq5an{l^XT2?k@Tj#1ze~JFsi5k)l+CqhC6U_-3#{`q1)>?<0tHloab>(rvcD^X-T|F(&>9Dv`TX0+1^{dMECa~&35&N9=o^A%V=lz$j zs2GZ(GA5R#eFl7Pl0;2cDfO9Mjkj1-;F!~Rdkz;mDaD=oMA1)*ZV*;oR`)f7GL*vEq!q9(O$H22&=fg&35;}(_AE@FC>{Q0W+5s2RVay zJ3|QsQy4PK6gk}&3Q0zT?jgUQCH`!zJq73zJ^k}Qfd;bd<>||5HA%ejlecisGIp5mU5S-q+O}5n zn-vD#IAWA^hYieWWS7KfUdwQ*OZpnJ zclwI+3Ippj3C103sWICIl(k;D+55dZPccft8jGD#kO+Ok*b9+^M=S) z9sm5Bit21Fph7$WeX?P0vn{3s77aaG^<=j{_AYL^MIK!5WGUj;L2#8VtWRk^nLez9 zRR!T6)2}?IMy@LT!Q1P@V*rGtfP}dPGn=yDrGcDMR7$x9(vazoD;ivY21;s#IHGcz zj}kvR32=rrMHbxxU3Vd<5w0yO=JpinpYcp=VlvjD4x3~mSI8QvJO(X$5{V&wKetU3jtkHFaz zDQzmPK_e5V?&*5-y2I4>EQI_rceL5)`%^*~Ttv?u!+~HIf8S2UC|Ei2;xqt7FZ!mwApLTJTZ)BY(ircPC=oqX zDBFw>ExLV1Kb_BZWZ#Mx<%^33rt$fK^k?m6gC!mJI8*ycxdTjOyRl|v;wD!sJS6&W z9?|DtYe0(`=e^?sI|Z+g!Y_wBY}o4`q{YPFRdreT76@R5ZvuT3_u)Q;C{rVwwKept zCoKps2i1WgATBgemXcvK$p~6a>3}I(i(fZB5>|a?y2I~zp}(0ZM{8K>M!DUH8>47% z5i9Q_ce|U^UsZwj{hoESQ=!|X!%`OKZZ~~xCnUWACka$I(dIAngwrZ-p<-D#;%JHizY+RN}2OufJKR&P&nZ>0Z3azJ=Y_d z0uk(dDw{>rEs}uY8RK+`=xi-OiBLXxBNH|AqbYY&>}I2NfEnytZ30j&B3<6pvnFQB zS3UJuXg2SQ3i^mw%!CnxJXQy92-yUW$I9MVE8m&)o>y`kjj~<&?QtLF49po8v>Y@! z566mo)r8`i@vXp0KHA6Kz%!^Y44rN}LCg^~Q1q36BTEua?@T#ZacCR`KYej-5xxr9h|Kf^X1 znODE>kSr`z!e{z;!$m2B*sY*MfbOZJc9N)sjan>ch!Wyb9|rmRq*7tW;IM+`K0K7n zARa_sF#ok5;;&JZ&IN^7CaWntRG!1U3v@vNNtV-6W`v*BCz8 z0!6)Rf=8Z&jXeb^wzNXNVYXJ4r;0yeu z=l%xL;=$mQdOr_tZ=03Iz}~peNgVK>;}X5=7@ju%O^#O`jxI&sz6LAj#RKoH07UAR zZ6=fr+5^o2y<36{DZ|Z0e@$7;etYu{NpQQ7@7hvQ;qR?#( z7F~VEGBX8me_*A>A}K{!=Fh$JM?~ zU}C87!@+{)9HH|*p*uU3iLLGH&9EAV2N(3+trG=($Gu^mk;_rG5%RClPkeuzy$NtN zRbB|RbYd3qM1UiTwPcLRG0N)}0pbio;zXwjblE7mI&!!GncUWn#u}T%71WYc&dMAL zI@ZS+dVBV!^6PNnZAe}v+!QDx@+pE1$f;`MOD+v4wh~P+V(oHa8nLaJw6on;S5r@Qv6c9s)KSyMEqMl2^x*9tHZgfRe zOhPV=XS%h=$C=qBn+SEC+*{4a&W2y>oeHH7!MOU?mit1qV zT|kE&xtc4d!h~tQx7<6m<(UtBD}#n#%zhU)Na@|v$*Lw|{;A7+DnRhCb>4r|koDr% zD0S=OhTP(fmsldud+6|&0vVs(FK|%EP$dON4}5Bbix|enu<{09wC94tnu(=a^}2&} zum#Z%A{))Ds~mPHp%mWru^2;lA_~fFRS#;wdFkj@XsiEOVdXM@hXj-+3=N|8ZwE8e-qo9=P0jc><=y800|Ri1dRR0n#~S-u#^Bb{*g1zem* zrQC455-uHz@+)Kv5`aDx ziQI5sNaH*_tN~DqX|W*`2C3{|u*yX98z9eduP3 zkfIu9s$22j2hAd#HJ(=c2;`n~n$kg?IkorWiPLf}%}KM5+R`o~zCu zB(oP9W?o1vZiO6lU-UM(9zsfjer(!OdLB&PbMGhe z3q0UniknJiy>1v7qEhqL(*s|q;*=CToah|71?$g>jXBhS)!S3d^l$}=Yx?NejmSNE70A0n)D z9vZo^x#W-V++lrH-9^YHV# zI8#F|TgUSz?OjeLVBzdkt9(QOy6=aPPrgC6wOka4_B;5Nk3!l0Vvly|(WivEvP)Fz z^)_x&%?N|nQ94!(XEcHc$Sua*(s;&=D%e{KPnISqe8eudH<@tnDx2hV7-H7xax;JM z#fxPJ-S-8E`3$RA-|MWV_EjaUH_^%mVD?!Bz)LW!GR&@bgB(e2tZZbky(+vMBibmB zx0Zrm1$|+|KCRQ?{(Sl}1uW-j5z1Qvu#($3HnT1t!Ox}X0y)4LBJGfboRqVDxKdg# zq4qeGiCE76G|K*@f9Tkt_ZGct3JOir>XsCoWTJ4B>4!&*2+YV4JQCLs(!p;(iDC_) z4xIhHMv32G+$bby2(MgiiKENrl5z&p+^1&D*ZXU6n0!N6^3K{l)tSBSI6ZC|32M(& zLQS_Gg%+4IpCGzND;I`KGEN#wfKWNB`fEtoi5KB*jdMR_&iUZ*cL+jTCD;k*xR4_* z4N~SKiO8ba?ydgnn@H=UA5u0AOasU=B*Cza4b#tF!Y&v^hxmR@6j}GF(Onm%oW>9r z9No*y;CXvR@(}Bzb<;d}=f1Y%*U^hc42b&yK$sB=FHp&D>N+EcoNDa*d}7R37qApB z-Jjj&1S^t_p?}%Sq1?7MTguRx_cv7XQ8+av!dm6m+_#kSz5Aa-I|@n4XbEtI$sAj> z9CYp*yb-j@U+LQ_T%8$!iBc9P#d+&MIcX9x-y#U9tEL^}-eNz8aUzV%DRDB^=C?V= zdSXm>OA48YeVH7`8ej8!mK+*hUn0`5jjROvv*vpq3}n64lIU!~C0C+|(@t@sL-ceR zk3*+Mm>oc0V0HS4VfA;M7aee@kZnq9xDU6Fve*S<2D9XJUH^e8x&W&D-uFpc5K_Yx z^)<>TkN3Io*GvS|K%hiv5FgS#wGoef5g=Uz1#1@+z}Df(?}L-7j-4^za*bIfp(qOL z8F>Ee`;^LohCf>8&9NErtkU!l;ycicqCws;{5m)vkq=>HlXUH1v2>WYb)0q~ccub+ z<;a5SJ$VlNjH#{>Du2iC@0_t6fTFb-5+m6JXHo33^mI2LvV<9tznEk6{FL12Wwcy< zhKU-n&VOJZO+x!$k@Q9K*d(@$^L6WYl6p2y)kDf$~E)QVGeAC zG2KlgMh}Ky|IQvk1B`v;kpg}kv&rjf4>$47PDbgBCGVn5&xi+dB**QA_=dKTJC3E; zV^x5+$xsL{hQ(-0oGrXp7bRl3ppgl-AD!I|lR+`wEAQ;ec9mFwM5CV?H|_#mhal&m zKwT_5n>PMg#=FDVK-Qu<(v@&LEo$x1*{U|!F`|r(vf!w~v@jxV$nAVe*wunS#C*hW z9nig|B&%*gQm+z#e*qiZ5-R{0g1IVJfwQ~FZL0-axEj2mP zTF=lpCZ;~b-ai?l?TT&NZaWMK#HyZ1j1~BjoJ0|_f_MNRG#7{4= zmU~OTFcmP!Vem_|Z40WpN@1Wn0J97(^!-~(F{6o#52o$+Bej;`OZoN)v2?vvt8a^j z64iB@{4p?8Tz&?CYUg(?OfqYlmxQ4j)8>i9s(e(9eOeNZA&g&mREZ(=AtcIqR$EdV zc0>bdYJYInAR0$OoelE>If|K`q4J3`S?{HbjG_6LJ>4A7Yg}czAfzL$5vO}AcC!CKr2WI_=QW2j9MO>DNk|-u+P9){6Io5pEq7t$|)6vVcR` z(&xjW`*f(LBHUdV7>Qs3sVq60o6VBH;+A!Ut5T;ycy1}axQ+}W!PDGxFI(WuG7xk% zp>`}nhL$oa#2hg_s|}bx;itT>UpmA?WOP>({tUc`I^3z~_C5y$ZdmOb!D2Vc)^ypg z&C@lUQPJ~K@~q9%V&J{+y9<9LV*Ix4`o-KS;AuM3S%45c&Tu(ei*kJ{RCV6WtGJIl zBmLVS!Prk6K;g?iFo^?E6 z*BXo;mHQ~?pJW}5>CFlBrm+0RL|?EHPlmKk-9MFGru8|~(?xuW!vGbb!=amWs&~Aj zON&?Esy$XzbB9}WxIQDpWtvB+9Od?|KxvnF8@kO4tx zIb6czs(y9WNE{fW`cngEB93$bOfn)V3RXyY@}!-z@22&GDE5S_s^@?&x8t* z7-~@sXMXo$Q845jhcbJWh56uZsJlB(;!clJ*`T5^Lw}e)b58Z0x51F;&IdSn`#`gq zvsG3xyqd{i?WS;YH|LxVSB14#hphss@b2P6P9L%`jbta4?mWKE(>=ZIdW1L|x+QYRf{Eus20y%oJuE zLuvcObvV@`JONZ%vtYj7GhQp_Bssi-{KQnhXG{wSHNT&ya>Lw>PHGIjK(R}Sp+A-+ zp0d|t5*jiQAn>=%Nns9hj4@lNQdQneC60@0vcJ#gR?9x01@=OC<)5dT;Znp&keosd zX5L#4F>OjiR?l2pG8OmOk-@A;Di4sZtQcj50r_idqmvNlc7ckEYI+kD+FmruUcx}# z`hn~ceej4~R`lPLYO5|jEJSIH1oeeVC>EH<5>d};Esk804Z$GRJNDl6s$0neV&p(G zOjXY%0b@D~X+~QYiat*VWkj+cY}A#K4(k2RYn^b3!)I*}DRayxmCUoXg~YvnM|hS< zR`KK$ky90+g0P7Sb!Ckhhnnik-r7cco3T6jiUU8@)SP2t4l>)Rxs-;MXD7YH84t-y zYpe=g;oc~rt3P+Ib$B#N<9!!-yb`V|SPb)rrP6WMogu@8iQyxL@dufF7zz_Aiuqf- zv!+lJNVYl*vGkH_e1B(Ez*a2wC0C3%{E5kWOM;-ZWr8~#=C2~39YRoAwU^y@IZ4N} z`iK~C-~5IkS1m4XX^iQ*)kV;oT+Hq|3iiJO2DNhKhD>u;?{MpJL}iSBT&NviAa>Y< zPiaD=&*v2e2*9?8E#myR>drG01z^|=Z7n#)A_?eZ>8ziZKdRY0fw%;l&K za4k3zs9oD@F5M@Lzs$CU)EPAoX-q{CaXX^JMKc} zUeS1lea#S9Ng{c}5Y60|7b>AKmFOOVSN^)L#R>Oj)HZ7+f zvaPX@!G#vI;fAQtQ#zf)_Tg_P4ajU1chEQJJ5|)W`P!-Xd&KrDeIfw~c|$9y8Y!g; zkwOr(+G$f$wV@-^f4}}E5BlS4?tHu0?dYU5>uRORmNqo_4PTwTcT&Jm5fQ!(rx&s5 zctLHKy~I~%b-f)OgGvcRwJ_OWJ%Zx~95clg zTS^C}gpI;0JW-ya+%K1LRj@g;zQ2zzV;JN?-RG!3HyDwoaEZ5KMOJ+K{YGYfNF-g~ zDdbo0IxKGf68`xzhCTSj)d+Ad*4uRff zBG^DLM4ct6w6XG|z#TyXshce|UP$)l*p9x5Xxs z@*F%^q*%oew2=lnh915FDPxo{9E#5R0$nXlSdi>ijEt&KGrf{ap<>p<>&$GRd6aDH*ZLV&z{EeN;Kg0KVa@VbFWo+aa~Z z>NfD*q&qCSqS4IEG@>U$u0b-%2FBO)Ohqe=F=?wy!8}xe#%Z|5>Cu|FSmlC`=g1cg znO)P2eQ=XwXdip_TOp~PML_5dP~-Pc%#r697~l8lkaN{F4%`nG3T7nWHPEU#Sb5}{?sYkCXXsqA$i`Vq?Q@X9Dmz^Je zA%a{{Cdc}X5_(tzK6^U_59f?3u)7CYaQxOvaSkP#Xvs`JB*Dfhs)_kDFL@(}%@`FP zB_ch1S6+0Wm$C2QHK`>Ov=12;sf>kTVLZ5Gm)&y~0;l^OVH)gd297e zYz{>Bj#fORqCMh9P^X{whtvnN(0b_Ya(BNhR}|fdX2zCz7}fpbe!T1q<8I=n>w98N z6suiJS#_Dn-l8K-a^Mh2vD^ ztejeJ+&6%7_JD~3$uMa@#%svDg5AtY@%$+M>xrj&dv>m6udt?aDG|$(PHvixRjg3(rFHa;XTv=G`bXzY zM}5`WRyi1Hy-_7%*X?|qzFh8vCr*TYPn|X{)Tz$>oa4+7D^$B9wWqkG8(qgMvo0Q& zMmpQ;T0v6gxi4JSp48qEGI>=ZP;_8sK|iAhUH9SIy(L(uVs`w4nTBeS?V5FqEOI;? zNmx9G-C!}KB1@N!2h(HFedQBF^ z+k_n|3`c&s1H0BR{E-49kJ4jox?G>&+JPu{8&dsN&Dn~n){4#4sHwryaDot_8RDra zRRUGi)G@R-AxwI!pT?yWywRmTy7}Wu4!0WXU`+lLwtztSWF4}vS7ate>tsyU_;6pK z9wV-4agHh^Oq247I}$Be!r3h2-5a~PLLsJeUBHXSdAO8bN#%0(=xKg>ZU=UE4x12+KWvdJPN3DdTI-Xm$9;{ zz!_$U0|B>SJb!RNi3y{w%WuDI^x-)s!i7@h)vgN6kz$fFcgMr?Gf{kLo*&2{sbPcT z&31V!I{4ryVE}l$!o5_YHEN<{?a2jO0`9|6$|7ahk+4uwYutoppx**FNvz|DeO_zj zFxO9xgXo!5N;T!zoTH+e+ts`95nM%2RMs~6CKTmMO=&;Xe6iIoI;Xfwm0^xF%mSa~ zXG|iTp(vAh=P%oU+g6 zbT8A$S9A2EZj_3)>CPYkV|h2yjsf%kbNRP)aTy~sQtm`hA3eLNcGfA4Q^g0iHkdxJ zS{TkzD0Gs#hhf}ov~hR46ZduClNPHDm%W0P36-$Xv>Qn^j&q)sn~%TbVHSI%<$lAL z%x{hh?ua$mMwr$)9WYBjap6kw&h?9d;8Pa$>QN3pXvfK6Mv%>&h_!-3(zg$Wo6ry(h-h_E8ZB{eTuP-6lM7feM3HC{q|!QA_7E2};OuAbT1he{I#dxja5A z<@oSUTK#$|-HSi^G}g#POmpq(x>_+9z?wCTaA_)IeTDe&9umoH9KYp`uDnXEH_x(;`~afmQM3?lX34Aq19?4carH-1rY(qEarzFx=xT6KLtN) zdGW<+U>>S1>E;qPm7l{!c0O8o|2H)A<|)>NjEv?us3DPF%L?Ys%~x0Ja~$Z~6noCb z>?gJq`WmcYZ8VXSOPgMwn{CW94DwKK|;e^O|8&K?KQ2 z9|Cc#i^s!RLv~EG&)jVKgz3AV-6M1EkDCW>B%2HZG}~TECycD;;7vr1sY1Z*fv_-$_Us6$s{v7v>AR78B&=vAhg1S zV5HfeDae^E0@K#T?l}Zyo?8Z>?!BWux9b82T!^lgc7=vB%R+i=$HrJpKmXdwlQZ&8 zYw?t|mQF0*n%ysdCH|oMhQ4AWh{$PFV3GP7j$>mG?cB5wu{xi&M9H;F8N|QX1R1{> zgBV(}Qm$ZG*oad0f3q$p;&np968729bhS zYblHM5dY8l{9h;c@UbBrcqvBQ9V{0$OP3JS`|)LyMQ>B= zc6Fb>-PCb1U;A%o!}%cSd23>BbNlc2cQrKE4r+kZ~dpyw) z+Q33vIJv2C@kqa({`g-T;X1}Pt$L)Heb>wbMx?eRz3%rdfl#thYdr~Yr-RPmRC^Wo z1)Jm{N4ZqbMiQ#YjyBzzGzXJ`n4yIk!ETTWv|MG4mmJ0!5gQ)#u8YqnIXOftj+uk8 z;y8`tnlM#37AU31)LA$`>Z~}Up2t0wX=+8;ZGUpraL7Ms-wck!YdTVYlLKo?@+DJgZ^~Yh_gfF9yc+YkB=c^3na=sY|m0vAjwSj2}B0j@J!PkW%74A|P*0 zYwm~1SG+!aghX~ERGyf&e7{InZ-A(l#e%+U9}gaMm&u(m#bu& z_&#*Hqb>Lq7V6EpCG1;O&;-5X!#lYKqnL-pAc8D3 zL%2yIJYL!LB+Fu-i@ar(zxHb3z5o3)_{Lcn0o7x5&`&z4)A^)dHkz)@!`mswr^O3_ z;mYcJ`HceOkJNKR$VHBU+e64-j(f+75EWiU?XnOqMwFeo&=zGC;hNH9y|TC2qSrHX zUDO28bWeUwbDDZ5F%Bi@Ta`j+mwg(|;jL&@lNO#?q6hk7E><1KAm73#MXA#49!)?} zgt!GC;`3%~zig<(w58&4-=XeIHRy7*=rjh)O9Eh>ae(hI9^pKEa+&{ImseQ26b1q1 zFGoNL?0aUfcqj~wkkinJn01}S!n?iVf{aPyOEY}Sy`I>m=c0QX9kg9)etKw=dv%hj zj92<3D7s%#PNK+;RM2ocpL3=MH)|k6Ir6e8TK}G=G-=8kzAMSN#_|P&G5v6X>jK*y zW-SZotqh)(grra5XBTsU8Sn@%%YYxeZc-9hIo9*8Lwc(6S=J0iq*Bc9xFYTmPIA{l z2SiO93JAPSPb^>umCQ&MiRV_~Fm4#HWhjxQe z>vyPf3*+`L>216N)Gn>43mJi`i>%(@Yc02m~DsA%6+DwO$<=W&r`GyQ{ zUQIXCF#*NCYhcW9w$#mIYFwJC7PYa=V9|)7?5i@^Q?x)-`542->Z?*#)*naFfrd^v zgAr(<3%Se_vFG|sI`^4Ax|_h zolBWEPQ7~?+^+sO^JRW_N1sPUHRBI&wno2nCC_dVkUHuP$qt+JS|3Bu{iOlMR0VIL zt7SgjX_*^1C91~Ir`1V$&(*~+hu0w@G=u&VUZ@2tC-fo1t$U;>Jj$yNbXR&1CN~r- z@c^@R6c<4wTgX&Qk5z~csoHq={Z4@!@H1jUeV(s-6Z~fezEN>918H)r(DXQHz-edW zd7*hWp?#H0sucf|kG~gu9E3|wh`L{6>b+mHJyrA>mP*mw4~L{KPuibA+`-!Una>80 zYJII)HBM4+Qg}_*+GZDuS-c5-2bM%^i;5;BMuI`ujHO`cqjt6w5dJR{ypIjAK6CSu zI!NcAn{ZHPk552nW3J?WK1VG!UZfQT2_reGL)12jm*t&ycKn6HXovWAoxWfXqFdoiN2kb}O=&l0Pa|)khE}@pvl;j!s>*?tBnm$X> zlVi2@NM0Kad<`OyrRt1?AW62k>9G?9S^Iy`$D&YCe_T+>Nj|hCeBhrDirTl;Sij;m zs8GW+CwWqZWmv~(N8xUM4E2x)&uO-4c?EM2>m7GT{dGH~y3qeFUCETLG3pD*fb(hQ zM#Uj?NSN=hv%Rd8kulqtyg3h$aL>`RoDB9}cdJje+To)oN{C@<{|Ka0;6=zp~?^Lm+3Hs9QnX$$dqnD0r;`u+}pdn%`fmdvYJ!1_XTn z-qB5rEC+81F!_Z4f%@=Ky%+iJ@@E)rvYEp^uozpH$;FS&d3j&7lmG>j%1c&Q{Psu> zV!43wzhC@xwXWKr6Hdk7l&%y@%Z#ChPOV^?HY|wHbvLXsZSxEPFA-=ftp(dr|6zKSBxz z=>GOIq?2RH0B-tRa=$vLFIRM6OpL_6NsjHf=!oC*KYkd3~Uk#(Oy4$)azWr8* z`0X4T9pU+wdP_C7>jrf-ZjRuud6F+XG1&b~5!I_`SzYi_{&V?q$rXH*V)$O%$RArx zMoH>5mAD7+he=O1`JHAbqJ}{01;1UhCny?hY?^y#V;lX~c4hA;zdiyQv9V|!y;$9+a6O{N3VwOB zJ_sxKH-|>9@k=bMA?Y9(ji@(HnshV+hkR&{PdGxS>`#bCiVDkoYxB2Y+r8*?@0VX> z?BJC}5bhuO!T86FqBrW_#hF@sf_Ce6OjF_~;UWL0{kr^z18^KT&X^vD(=**po$ffp zobE9(-7($Gm}Y8rZu&ICrjLmY)3xb7aeDIjKK?!aJ^Q}CpFiOJ{9{};jdxu8rQh3u z0aKv%;kO_IM<=9XWcc?yH|A|j4m`Blt0!0v3;0CN#e6mJtF3c~gay3;`85bN*a|Fo z6s^c_Ny7~%SnG>k#vhzPc!hZpN`oZ{dpw%8cFpXV6 zxVpIA`TWw$Ita1~k;9V!c$Gpxou}txtXbbyWxl@4s=k+5J_pqh1KR9{y3JX!kjWdc&-Y)hR$&+4|$RAH$nx z8|AvagONEULSv)j(GJp_GMi7X-XC=+P)I(ipWF*ynGl2*ziJN%p}CK>rdhry;?kuE ziBrd1&Ou50_HHzi?pQ=zc0gr^jHcn{c2^*|6x#s9=P}>E zXb?d8zMI`}kp(PW_x1+`BIKJ*ctA|AE!ngfit(s1(XaRk&EZhWzF|joJ6Rtgo^Xj% z8K!@)qZOGrEZSIL%t=Gdw`v4Zq6VdnqC@mj9tbEfhKnlSabn{dDrBfe(N%o*>co^` zY|=0a2QY$r7M2WwRn0n7%toLeB1}Wmys(6ia5--p7?%uO>D}(8PpU3VB9%wqYCaOw zh%TIKw`WDQ_f*)^BVH0dul7qHaz-+lXDhIs$0Akq9+M8=V+B$pU}2n?IPdW!AAXC% zLBoR$#uug@#>(4j#0kRub({#w5U}FSs${{rZRMbDAnD@A&&-zc%m4Q>4);8HC;FQ` z8#4QV_k9?1+#{`-SA62}lh$uL%ultyWHCOK!_n(ua0jY4-mygG#}V$Y>?Jwp-wrSz zz3&>vPS_8y7>#8dCm^8NF&g@o{a%=DnU+zqgSy=pQYHu>}<5L^;KxuZIHiV4~ ziJ0Aq0>g4ro4V~32ZBpbA49cZOMXg5?`kPdwPabWoyzs=CJ&X*B$fvcIi6U%+X;T$ z?#cM`dhb91L|@4;J~(371^H^-I&&K??gRkGS#2o4`9==4Pd*biDpPT;Mn5brJ+>+pfO<|7B}lgMaIU{&Zq_*(DCo0~Lozu)_{ zm<{(t6>xZ4UcKfnYu4@i>NPJzRMXz!vlTI9-#|KP>4sT3+=Damm7H0Gcy2Lpy~d*y z5Z+HlMXO`*#Y_ql5&KtWgtu3lA{}#pX*tN(Ry>24PnH^?L$0uXaw(hiccz81?RQ`z z$QRK_^D}`G?=*@qYV(~DO29kN8?)D=<`Tiqa)zOX4j{`kg6=qWb^4;)4EZA8Uh;Jx z!uY(YciHt3I#?8vyd@oLL*~?dBo&Yw7UEMo^)H(t)ga&57_CodYg;9vvweXlak8Wj ztd0TJEx|oM7h5{zIeX%4|2b&FzObfzt=2cx{P;3xun_AnY*ZfdQxbQQ%8vAANaB;^ z4{%PnjEzv2?Z}#NryIgW22VU@Q6>DX$M(pu$zst}Vy|DVyty_s8KNNouKPdvw=l9I{K~N9I~n+x^EBfOl_t~7v@E&5Eg7X z)rSuH1bAU?B3r&7(lh_UvaO&$%+%2D9mYyqN#g>pPYc zQ%dgPAD$we{u9d~Raa`yDP^M*@<7BLKS4H|Gt%nopKy+W*)YX^n0_ zl885$__a&8yb`7BO}7sIrsyO%-p)8Mc%!#U6VgvkIZM~e>DfVVl0E3_|4qf73t!yW zfInkNO+P99B{`CKGJozq!Bl;AB{4g0!jx}|+^5*wuAG<73jG$_3R&e{*e*^d)#FFF z9Jj~)&Aho`?4vvCJJE|dUs>FrA@$BxX~GOI=y8+68(G(F+`ZQj!AY&saruo+h}9$} zb6ZrKbD-9;?>Jv<62+2SVlk6WH%{QA)sdXk0H=Nb9ctWks(cumhOnhIO;4M>R6mR7 z7&3Tvc_D5C$`F9)F+WOm^!6*)ffFv;ApSY1FL4(B%D6i-Z@@{y1^U0Zqioxi8( zvLvCS0O2D++S8Prp81`ahvErUjJs(nWA3pSza1=mPwoze z8|2cxj|w%G6quA9Umbn`ztB+2&a$XZxL0S)?-q=J%NUbA5?abIdAtpU{Uet>(QZDF zb4LH3i-^K+i&CP_Hy%~{2_VrzVD~bGkyo4p3*i%jmb;DisAmESb$x47yAfUGx$x7X zhA>FM+gxX$M)oA(1?P}<(dWS$%)x+KVr7SC!1|VOV($if2(8$*W?><1!yPRVu!q*deNvX&s7A z&wMiq;Fi{iNqF_iXW7w{dr+ExaED5l()#P*EiSODAs{^5279dL1E1PaBj>F7&c;-t ziYs=7`s)d{sxj5KpG~7tHMBO9W1f+R{_r3x>KgA(2ppj+NxHGmygRm8-_U8}IZF}X>$1paSWA7#VJ$rlxA%_OYF8lp|UqeBi?hWs)cKdYBS&ud6o_DyrLV-FCkbx~&E8UpP}p23y5G6nw8*p(s{-Z0Xx=6N zBbM_{0$y(i%0w&NveqDP9(z?3I~w~9UZdFetLG%s8ObNhq4ms-scILDlh@>0|KO+A zePwxoP*PyC<2#N}Fpm!YnX#668rE=cFv^G-4ssK2rd`l2OIT9;X-Lf@DxI9bsqGal z@I?s{5UY7EOdmq&aC&i3{n>cE{hfWMT%NgyJT6_bGsxOfdA^$XrI;}Suspk&N)xOS zFIF7(n$$wWMNJ;+Ei+poPIro}FdNRAsdL+88^+Bypo#5Oy`YkmjxG!@o|5SgFVQDb z^6m;~}Vb*$AMIUzkQBugrvS@vq$)Yk3X0JV1>wMWv$He1vr7)h zdAHyJxg+-l949clJG(bBJG0Ek9d~?0D`N}>i5VvLkV zi3JE1J}MFexhRqdm0!>F?9A?IQk4H0@1thA`@Priy?*cY>-nSKx8A#U_o(+*?7uoT z-M-tIJJfsqwrKkN$a&*mIQgPAp>AtDI%bivWXAaR^H1)0?)a(PEyuL!Z(XycdEL|} zM*i!85xcYZzVP^@dplRWvFPP^p4sD-t@Zr0@+B*4%byL$vZ(8<~S00>r z;#K>qQNsPWUH|6Rb5rZC+MSuWci{fcJ4f8T{^eO^ZH}6-dXnB*4WV#7dPJg z{cU59T(kGaG5gNU8F_l$kaNf1w@0Qbnv^ErdGF{oT0CqcT6w&01U-n?PUruO;Is4r!QTKjf9`oQLkf1UC(an!1sU;J&^eaohF z?|m=jm+t1)d~f&Yp4~Iwtv~t8FMi?Ffep89*;G^a9)D@(bqCko^!k^UM&3NP zbN>9X8=Q*=#3$Q~>jsZc8T)f-*NCqz&OLp2d+(1n4Vk~1n!0Cl-a0g<{Ycg2vs?A& zCfvQt{$R(bZ6B7cWXx!!LSt@Be0Z9|mr-uTjnA6jRYN3aU0n2$whIUWIUS z#6E48~+p#@cxNyRWQUwoqYGwV=MD=3pr@Mxg+m_VgEf6i^2#)c_^;x=_M-GC8I03b}q0 zmcA*}<KaW3iMBvm7R&?2DXX)G~}(mOT~!=>z5&rp6F72B0zB=&WD&5vgvBa$&G| zZp_yybuioxhT9Rw?Fev|u<`aD!-TTh5IAIpDzhOuj4HEf z<$#3jDMm&Vz|O?>B!v-C7!f5*1VH}Ir!qK|!LhL8?UQN%r%=V{Y;uDk84Sq?6E}RI z0NDU*22+f~a2dN1b>aT>ml6IJ8c<$gmc#F841#P_DPBSl>$MB(AdzA@It!~&^B}aI7@Ad;( zY2$|1d%>sNd;Os52M|BdxK@MW)?A4!=(6BbP<8CZybu}`v48pA&YtY?kr_oPlK|gr zlU4Pcs;f3O9lH0H@4T#q%t0k9upIXUfi-iF3PF=?F9qbnh&Xi5En0kr3S880L#dzzt%S zkc<&ADiQ(3i{lZD$7A4yVt_`FZTZERmr}|;H&^nc++7Jgg$?G4x|*bYi3D99O3kAv zpzXn-UE#;)SPahpF0eb)U+#$ zQEFwOn_XdVt0`u4=(>fkjYecT(4JacI|sW=TGn+s-rj>RX87~qzCt?bhah|{5o&pn zk0qlXCL!H5iEhNBdQ^{3fapP5y(hrHpVnU4MNU4v9nDmF}ejls?##Y$}<1O#wBVuxRUo3{D zDZ}L>@mM82^d(`$WpF|=O3kXQk59%E!{?CVaH%plAxXczmG|+HL;_1(2FD9Rq7s)E z;`HcK1}6!860TX^7m?sP<$ZiIAy&$ZVmZTfDn*mgN?zb}@ya>GC@)s7hbSgKMBW%m z@|5!;Kv$J>h|x#_4pSzF7>y2FBM~v95;vX@hVv&T5l|}VNkXzxry}B8r5sWuM%}iI zmv|yZ>!p|VNr;M-dX`X$G6aW2P1O{$3ZB`d=L|yUqK@5aAn$SA9*s72K%vHu(nOYx U48wH?Nr*?2Ra2& Date: Tue, 4 Oct 2022 14:36:59 +0200 Subject: [PATCH 43/70] Export also compiling date in csv (#21) --- CHANGES.rst | 3 ++- .../volto/formsupport/restapi/services/form_data/csv.py | 4 ++++ .../volto/formsupport/tests/test_store_action_form.py | 7 ++++++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index a4e24860..315688ba 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -6,7 +6,8 @@ Changelog - Add limit attachments validation. Can be configured with environment variable. [cekk] - +- Export also compiling date in csv. + [cekk] 2.4.0 (2022-09-08) ------------------ diff --git a/src/collective/volto/formsupport/restapi/services/form_data/csv.py b/src/collective/volto/formsupport/restapi/services/form_data/csv.py index 7bf397d3..6e7e4b46 100644 --- a/src/collective/volto/formsupport/restapi/services/form_data/csv.py +++ b/src/collective/volto/formsupport/restapi/services/form_data/csv.py @@ -80,6 +80,10 @@ def get_data(self): if label not in columns and label not in fixed_columns: columns.append(label) data[label] = json_compatible(value) + for k in fixed_columns: + # add fixed columns values + value = item.attrs.get(k, None) + data[k] = json_compatible(value) rows.append(data) columns.extend(fixed_columns) writer = csv.DictWriter(sbuf, fieldnames=columns, quoting=csv.QUOTE_ALL) 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 34316fac..e671beca 100644 --- a/src/collective/volto/formsupport/tests/test_store_action_form.py +++ b/src/collective/volto/formsupport/tests/test_store_action_form.py @@ -2,6 +2,7 @@ from collective.volto.formsupport.testing import ( # noqa: E501, VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING, ) +from datetime import datetime from io import StringIO from plone import api from plone.app.testing import setRoles @@ -212,7 +213,6 @@ def test_export_csv(self): }, } transaction.commit() - response = self.submit_form( data={ "from": "john@doe.com", @@ -246,3 +246,8 @@ def test_export_csv(self): 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)) From c90605de873e8a0873be64151842fe60518792be Mon Sep 17 00:00:00 2001 From: Andrea Cecchi Date: Tue, 4 Oct 2022 14:37:52 +0200 Subject: [PATCH 44/70] Preparing release 2.5.0 --- CHANGES.rst | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 315688ba..ff0935e6 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,7 +1,7 @@ Changelog ========= -2.4.1 (unreleased) +2.5.0 (2022-10-04) ------------------ - Add limit attachments validation. Can be configured with environment variable. diff --git a/setup.py b/setup.py index d76fdb52..4cb3821d 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ setup( name="collective.volto.formsupport", - version="2.4.1.dev0", + version="2.5.0", description="Add support for customizable forms in Volto", long_description=long_description, # Get more from https://pypi.org/classifiers/ From 5479efeec31b55143f2a5b420ba1bb69fe7d004e Mon Sep 17 00:00:00 2001 From: Andrea Cecchi Date: Tue, 4 Oct 2022 14:38:08 +0200 Subject: [PATCH 45/70] Back to development: 2.5.1 --- CHANGES.rst | 6 ++++++ setup.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index ff0935e6..fd333f4a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,12 @@ Changelog ========= +2.5.1 (unreleased) +------------------ + +- Nothing changed yet. + + 2.5.0 (2022-10-04) ------------------ diff --git a/setup.py b/setup.py index 4cb3821d..6aaf561f 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ setup( name="collective.volto.formsupport", - version="2.5.0", + version="2.5.1.dev0", description="Add support for customizable forms in Volto", long_description=long_description, # Get more from https://pypi.org/classifiers/ From 83c543b823f182125f80c37519843e65d2cd7185 Mon Sep 17 00:00:00 2001 From: Andrea Cecchi Date: Mon, 7 Nov 2022 10:14:18 +0100 Subject: [PATCH 46/70] Honeypot support (#19) * add collective.honeypot support * add collective.honeypot support * remove unused imports * fix readme * remove unused dependency * fix test for volto support * remove unused import * fix honeypot version --- .gitignore | 3 +- CHANGES.rst | 3 +- README.rst | 26 +- base.cfg | 8 +- setup.py | 7 +- .../volto/formsupport/captcha/configure.zcml | 8 + .../volto/formsupport/captcha/honeypot.py | 43 +++ src/collective/volto/formsupport/testing.py | 2 + .../volto/formsupport/tests/test_captcha.py | 40 ++- .../volto/formsupport/tests/test_honeypot.py | 298 ++++++++++++++++++ 10 files changed, 413 insertions(+), 25 deletions(-) create mode 100644 src/collective/volto/formsupport/captcha/honeypot.py create mode 100644 src/collective/volto/formsupport/tests/test_honeypot.py diff --git a/.gitignore b/.gitignore index f24e0133..06176552 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -.coverage +.DS_Store .python-version *.egg-info *.log @@ -10,6 +10,7 @@ bin/ buildout-cache/ develop-eggs/ eggs/ +extras/ htmlcov/ include/ lib/ diff --git a/CHANGES.rst b/CHANGES.rst index fd333f4a..4f88c0bd 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,7 +4,8 @@ Changelog 2.5.1 (unreleased) ------------------ -- Nothing changed yet. +- Add collective.norobots support. + [cekk] 2.5.0 (2022-10-04) diff --git a/README.rst b/README.rst index cd199f95..31c4dddf 100644 --- a/README.rst +++ b/README.rst @@ -149,14 +149,33 @@ Captcha support =============== Captcha support requires a specific name adapter that implements ``ICaptchaSupport``. -This product contains implementations for HCaptcha, Google ReCaptcha and questions and answers -captcha using plone.formwidget.hcaptcha, plone.formwidget.recaptcha and collective.z3cform.norobots -respectively, which must be included, installed and configured separately. +This product contains implementations for: + +- HCaptcha (plone.formwidget.hcaptcha) +- Google ReCaptcha (plone.formwidget.recaptcha) +- Custom questions and answers (collective.z3cform.norobots) +- Honeypot (collective.honeypot) + + +Each implementation must be included, installed and configured separately. + +To include one implementation, you need to install the egg with the needed extras_require: + +- collective.volto.formsupport[recaptcha] +- collective.volto.formsupport[hcaptcha] +- collective.volto.formsupport[norobots] +- collective.volto.formsupport[honeypot] During the form post, the token captcha will be verified with the defined captcha method. For captcha support `volto-form-block` version >= 2.4.0 is required. +Honeypot configuration +---------------------- + +If honeypot dependency is available in the buildout, the honeypot validation is enabled and selectable in forms. + +Default field name is `protected_1` and you can change it with an environment variable. See `collective.honeypot `_ for details. Attachments upload limits ========================= @@ -184,7 +203,6 @@ This add-on can be seen in action at the following sites: - https://www.comune.modena.it/form/contatti - Translations ============ diff --git a/base.cfg b/base.cfg index 9e930505..707264be 100644 --- a/base.cfg +++ b/base.cfg @@ -18,7 +18,9 @@ parts = vscode develop = . - +sources-dir = extras +auto-checkout = * +always-checkout = force [instance] recipe = plone.recipe.zope2instance @@ -26,6 +28,7 @@ user = admin:admin http-address = 8080 environment-vars = zope_i18n_compile_mo_files true + eggs = Plone Pillow @@ -121,3 +124,6 @@ scripts = [versions] # Don't use a released version of collective.volto.formsupport collective.volto.formsupport = + +[sources] + diff --git a/setup.py b/setup.py index 6aaf561f..d82066be 100644 --- a/setup.py +++ b/setup.py @@ -40,7 +40,6 @@ "PyPI": "https://pypi.python.org/pypi/collective.volto.formsupport", "Source": "https://github.com/collective/collective.volto.formsupport", "Tracker": "https://github.com/collective/collective.volto.formsupport/issues", - # 'Documentation': 'https://collective.volto.formsupport.readthedocs.io/en/latest/', }, license="GPL version 2", packages=find_packages("src", exclude=["ez_setup"]), @@ -51,12 +50,12 @@ python_requires=">=3.6", install_requires=[ "setuptools", - # -*- Extra requirements: -*- "z3c.jbot", "plone.api>=1.8.4", "plone.restapi", "plone.app.dexterity", "souper.plone", + "collective.honeypot>=2.1.1", ], extras_require={ "hcaptcha": [ @@ -68,6 +67,9 @@ "norobots": [ "collective.z3cform.norobots", ], + "honeypot": [ + "collective.honeypot", + ], "test": [ "plone.app.testing", # Plone KGS does not use this version, because it would break @@ -80,6 +82,7 @@ "plone.formwidget.hcaptcha", "plone.formwidget.recaptcha", "collective.z3cform.norobots", + "collective.honeypot", ], }, entry_points=""" diff --git a/src/collective/volto/formsupport/captcha/configure.zcml b/src/collective/volto/formsupport/captcha/configure.zcml index 48df9679..1265aa80 100644 --- a/src/collective/volto/formsupport/captcha/configure.zcml +++ b/src/collective/volto/formsupport/captcha/configure.zcml @@ -33,6 +33,14 @@ for="* zope.publisher.interfaces.browser.IBrowserRequest" name="norobots-captcha" /> + + Date: Mon, 7 Nov 2022 10:15:27 +0100 Subject: [PATCH 47/70] fix changelog --- CHANGES.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 4f88c0bd..343c9974 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,7 +4,7 @@ Changelog 2.5.1 (unreleased) ------------------ -- Add collective.norobots support. +- Add collective.honeypot support. [cekk] From a2344659cb4efe32a5bae5f18d2bb43dd83d492c Mon Sep 17 00:00:00 2001 From: Andrea Cecchi Date: Mon, 7 Nov 2022 10:15:43 +0100 Subject: [PATCH 48/70] Preparing release 2.6.0 --- CHANGES.rst | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 343c9974..05c7ea57 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,7 +1,7 @@ Changelog ========= -2.5.1 (unreleased) +2.6.0 (2022-11-07) ------------------ - Add collective.honeypot support. diff --git a/setup.py b/setup.py index d82066be..c4caaa2e 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ setup( name="collective.volto.formsupport", - version="2.5.1.dev0", + version="2.6.0", description="Add support for customizable forms in Volto", long_description=long_description, # Get more from https://pypi.org/classifiers/ From f990657574bef1264c1dad6bd13b605c65914bd2 Mon Sep 17 00:00:00 2001 From: Andrea Cecchi Date: Mon, 7 Nov 2022 10:16:03 +0100 Subject: [PATCH 49/70] Back to development: 2.6.1 --- CHANGES.rst | 6 ++++++ setup.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 05c7ea57..7f15d0a2 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,12 @@ Changelog ========= +2.6.1 (unreleased) +------------------ + +- Nothing changed yet. + + 2.6.0 (2022-11-07) ------------------ diff --git a/setup.py b/setup.py index c4caaa2e..c8092a40 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ setup( name="collective.volto.formsupport", - version="2.6.0", + version="2.6.1.dev0", description="Add support for customizable forms in Volto", long_description=long_description, # Get more from https://pypi.org/classifiers/ From 004ed89f1f4da11618f22125e9c5483a4a792608 Mon Sep 17 00:00:00 2001 From: Andrea Cecchi Date: Mon, 7 Nov 2022 10:18:44 +0100 Subject: [PATCH 50/70] fix dependencies --- setup.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/setup.py b/setup.py index c8092a40..a6492c36 100644 --- a/setup.py +++ b/setup.py @@ -55,7 +55,6 @@ "plone.restapi", "plone.app.dexterity", "souper.plone", - "collective.honeypot>=2.1.1", ], extras_require={ "hcaptcha": [ @@ -68,7 +67,7 @@ "collective.z3cform.norobots", ], "honeypot": [ - "collective.honeypot", + "collective.honeypot>=2.1.1", ], "test": [ "plone.app.testing", From 8e07440786267804393f6c2b111bd38942df40e1 Mon Sep 17 00:00:00 2001 From: Andrea Cecchi Date: Mon, 7 Nov 2022 10:19:06 +0100 Subject: [PATCH 51/70] fix dependencies --- CHANGES.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 7f15d0a2..9b08888e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,8 +4,8 @@ Changelog 2.6.1 (unreleased) ------------------ -- Nothing changed yet. - +- Fix dependencies. + [cekk] 2.6.0 (2022-11-07) ------------------ From e4e6d348d6743de166c7f1a548935e1ee688bfb2 Mon Sep 17 00:00:00 2001 From: Andrea Cecchi Date: Mon, 7 Nov 2022 10:19:18 +0100 Subject: [PATCH 52/70] Preparing release 2.6.1 --- CHANGES.rst | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 9b08888e..bfc25a51 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,7 +1,7 @@ Changelog ========= -2.6.1 (unreleased) +2.6.1 (2022-11-07) ------------------ - Fix dependencies. diff --git a/setup.py b/setup.py index a6492c36..afc27ca7 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ setup( name="collective.volto.formsupport", - version="2.6.1.dev0", + version="2.6.1", description="Add support for customizable forms in Volto", long_description=long_description, # Get more from https://pypi.org/classifiers/ From 6601a03626c850510b39f9623300b5dcc2c8ec92 Mon Sep 17 00:00:00 2001 From: Andrea Cecchi Date: Mon, 7 Nov 2022 10:19:32 +0100 Subject: [PATCH 53/70] Back to development: 2.6.2 --- CHANGES.rst | 6 ++++++ setup.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index bfc25a51..fc508abf 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,12 @@ Changelog ========= +2.6.2 (unreleased) +------------------ + +- Nothing changed yet. + + 2.6.1 (2022-11-07) ------------------ diff --git a/setup.py b/setup.py index afc27ca7..3f108240 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ setup( name="collective.volto.formsupport", - version="2.6.1", + version="2.6.2.dev0", description="Add support for customizable forms in Volto", long_description=long_description, # Get more from https://pypi.org/classifiers/ From 9c7a4119011a2c19e4072895306cc9d00234718c Mon Sep 17 00:00:00 2001 From: Andrea Cecchi Date: Mon, 7 Nov 2022 11:23:11 +0100 Subject: [PATCH 54/70] fix honeypot version --- CHANGES.rst | 4 ++-- setup.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index fc508abf..f4226596 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,8 +4,8 @@ Changelog 2.6.2 (unreleased) ------------------ -- Nothing changed yet. - +- Fix collective.honeypot version. + [cekk] 2.6.1 (2022-11-07) ------------------ diff --git a/setup.py b/setup.py index 3f108240..3b6c856a 100644 --- a/setup.py +++ b/setup.py @@ -67,7 +67,7 @@ "collective.z3cform.norobots", ], "honeypot": [ - "collective.honeypot>=2.1.1", + "collective.honeypot>=2.1", ], "test": [ "plone.app.testing", From d2696273312333d16ae327493f55d57899c66757 Mon Sep 17 00:00:00 2001 From: Andrea Cecchi Date: Mon, 7 Nov 2022 11:23:24 +0100 Subject: [PATCH 55/70] Preparing release 2.6.2 --- CHANGES.rst | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index f4226596..9d6a2d91 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,7 +1,7 @@ Changelog ========= -2.6.2 (unreleased) +2.6.2 (2022-11-07) ------------------ - Fix collective.honeypot version. diff --git a/setup.py b/setup.py index 3b6c856a..fc69abb9 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ setup( name="collective.volto.formsupport", - version="2.6.2.dev0", + version="2.6.2", description="Add support for customizable forms in Volto", long_description=long_description, # Get more from https://pypi.org/classifiers/ From 9006ec0b7d2538d6b89e533c9d545d33da3a5b66 Mon Sep 17 00:00:00 2001 From: Andrea Cecchi Date: Mon, 7 Nov 2022 11:23:39 +0100 Subject: [PATCH 56/70] Back to development: 2.6.3 --- CHANGES.rst | 6 ++++++ setup.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 9d6a2d91..5fee4fca 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,12 @@ Changelog ========= +2.6.3 (unreleased) +------------------ + +- Nothing changed yet. + + 2.6.2 (2022-11-07) ------------------ diff --git a/setup.py b/setup.py index fc69abb9..9eefe33e 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ setup( name="collective.volto.formsupport", - version="2.6.2", + version="2.6.3.dev0", description="Add support for customizable forms in Volto", long_description=long_description, # Get more from https://pypi.org/classifiers/ From 010f8f60231a83b260716b96f92aedaa78912cdc Mon Sep 17 00:00:00 2001 From: Mauro Amico Date: Fri, 3 Mar 2023 17:01:17 +0100 Subject: [PATCH 57/70] docs: badges --- README.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 31c4dddf..d16f1d8c 100644 --- a/README.rst +++ b/README.rst @@ -6,7 +6,9 @@ :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 :alt: Supported - Python Versions +.. 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 .. image:: https://img.shields.io/pypi/l/collective.volto.formsupport.svg :target: https://pypi.python.org/pypi/collective.volto.formsupport/ From 47d6fbb6c8979e6dc71d2cc6f89f4243ad678f39 Mon Sep 17 00:00:00 2001 From: Mauro Amico Date: Mon, 6 Mar 2023 11:14:59 +0100 Subject: [PATCH 58/70] add content-transfer-encoding customization (#26) --- CHANGES.rst | 3 ++- README.rst | 15 ++++++++++++++ .../restapi/services/submit_form/post.py | 20 +++++++++---------- 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 5fee4fca..067c96eb 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,7 +4,8 @@ Changelog 2.6.3 (unreleased) ------------------ -- Nothing changed yet. +- Override content-transfer-encoding using `MAIL_CONTENT_TRANSFER_ENCODING` env + [mamico] 2.6.2 (2022-11-07) diff --git a/README.rst b/README.rst index d16f1d8c..1c7c6cab 100644 --- a/README.rst +++ b/README.rst @@ -197,6 +197,21 @@ By default this is not set. The upload limit is also passed to the frontend in the form data with the `attachments_limit` key. +Content-transfer-encoding +========================= + +It is possible to set the content-transfer-encoding for the email body, settings the environment +variable `MAIL_CONTENT_TRANSFER_ENCODING`:: + + [instance] + environment-vars = + MAIL_CONTENT_TRANSFER_ENCODING base64 + +This is useful for some SMTP servers that have problems with `quoted-printable` encoding. + +By default the content-transfer-encoding is `quoted-printable` as overrided in +https://github.com/zopefoundation/Products.MailHost/blob/master/src/Products/MailHost/MailHost.py#L65 + Examples ======== 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 c59ca525..7e5481b5 100644 --- a/src/collective/volto/formsupport/restapi/services/submit_form/post.py +++ b/src/collective/volto/formsupport/restapi/services/submit_form/post.py @@ -19,12 +19,14 @@ from zope.interface import implementer import codecs -import six -import os import logging import math +import os +import six + logger = logging.getLogger(__name__) +CTE = os.environ.get("MAIL_CONTENT_TRANSFER_ENCODING", None) @implementer(IPostEvent) @@ -245,25 +247,23 @@ def send_data(self): registry = getUtility(IRegistry) mail_settings = registry.forInterface(IMailSchema, prefix="plone") mto = self.block.get("default_to", mail_settings.email_from_address) - encoding = registry.get("plone.email_charset", "utf-8") + charset = registry.get("plone.email_charset", "utf-8") message = self.prepare_message() msg = EmailMessage() - msg.set_content(message) + msg.set_content(message, charset=charset, subtype="html", cte=CTE) msg["Subject"] = subject msg["From"] = mfrom msg["To"] = mto msg["Reply-To"] = mreply_to - msg.replace_header("Content-Type", 'text/html; charset="utf-8"') - self.manage_attachments(msg=msg) - self.send_mail(msg=msg, encoding=encoding) + self.send_mail(msg=msg, charset=charset) for bcc in self.get_bcc(): # send a copy also to the fields with bcc flag msg.replace_header("To", bcc) - self.send_mail(msg=msg, encoding=encoding) + self.send_mail(msg=msg, charset=charset) def prepare_message(self): message_template = api.content.get_view( @@ -293,11 +293,11 @@ def filter_parameters(self): if x.get("field_id", "") not in skip_fields ] - def send_mail(self, msg, encoding): + def send_mail(self, msg, charset): host = api.portal.get_tool(name="MailHost") # we set immediate=True because we need to catch exceptions. # by default (False) exceptions are handled by MailHost and we can't catch them. - host.send(msg, charset=encoding, immediate=True) + host.send(msg, charset=charset, immediate=True) def manage_attachments(self, msg): attachments = self.form_data.get("attachments", {}) From 536b02534d7236ac3a7a0836424c9bebb1aa1be8 Mon Sep 17 00:00:00 2001 From: Tiberiu Ichim Date: Mon, 3 Apr 2023 10:06:43 +0300 Subject: [PATCH 59/70] Allow form block to be inside block container (columns, tabs, accordion) (#29) * Allow form block to be inside block container (columns, tabs, accordion) --- .gitignore | 1 + CHANGES.rst | 3 ++ .../volto/formsupport/datamanager/catalog.py | 5 ++- .../restapi/services/form_data/form_data.py | 5 ++- .../restapi/services/submit_form/post.py | 4 +- src/collective/volto/formsupport/utils.py | 41 +++++++++++++++++++ 6 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 src/collective/volto/formsupport/utils.py diff --git a/.gitignore b/.gitignore index 06176552..76dfed01 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ report.html reports/ pyvenv.cfg # excludes +*~ diff --git a/CHANGES.rst b/CHANGES.rst index 067c96eb..116bb95f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -6,6 +6,9 @@ Changelog - Override content-transfer-encoding using `MAIL_CONTENT_TRANSFER_ENCODING` env [mamico] +- The form block can now be stored in a Volto block container (columns, + accordion, tabs, etc) + [tiberiuichim] 2.6.2 (2022-11-07) diff --git a/src/collective/volto/formsupport/datamanager/catalog.py b/src/collective/volto/formsupport/datamanager/catalog.py index 66c33c6f..80836fa5 100644 --- a/src/collective/volto/formsupport/datamanager/catalog.py +++ b/src/collective/volto/formsupport/datamanager/catalog.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- + from collective.volto.formsupport import logger from collective.volto.formsupport.interfaces import IFormDataStore +from collective.volto.formsupport.utils import get_blocks from copy import deepcopy from datetime import datetime from plone.dexterity.interfaces import IDexterityContent @@ -45,7 +47,8 @@ def block_id(self): return data.get("block_id", "") def get_form_fields(self): - blocks = getattr(self.context, "blocks", {}) + blocks = get_blocks(self.context) + if not blocks: return {} form_block = {} diff --git a/src/collective/volto/formsupport/restapi/services/form_data/form_data.py b/src/collective/volto/formsupport/restapi/services/form_data/form_data.py index 9dca2839..b3d7e842 100644 --- a/src/collective/volto/formsupport/restapi/services/form_data/form_data.py +++ b/src/collective/volto/formsupport/restapi/services/form_data/form_data.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- + from collective.volto.formsupport.interfaces import IFormDataStore +from collective.volto.formsupport.utils import get_blocks from plone import api from plone.memoize import view from plone.restapi.interfaces import IExpandableElement @@ -46,7 +48,8 @@ def __call__(self, expand=False): @property @view.memoize def form_block(self): - blocks = getattr(self.context, "blocks", {}) + blocks = get_blocks(self.context) + if isinstance(blocks, six.text_type): blocks = json.loads(blocks) if not blocks: 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 7e5481b5..f3ec1157 100644 --- a/src/collective/volto/formsupport/restapi/services/submit_form/post.py +++ b/src/collective/volto/formsupport/restapi/services/submit_form/post.py @@ -1,8 +1,10 @@ # -*- coding: utf-8 -*- + from collective.volto.formsupport import _ 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.utils import get_blocks from email.message import EmailMessage from plone import api from plone.protect.interfaces import IDisableCSRFProtection @@ -165,7 +167,7 @@ def validate_attachments(self): ) def get_block_data(self, block_id): - blocks = getattr(self.context, "blocks", {}) + blocks = get_blocks(self.context) if not blocks: return {} for id, block in blocks.items(): diff --git a/src/collective/volto/formsupport/utils.py b/src/collective/volto/formsupport/utils.py new file mode 100644 index 00000000..3027f9ed --- /dev/null +++ b/src/collective/volto/formsupport/utils.py @@ -0,0 +1,41 @@ +from collections import deque + +import copy +import json +import six + + +def flatten_block_hierachy(blocks): + """ Given some blocks, return all contained blocks, including "subblocks" + This allows embedding the form block into something like columns datastorage + """ + + queue = deque(list(blocks.items())) + + while queue: + blocktuple = queue.pop() + yield blocktuple + + block_value = blocktuple[1] + + if "data" in block_value: + if isinstance(block_value["data"], dict): + if "blocks" in block_value["data"]: + queue.extend(list( + block_value["data"]["blocks"].items())) + + if "blocks" in block_value: + queue.extend(list(block_value["blocks"].items())) + + +def get_blocks(context): + """ Returns all blocks from a context, including those coming from slots + """ + + blocks = copy.deepcopy(getattr(context, "blocks", {})) + if isinstance(blocks, six.text_type): + blocks = json.loads(blocks) + + flat = list(flatten_block_hierachy(blocks)) + + return dict(flat) From 9a2ac5fdfee989c74bfe3e57db41807ae58cd97b Mon Sep 17 00:00:00 2001 From: Andrea Cecchi Date: Mon, 3 Apr 2023 09:07:25 +0200 Subject: [PATCH 60/70] Preparing release 2.7.0 --- CHANGES.rst | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 116bb95f..88a26c7e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,7 +1,7 @@ Changelog ========= -2.6.3 (unreleased) +2.7.0 (2023-04-03) ------------------ - Override content-transfer-encoding using `MAIL_CONTENT_TRANSFER_ENCODING` env diff --git a/setup.py b/setup.py index 9eefe33e..6212f40d 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ setup( name="collective.volto.formsupport", - version="2.6.3.dev0", + version="2.7.0", description="Add support for customizable forms in Volto", long_description=long_description, # Get more from https://pypi.org/classifiers/ From 632cffc4c91966f7da62b3736b26b82ef1676268 Mon Sep 17 00:00:00 2001 From: Andrea Cecchi Date: Mon, 3 Apr 2023 09:07:39 +0200 Subject: [PATCH 61/70] Back to development: 2.7.1 --- CHANGES.rst | 6 ++++++ setup.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 88a26c7e..bdaafcc5 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,12 @@ Changelog ========= +2.7.1 (unreleased) +------------------ + +- Nothing changed yet. + + 2.7.0 (2023-04-03) ------------------ diff --git a/setup.py b/setup.py index 6212f40d..1e5b603d 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ setup( name="collective.volto.formsupport", - version="2.7.0", + version="2.7.1.dev0", description="Add support for customizable forms in Volto", long_description=long_description, # Get more from https://pypi.org/classifiers/ From 274b71720dc0ba0fd7c4b954895a714ca0cea928 Mon Sep 17 00:00:00 2001 From: Jefferson Bledsoe Date: Fri, 21 Apr 2023 07:58:49 +0100 Subject: [PATCH 62/70] Ability to forward some headers (#27) * Add support for forwarding headers * Test for forwarding headers in email * Docs update * Update changelog --- CHANGES.rst | 3 +- README.rst | 14 ++++ .../restapi/services/submit_form/post.py | 8 ++ .../tests/test_send_action_form.py | 77 ++++++++++++++++++- 4 files changed, 100 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index bdaafcc5..dcbaaeb6 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,7 +4,8 @@ Changelog 2.7.1 (unreleased) ------------------ -- Nothing changed yet. +- Allow forwarding request headers in the sent emails #27 + [JeffersonBledsoe] 2.7.0 (2023-04-03) diff --git a/README.rst b/README.rst index 1c7c6cab..6dd3dfa5 100644 --- a/README.rst +++ b/README.rst @@ -212,6 +212,20 @@ This is useful for some SMTP servers that have problems with `quoted-printable` By default the content-transfer-encoding is `quoted-printable` as overrided in https://github.com/zopefoundation/Products.MailHost/blob/master/src/Products/MailHost/MailHost.py#L65 +Header forwarding +========================= + +It is possible to configure some headers from the form POST request to be included in the email's headers by configuring the `httpHeaders` field in your volto block. + +[volto-formblock](https://github.com/collective/volto-form-block) allows the following headers to be forwarded: + +- `HTTP_X_FORWARDED_FOR` +- `HTTP_X_FORWARDED_PORT` +- `REMOTE_ADDR` +- `PATH_INFO` +- `HTTP_USER_AGENT` +- `HTTP_REFERER` + Examples ======== 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 f3ec1157..4a3b6514 100644 --- a/src/collective/volto/formsupport/restapi/services/submit_form/post.py +++ b/src/collective/volto/formsupport/restapi/services/submit_form/post.py @@ -259,6 +259,14 @@ def send_data(self): msg["To"] = mto msg["Reply-To"] = mreply_to + msg.replace_header("Content-Type", 'text/html; charset="utf-8"') + + headers_to_forward = self.block.get("httpHeaders", []) + for header in headers_to_forward: + header_value = self.request.get(header) + if header_value: + msg[header] = header_value + self.manage_attachments(msg=msg) self.send_mail(msg=msg, charset=charset) 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 02bc43ad..9ff81099 100644 --- a/src/collective/volto/formsupport/tests/test_send_action_form.py +++ b/src/collective/volto/formsupport/tests/test_send_action_form.py @@ -216,10 +216,85 @@ def test_email_sent_with_site_recipient( self.assertIn("Message: just want to say hi", msg) self.assertIn("Name: John", msg) - def test_email_sent_ignore_passed_recipient( + def test_email_sent_with_forwarded_headers( self, ): + self.document.blocks = { + "form-id": { + "@type": "form", + "send": True, + "httpHeaders": [], + }, + } + transaction.commit() + + response = self.submit_form( + data={ + "from": "john@doe.com", + "data": [ + {"label": "Message", "value": "just want to say hi"}, + {"label": "Name", "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: John", msg) + self.assertNotIn("REMOTE_ADDR", msg) + + self.document.blocks = { + "form-id": { + "@type": "form", + "send": True, + "httpHeaders": [ + "REMOTE_ADDR", + "PATH_INFO", + ], + }, + } + transaction.commit() + + response = self.submit_form( + data={ + "from": "john@doe.com", + "data": [ + {"label": "Message", "value": "just want to say hi"}, + {"label": "Name", "value": "John"}, + ], + "subject": "test subject", + "block_id": "form-id", + }, + ) + transaction.commit() + self.assertEqual(response.status_code, 204) + 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("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: John", msg) + self.assertIn("REMOTE_ADDR", msg) + self.assertIn("PATH_INFO", msg) + + def test_email_sent_ignore_passed_recipient( + self, + ): self.document.blocks = { "form-id": {"@type": "form", "send": True}, } From e23c1799249f731a1d8406becdc194b2a4ccd475 Mon Sep 17 00:00:00 2001 From: Jefferson Bledsoe Date: Fri, 21 Apr 2023 07:59:14 +0100 Subject: [PATCH 63/70] Update GitHub Action dependencies (#30) * Update GitHub Action dependencies * Bump setup-python from v1 to v4 --- .github/workflows/tests.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ad1a9c70..f0958cde 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,14 +14,14 @@ jobs: # - python: "3.7" # plone: "51" steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v3 - name: Cache eggs - uses: actions/cache@v1 + uses: actions/cache@v3 with: path: eggs key: ${{ runner.OS }}-build-python${{ matrix.python }}-${{ matrix.plone }} - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v1 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python }} - name: Install dependencies From cf4b239b92a4250301333db7576d0c2607a2eec7 Mon Sep 17 00:00:00 2001 From: "Leonardo J. Caballero G" Date: Thu, 11 May 2023 10:12:58 -0400 Subject: [PATCH 64/70] Added Spanish translation --- CHANGES.rst | 3 + .../collective.volto.formsupport.po | 104 ++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 src/collective/volto/formsupport/locales/es/LC_MESSAGES/collective.volto.formsupport.po diff --git a/CHANGES.rst b/CHANGES.rst index dcbaaeb6..f0078742 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,6 +4,9 @@ Changelog 2.7.1 (unreleased) ------------------ +- Add Spanish translation. + [macagua] + - Allow forwarding request headers in the sent emails #27 [JeffersonBledsoe] diff --git a/src/collective/volto/formsupport/locales/es/LC_MESSAGES/collective.volto.formsupport.po b/src/collective/volto/formsupport/locales/es/LC_MESSAGES/collective.volto.formsupport.po new file mode 100644 index 00000000..53448f55 --- /dev/null +++ b/src/collective/volto/formsupport/locales/es/LC_MESSAGES/collective.volto.formsupport.po @@ -0,0 +1,104 @@ +# Translation of collective.volto.formsupport.pot to Spanish +# Leonardo J. Caballero G. , 2023. +msgid "" +msgstr "" +"Project-Id-Version: collective.volto.formsupport\n" +"POT-Creation-Date: 2022-09-20 08:23+0000\n" +"PO-Revision-Date: 2023-05-11 10:11-0400\n" +"Last-Translator: Leonardo J. Caballero G. \n" +"Language: es\n" +"Language-Team: ES \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"Language-Code: es\n" +"Language-Name: Español\n" +"Preferred-Encodings: utf-8\n" +"Domain: collective.volto.formsupport\n" +"X-Is-Fallback-For: es-ar es-bo es-cl es-co es-cr es-do es-ec es-es es-sv es-gt es-hn es-mx es-ni es-pa es-py es-pe es-pr es-us es-uy es-ve\n" +"X-Generator: Poedit 2.2.1\n" + +#: collective/volto/formsupport/captcha/recaptcha.py:12 +msgid "Google ReCaptcha" +msgstr "Google ReCaptcha" + +#: collective/volto/formsupport/captcha/hcaptcha.py:13 +msgid "HCaptcha" +msgstr "HCaptcha" + +#: collective/volto/formsupport/captcha/hcaptcha.py:62 +msgid "HCaptcha Invisible" +msgstr "HCaptcha invisible" + +#: collective/volto/formsupport/configure.zcml:33 +msgid "Installs the collective.volto.formsupport add-on." +msgstr "Instala o complemento collective.volto.formsupport." + +#: collective/volto/formsupport/captcha/hcaptcha.py:43 +#: collective/volto/formsupport/captcha/norobots.py:53 +#: collective/volto/formsupport/captcha/recaptcha.py:42 +msgid "No captcha token provided." +msgstr "No se proporcionó ningún token captcha." + +#: collective/volto/formsupport/captcha/norobots.py:16 +msgid "NoRobots ReCaptcha Support" +msgstr "Soporte de NoRobots ReCaptcha" + +#: collective/volto/formsupport/captcha/hcaptcha.py:55 +#: collective/volto/formsupport/captcha/norobots.py:69 +#: collective/volto/formsupport/captcha/recaptcha.py:54 +msgid "The code you entered was wrong, please enter the new one." +msgstr "El código que ingresaste es incorrecto, ingresa el nuevo." + +#: collective/volto/formsupport/configure.zcml:42 +msgid "Uninstalls the collective.volto.formsupport add-on." +msgstr "Desinstala o complemento collective.volto.formsupport." + +#: collective/volto/formsupport/configure.zcml:33 +msgid "Volto: Form support" +msgstr "Volto: Soporte a formularios" + +#: collective/volto/formsupport/configure.zcml:42 +msgid "Volto: Form support (uninstall)" +msgstr "Volto: Soporte a formularios (Desinstalar)" + +#. Default: "Attachments too big. You uploaded ${uploaded_str}, but limit is ${max} MB. Try to compress files." +#: collective/volto/formsupport/restapi/services/submit_form/post.py:152 +msgid "attachments_too_big" +msgstr "Adjuntos demasiado grandes. Subiste el archivo ${uploaded_str}, pero el límite es de ${max} MB. Intenta comprimir archivos." + +#. Default: "Block with @type \"form\" and id \"$block\" not found in this context: $context" +#: collective/volto/formsupport/restapi/services/submit_form/post.py:93 +msgid "block_form_not_found_label" +msgstr "Bloque con @type \"form\" y id \"${block}\" no encontrado en contexto: $context." + +#. Default: "Empty form data." +#: collective/volto/formsupport/restapi/services/submit_form/post.py:119 +msgid "empty_form_data" +msgstr "Formulario sem dados." + +#. Default: "Unable to send confirm email. Please retry later or contact site administator." +#: collective/volto/formsupport/restapi/services/submit_form/post.py:66 +msgid "mail_send_exception" +msgstr "No se puede enviar el correo electrónico de confirmación. Vuelva a intentarlo más tarde o póngase en contacto con el administrador del sitio." + +#. Default: "You need to set at least one form action between \"send\" and \"store\"." +#: collective/volto/formsupport/restapi/services/submit_form/post.py:108 +msgid "missing_action" +msgstr "Debe seleccionar al menos una acción entre \"guardar\" y \"enviar\"." + +#. Default: "Missing block_id" +#: collective/volto/formsupport/restapi/services/submit_form/post.py:86 +msgid "missing_blockid_label" +msgstr "Campo block_id no informado" + +#. Default: "A new form has been submitted from ${url}:" +#: collective/volto/formsupport/browser/send_mail_template.pt:7 +msgid "send_mail_text" +msgstr "Se ha enviado un nuevo formulario desde ${url}:" + +#. Default: "Missing required field: subject or from." +#: collective/volto/formsupport/restapi/services/submit_form/post.py:230 +msgid "send_required_field_missing" +msgstr "Campo obligatorio no presente: Asunto o remitente." From 15101d682a2915ca07b9bdeb6b2b84eed6f5253b Mon Sep 17 00:00:00 2001 From: Jefferson Bledsoe Date: Tue, 23 May 2023 22:11:58 +0100 Subject: [PATCH 65/70] XML Sending and custom field mapping (#22) * Allow sending XML * Fix serialization bug * Use custom_field_id if it's available when sending an XML * Allow storing custom_field_id in-place of a field's label * Bump dev version * Fix attachment sending * Changelog * Docs * Add test for XML sending * Add test for field renaming --------- Co-authored-by: Andrea Cecchi --- CHANGES.rst | 4 ++ README.rst | 22 ++++++- .../volto/formsupport/datamanager/catalog.py | 15 ++++- .../restapi/services/submit_form/post.py | 37 +++++++++++- .../tests/test_send_action_form.py | 37 ++++++++++++ .../tests/test_store_action_form.py | 60 +++++++++++++++++++ 6 files changed, 169 insertions(+), 6 deletions(-) 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 +

My value +``` + +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)) From 7a15276917e829ed7c1c350a47414f082166dfe6 Mon Sep 17 00:00:00 2001 From: Jefferson Bledsoe Date: Wed, 24 May 2023 10:35:43 +0100 Subject: [PATCH 66/70] Allow changing the format of the contents of the email (#31) * Unit test for email formatting * Test placeholder * Implement sending a table based on `email_format` * Adjust whitespace in tests to make them pass * Add 'email_format: list' test * Update with recommended changes * Fix test * Changelog * Update email_format_getting with suggesting from @cekk * Fix bad merge * Update locales --- CHANGES.rst | 2 + .../volto/formsupport/browser/configure.zcml | 6 + .../browser/send_mail_template_table.pt | 30 +++++ .../locales/collective.volto.formsupport.pot | 30 +++-- .../restapi/services/submit_form/post.py | 11 +- .../tests/test_send_action_form.py | 110 ++++++++++++++++-- 6 files changed, 168 insertions(+), 21 deletions(-) create mode 100644 src/collective/volto/formsupport/browser/send_mail_template_table.pt diff --git a/CHANGES.rst b/CHANGES.rst index d49ec02a..f10cffd2 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -13,6 +13,8 @@ Changelog - Allow forwarding request headers in the sent emails #27 [JeffersonBledsoe] +- Added support for sending emails as a table #31 + [JeffersonBledsoe] 2.7.0 (2023-04-03) diff --git a/src/collective/volto/formsupport/browser/configure.zcml b/src/collective/volto/formsupport/browser/configure.zcml index 0361f7cc..24ef7246 100644 --- a/src/collective/volto/formsupport/browser/configure.zcml +++ b/src/collective/volto/formsupport/browser/configure.zcml @@ -24,4 +24,10 @@ template="send_mail_template.pt" permission="zope2.View" /> + diff --git a/src/collective/volto/formsupport/browser/send_mail_template_table.pt b/src/collective/volto/formsupport/browser/send_mail_template_table.pt new file mode 100644 index 00000000..1418ae70 --- /dev/null +++ b/src/collective/volto/formsupport/browser/send_mail_template_table.pt @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + +
Form submission data for ${title}
FieldValue
${label}${value}
+
+ + diff --git a/src/collective/volto/formsupport/locales/collective.volto.formsupport.pot b/src/collective/volto/formsupport/locales/collective.volto.formsupport.pot index ef5b93d9..3dc40032 100644 --- a/src/collective/volto/formsupport/locales/collective.volto.formsupport.pot +++ b/src/collective/volto/formsupport/locales/collective.volto.formsupport.pot @@ -4,7 +4,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" -"POT-Creation-Date: 2022-09-20 08:23+0000\n" +"POT-Creation-Date: 2023-05-24 00:33+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI +ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -29,6 +29,10 @@ msgstr "" msgid "HCaptcha Invisible" msgstr "" +#: collective/volto/formsupport/captcha/honeypot.py:11 +msgid "Honeypot Support" +msgstr "" + #: collective/volto/formsupport/configure.zcml:33 msgid "Installs the collective.volto.formsupport add-on." msgstr "" @@ -62,32 +66,37 @@ msgid "Volto: Form support (uninstall)" msgstr "" #. Default: "Attachments too big. You uploaded ${uploaded_str}, but limit is ${max} MB. Try to compress files." -#: collective/volto/formsupport/restapi/services/submit_form/post.py:152 +#: collective/volto/formsupport/restapi/services/submit_form/post.py:158 msgid "attachments_too_big" msgstr "" #. Default: "Block with @type \"form\" and id \"$block\" not found in this context: $context" -#: collective/volto/formsupport/restapi/services/submit_form/post.py:93 +#: collective/volto/formsupport/restapi/services/submit_form/post.py:99 msgid "block_form_not_found_label" msgstr "" #. Default: "Empty form data." -#: collective/volto/formsupport/restapi/services/submit_form/post.py:119 +#: collective/volto/formsupport/restapi/services/submit_form/post.py:125 msgid "empty_form_data" msgstr "" +#. Default: "Error submitting form." +#: collective/volto/formsupport/captcha/honeypot.py:28 +msgid "honeypot_error" +msgstr "" + #. Default: "Unable to send confirm email. Please retry later or contact site administator." -#: collective/volto/formsupport/restapi/services/submit_form/post.py:66 +#: collective/volto/formsupport/restapi/services/submit_form/post.py:72 msgid "mail_send_exception" msgstr "" #. Default: "You need to set at least one form action between \"send\" and \"store\"." -#: collective/volto/formsupport/restapi/services/submit_form/post.py:108 +#: collective/volto/formsupport/restapi/services/submit_form/post.py:114 msgid "missing_action" msgstr "" #. Default: "Missing block_id" -#: collective/volto/formsupport/restapi/services/submit_form/post.py:86 +#: collective/volto/formsupport/restapi/services/submit_form/post.py:92 msgid "missing_blockid_label" msgstr "" @@ -96,7 +105,12 @@ msgstr "" msgid "send_mail_text" msgstr "" +#. Default: "Form submission data for ${title}" +#: collective/volto/formsupport/browser/send_mail_template_table.pt:7 +msgid "send_mail_text_table" +msgstr "" + #. Default: "Missing required field: subject or from." -#: collective/volto/formsupport/restapi/services/submit_form/post.py:230 +#: collective/volto/formsupport/restapi/services/submit_form/post.py:236 msgid "send_required_field_missing" msgstr "" 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 de3df91e..e784e41e 100644 --- a/src/collective/volto/formsupport/restapi/services/submit_form/post.py +++ b/src/collective/volto/formsupport/restapi/services/submit_form/post.py @@ -278,8 +278,17 @@ def send_data(self): self.send_mail(msg=msg, charset=charset) def prepare_message(self): + email_format_page_template_mapping = { + "list": "send_mail_template", + "table": "send_mail_template_table", + } + email_format = self.block.get("email_format", "") + template_name = email_format_page_template_mapping.get( + email_format, "send_mail_template" + ) + message_template = api.content.get_view( - name="send_mail_template", + name=template_name, context=self.context, request=self.request, ) 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 7678eafd..b3bbe9ca 100644 --- a/src/collective/volto/formsupport/tests/test_send_action_form.py +++ b/src/collective/volto/formsupport/tests/test_send_action_form.py @@ -22,7 +22,6 @@ class TestMailSend(unittest.TestCase): - layer = VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING def setUp(self): @@ -126,7 +125,6 @@ def test_email_not_send_if_block_id_is_incorrect_or_not_present(self): ) def test_email_not_send_if_no_action_set(self): - response = self.submit_form( data={"from": "john@doe.com", "block_id": "form-id"}, ) @@ -141,7 +139,6 @@ def test_email_not_send_if_no_action_set(self): def test_email_not_send_if_block_id_is_correct_but_form_data_missing( self, ): - self.document.blocks = { "form-id": {"@type": "form", "send": True}, } @@ -165,7 +162,6 @@ def test_email_not_send_if_block_id_is_correct_but_form_data_missing( def test_email_not_send_if_block_id_is_correct_but_required_fields_missing( self, ): - self.document.blocks = { "form-id": {"@type": "form", "send": True}, } @@ -189,7 +185,6 @@ def test_email_not_send_if_block_id_is_correct_but_required_fields_missing( def test_email_sent_with_site_recipient( self, ): - self.document.blocks = { "form-id": {"@type": "form", "send": True}, } @@ -331,7 +326,6 @@ def test_email_sent_ignore_passed_recipient( def test_email_sent_with_block_recipient_if_set( self, ): - self.document.blocks = { "text-id": {"@type": "text"}, "form-id": { @@ -369,7 +363,6 @@ def test_email_sent_with_block_recipient_if_set( def test_email_sent_with_block_subject_if_set_and_not_passed( self, ): - self.document.blocks = { "text-id": {"@type": "text"}, "form-id": { @@ -407,7 +400,6 @@ def test_email_sent_with_block_subject_if_set_and_not_passed( def test_email_with_use_as_reply_to( self, ): - self.document.blocks = { "text-id": {"@type": "text"}, "form-id": { @@ -453,7 +445,6 @@ def test_email_with_use_as_reply_to( def test_email_field_used_as_bcc( self, ): - self.document.blocks = { "text-id": {"@type": "text"}, "form-id": { @@ -500,7 +491,6 @@ def test_email_field_used_as_bcc( def test_send_attachment( self, ): - self.document.blocks = { "text-id": {"@type": "text"}, "form-id": { @@ -583,6 +573,102 @@ def test_send_attachment_validate_size( ) self.assertEqual(len(self.mailhost.messages), 0) + def test_email_body_formated_as_table( + self, + ): + self.document.blocks = { + "form-id": {"@type": "form", "send": True, "email_format": "table"}, + } + transaction.commit() + + subject = "test subject" + name = "John" + message = "just want to say hi" + + response = self.submit_form( + data={ + "from": "john@doe.com", + "data": [ + {"label": "Message", "value": message}, + {"label": "Name", "value": name}, + ], + "subject": 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(f"Subject: {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("", msg) + self.assertIn("
", msg) + # TODO: Is the document title the desired behaviour here? Or should it be the subject of the email + self.assertIn( + f"Form submission data for {self.document.title}", msg + ) + self.assertIn( + """ + + Field + Value + + """, + msg, + ) + + self.assertIn( + """ + Name""", + msg, + ) + self.assertIn(f"{name}", msg) + self.assertIn( + """ + """, + msg, + ) + self.assertIn(f"{message}", msg) + + def test_email_body_formated_as_list( + self, + ): + self.document.blocks = { + "form-id": {"@type": "form", "send": True, "email_format": "list"}, + } + transaction.commit() + + response = self.submit_form( + data={ + "from": "john@doe.com", + "data": [ + {"label": "Message", "value": "just want to say hi"}, + {"label": "Name", "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: John", msg) + def test_send_xml(self): self.document.blocks = { "form-id": {"@type": "form", "send": True, "attachXml": True}, @@ -614,5 +700,5 @@ 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']) - self.assertEqual(field.text, form_data[index]['value']) + self.assertEqual(field.get("name"), form_data[index]["label"]) + self.assertEqual(field.text, form_data[index]["value"]) From 4945b2989e817c09450a2a4d6844793fbac968dd Mon Sep 17 00:00:00 2001 From: Jefferson Bledsoe Date: Mon, 12 Jun 2023 08:42:34 +0100 Subject: [PATCH 67/70] Add borders to table (#33) * Add borders to table * Fix test --- .../volto/formsupport/browser/send_mail_template_table.pt | 2 +- .../volto/formsupport/tests/test_send_action_form.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) 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 1418ae70..6965440b 100644 --- a/src/collective/volto/formsupport/browser/send_mail_template_table.pt +++ b/src/collective/volto/formsupport/browser/send_mail_template_table.pt @@ -3,7 +3,7 @@ define="parameters python:options.get('parameters', {}); url python:options.get('url', ''); title python:options.get('title', '');"> - +
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 b3bbe9ca..10265e2a 100644 --- a/src/collective/volto/formsupport/tests/test_send_action_form.py +++ b/src/collective/volto/formsupport/tests/test_send_action_form.py @@ -608,9 +608,8 @@ def test_email_body_formated_as_table( self.assertIn("To: site_addr@plone.com", msg) self.assertIn("Reply-To: john@doe.com", msg) - self.assertIn("
Form submission data for ${title}
", msg) + self.assertIn("""
""", msg) self.assertIn("
", msg) - # TODO: Is the document title the desired behaviour here? Or should it be the subject of the email self.assertIn( f"Form submission data for {self.document.title}", msg ) From 9e65522dbb0a9c0654b69160b7ea39709fb2db23 Mon Sep 17 00:00:00 2001 From: Matthias Barde Date: Mon, 12 Jun 2023 14:40:48 +0200 Subject: [PATCH 68/70] add german translations (#32) * add german translations * Update CHANGES.rst * Update CHANGES.rst --------- Co-authored-by: Andrea Cecchi --- CHANGES.rst | 3 +- .../collective.volto.formsupport.po | 108 ++++++++++++++++++ 2 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 src/collective/volto/formsupport/locales/de/LC_MESSAGES/collective.volto.formsupport.po diff --git a/CHANGES.rst b/CHANGES.rst index f10cffd2..ac7830c7 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -10,7 +10,8 @@ Changelog [JeffersonBledsoe] - Add Spanish translation. [macagua] - +- Add German translation. + [mbarde] - Allow forwarding request headers in the sent emails #27 [JeffersonBledsoe] - Added support for sending emails as a table #31 diff --git a/src/collective/volto/formsupport/locales/de/LC_MESSAGES/collective.volto.formsupport.po b/src/collective/volto/formsupport/locales/de/LC_MESSAGES/collective.volto.formsupport.po new file mode 100644 index 00000000..f3ec6fe0 --- /dev/null +++ b/src/collective/volto/formsupport/locales/de/LC_MESSAGES/collective.volto.formsupport.po @@ -0,0 +1,108 @@ +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"POT-Creation-Date: 2023-05-11 11:43+0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI +ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0\n" +"Language-Code: en\n" +"Language-Name: English\n" +"Preferred-Encodings: utf-8 latin1\n" +"Domain: DOMAIN\n" + +#: ../captcha/recaptcha.py:12 +msgid "Google ReCaptcha" +msgstr "" + +#: ../captcha/hcaptcha.py:13 +msgid "HCaptcha" +msgstr "" + +#: ../captcha/hcaptcha.py:62 +msgid "HCaptcha Invisible" +msgstr "" + +#: ../captcha/honeypot.py:11 +msgid "Honeypot Support" +msgstr "" + +#: ../configure.zcml:33 +msgid "Installs the collective.volto.formsupport add-on." +msgstr "" + +#: ../captcha/hcaptcha.py:43 +#: ../captcha/norobots.py:53 +#: ../captcha/recaptcha.py:42 +msgid "No captcha token provided." +msgstr "Kein Captcha-Token angegeben." + +#: ../captcha/norobots.py:16 +msgid "NoRobots ReCaptcha Support" +msgstr "" + +#: ../captcha/hcaptcha.py:55 +#: ../captcha/norobots.py:69 +#: ../captcha/recaptcha.py:54 +msgid "The code you entered was wrong, please enter the new one." +msgstr "Der eingegebene Code war falsch. Bitte geben Sie den neuen Code ein." + +#: ../configure.zcml:42 +msgid "Uninstalls the collective.volto.formsupport add-on." +msgstr "" + +#: ../configure.zcml:33 +msgid "Volto: Form support" +msgstr "" + +#: ../configure.zcml:42 +msgid "Volto: Form support (uninstall)" +msgstr "" + +#. Default: "Attachments too big. You uploaded ${uploaded_str}, but limit is ${max} MB. Try to compress files." +#: ../restapi/services/submit_form/post.py:156 +msgid "attachments_too_big" +msgstr "Anhang ist zu groß (${uploaded_str}). Die Größe darf ${max} MB nicht überschreiten. Versuchen Sie Dateien zu komprimieren." + +#. Default: "Block with @type \"form\" and id \"$block\" not found in this context: $context" +#: ../restapi/services/submit_form/post.py:97 +msgid "block_form_not_found_label" +msgstr "Kein Formular-Block mit der ID \"$block\" in diesem Kontext gefunden: $context" + +#. Default: "Empty form data." +#: ../restapi/services/submit_form/post.py:123 +msgid "empty_form_data" +msgstr "Leere Formulardaten." + +#. Default: "Error submitting form." +#: ../captcha/honeypot.py:28 +msgid "honeypot_error" +msgstr "Formular konnte nicht abgeschickt werden." + +#. Default: "Unable to send confirm email. Please retry later or contact site administator." +#: ../restapi/services/submit_form/post.py:70 +msgid "mail_send_exception" +msgstr "Die Bestätigungsmail konnte nicht versandt werden. Bitte versuchen Sie es später erneut oder kontaktieren Sie die Seitenadministration." + +#. Default: "You need to set at least one form action between \"send\" and \"store\"." +#: ../restapi/services/submit_form/post.py:112 +msgid "missing_action" +msgstr "Es muss mindestens eine Formular-Aktion gesetzt werden (\"E-Mail an Empfänger senden\" und/oder \"Kompilierte Daten speichern\")." + +#. Default: "Missing block_id" +#: ../restapi/services/submit_form/post.py:90 +msgid "missing_blockid_label" +msgstr "Fehlende block_id" + +#. Default: "A new form has been submitted from ${url}:" +#: ../browser/send_mail_template.pt:7 +msgid "send_mail_text" +msgstr "Auf der Seite ${url} wurde ein neues Formular eingereicht:" + +#. Default: "Missing required field: subject or from." +#: ../restapi/services/submit_form/post.py:234 +msgid "send_required_field_missing" +msgstr "Erforderliches Feld fehlt: Betreff oder Absender" From 848fb912432690fe8b67ec20d80cc2f68286a96b Mon Sep 17 00:00:00 2001 From: Mauro Amico Date: Mon, 18 Sep 2023 14:04:59 +0200 Subject: [PATCH 69/70] Test plone 6 (#35) * ci: plone6 tests * test plone 6.0 * typo * coverage * black --- .coveragerc | 2 +- .github/workflows/tests.yml | 17 ++-- .gitignore | 1 + base.cfg | 1 + constraints_plone60.txt | 1 + requirements.txt | 2 +- .../volto/formsupport/captcha/hcaptcha.py | 1 + .../volto/formsupport/captcha/norobots.py | 8 +- src/collective/volto/formsupport/testing.py | 2 - .../volto/formsupport/tests/test_captcha.py | 1 - .../volto/formsupport/tests/test_event.py | 2 - .../volto/formsupport/tests/test_honeypot.py | 4 - .../formsupport/tests/test_serialize_block.py | 4 - .../volto/formsupport/tests/test_setup.py | 28 ++++-- .../tests/test_store_action_form.py | 1 - src/collective/volto/formsupport/utils.py | 8 +- test_plone52.cfg | 3 + test_plone60.cfg | 86 +++++++++++++++++++ 18 files changed, 130 insertions(+), 42 deletions(-) create mode 100644 constraints_plone60.txt create mode 100644 test_plone60.cfg diff --git a/.coveragerc b/.coveragerc index 35be63d8..acf9b175 100644 --- a/.coveragerc +++ b/.coveragerc @@ -3,7 +3,7 @@ relative_files = True [report] include = - src/collective/* + */src/collective/* omit = */test* /home/*/.buildout/eggs/* diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f0958cde..fe3ee62b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,11 +8,15 @@ jobs: strategy: max-parallel: 4 matrix: - python: ["3.9"] - plone: ["52"] - # exclude: - # - python: "3.7" - # plone: "51" + python: ["3.8", "3.9", "3.10", "3.11"] + plone: ["52", "60"] + exclude: + - python: "3.9" + plone: "52" + - python: "3.10" + plone: "52" + - python: "3.11" + plone: "52" steps: - uses: actions/checkout@v3 - name: Cache eggs @@ -36,8 +40,7 @@ jobs: bin/code-analysis - name: Run tests run: | - bin/test - # bin/test-coverage + bin/test-coverage - name: createcoverage run: | bin/createcoverage -t '--all' diff --git a/.gitignore b/.gitignore index 76dfed01..98c3eeee 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ lib64 log.html output.xml pip-selfcheck.json +.coverage report.html .vscode/ .tox/ diff --git a/base.cfg b/base.cfg index 707264be..db51faf9 100644 --- a/base.cfg +++ b/base.cfg @@ -81,6 +81,7 @@ recipe = collective.recipe.template input = inline: #!/bin/bash export TZ=UTC + set -e ${buildout:directory}/bin/coverage run bin/test $* ${buildout:directory}/bin/coverage html ${buildout:directory}/bin/coverage report -m --fail-under=90 diff --git a/constraints_plone60.txt b/constraints_plone60.txt new file mode 100644 index 00000000..005e694e --- /dev/null +++ b/constraints_plone60.txt @@ -0,0 +1 @@ +-c https://dist.plone.org/release/6.0-latest/requirements.txt diff --git a/requirements.txt b/requirements.txt index 06390bdd..8a011da0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ --c constraints.txt setuptools zc.buildout +wheel diff --git a/src/collective/volto/formsupport/captcha/hcaptcha.py b/src/collective/volto/formsupport/captcha/hcaptcha.py index b383aa52..3d562711 100644 --- a/src/collective/volto/formsupport/captcha/hcaptcha.py +++ b/src/collective/volto/formsupport/captcha/hcaptcha.py @@ -2,6 +2,7 @@ from collective.volto.formsupport import _ from plone.formwidget.hcaptcha.interfaces import IHCaptchaSettings from plone.formwidget.hcaptcha.nohcaptcha import submit + # from plone.formwidget.hcaptcha.validator import WrongCaptchaCode from plone.registry.interfaces import IRegistry from zExceptions import BadRequest diff --git a/src/collective/volto/formsupport/captcha/norobots.py b/src/collective/volto/formsupport/captcha/norobots.py index 0b5409af..12591241 100644 --- a/src/collective/volto/formsupport/captcha/norobots.py +++ b/src/collective/volto/formsupport/captcha/norobots.py @@ -18,9 +18,7 @@ class NoRobotsSupport(CaptchaSupport): def __init__(self, context, request): super().__init__(context, request) registry = queryUtility(IRegistry) - self.settings = registry.forInterface( - INorobotsWidgetSettings, check=False - ) + self.settings = registry.forInterface(INorobotsWidgetSettings, check=False) def isEnabled(self): return self.settings and self.settings.questions @@ -66,9 +64,7 @@ def verify(self, data): if not view.verify(input=value, question_id=id, id_check=id_check): raise BadRequest( translate( - _( - "The code you entered was wrong, please enter the new one." - ), + _("The code you entered was wrong, please enter the new one."), context=self.request, ) ) diff --git a/src/collective/volto/formsupport/testing.py b/src/collective/volto/formsupport/testing.py index df74c330..a4e14086 100644 --- a/src/collective/volto/formsupport/testing.py +++ b/src/collective/volto/formsupport/testing.py @@ -15,7 +15,6 @@ class VoltoFormsupportLayer(PloneSandboxLayer): - defaultBases = (PLONE_APP_CONTENTTYPES_FIXTURE,) def setUpZope(self, app, configurationContext): @@ -48,7 +47,6 @@ def setUpPloneSite(self, portal): class VoltoFormsupportRestApiLayer(PloneRestApiDXLayer): - defaultBases = (PLONE_APP_CONTENTTYPES_FIXTURE,) def setUpZope(self, app, configurationContext): diff --git a/src/collective/volto/formsupport/tests/test_captcha.py b/src/collective/volto/formsupport/tests/test_captcha.py index 829fcbb9..f2656141 100644 --- a/src/collective/volto/formsupport/tests/test_captcha.py +++ b/src/collective/volto/formsupport/tests/test_captcha.py @@ -24,7 +24,6 @@ class TestCaptcha(unittest.TestCase): - layer = VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING def setUp(self): diff --git a/src/collective/volto/formsupport/tests/test_event.py b/src/collective/volto/formsupport/tests/test_event.py index aba1eb14..a118be18 100644 --- a/src/collective/volto/formsupport/tests/test_event.py +++ b/src/collective/volto/formsupport/tests/test_event.py @@ -24,7 +24,6 @@ def event_handler(event): class TestEvent(unittest.TestCase): - layer = VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING def setUp(self): @@ -91,7 +90,6 @@ def submit_form(self, data): def test_trigger_event( self, ): - self.document.blocks = { "text-id": {"@type": "text"}, "form-id": { diff --git a/src/collective/volto/formsupport/tests/test_honeypot.py b/src/collective/volto/formsupport/tests/test_honeypot.py index 23561ec7..4160972d 100644 --- a/src/collective/volto/formsupport/tests/test_honeypot.py +++ b/src/collective/volto/formsupport/tests/test_honeypot.py @@ -18,7 +18,6 @@ class TestHoneypot(unittest.TestCase): - layer = VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING def setUp(self): @@ -72,7 +71,6 @@ def submit_form(self, data): return response def test_honeypot_installed_but_field_not_in_form(self): - self.document.blocks = { "text-id": {"@type": "text"}, "form-id": { @@ -107,7 +105,6 @@ def test_honeypot_installed_but_field_not_in_form(self): ) def test_honeypot_field_in_form_empty_pass_validation(self): - self.document.blocks = { "text-id": {"@type": "text"}, "form-id": { @@ -138,7 +135,6 @@ def test_honeypot_field_in_form_empty_pass_validation(self): self.assertEqual(response.status_code, 204) def test_honeypot_field_in_form_compiled_fail_validation(self): - self.document.blocks = { "text-id": {"@type": "text"}, "form-id": { diff --git a/src/collective/volto/formsupport/tests/test_serialize_block.py b/src/collective/volto/formsupport/tests/test_serialize_block.py index c1b8baeb..b4b7c851 100644 --- a/src/collective/volto/formsupport/tests/test_serialize_block.py +++ b/src/collective/volto/formsupport/tests/test_serialize_block.py @@ -19,7 +19,6 @@ class TestBlockSerialization(unittest.TestCase): - layer = VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING def setUp(self): @@ -74,7 +73,6 @@ def test_serializer_return_filtered_block_data_to_anon(self): class TestBlockSerializationRecaptcha(unittest.TestCase): - layer = VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING def setUp(self): @@ -131,7 +129,6 @@ def test_serializer_with_recaptcha(self): class TestBlockSerializationHCaptcha(unittest.TestCase): - layer = VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING def setUp(self): @@ -188,7 +185,6 @@ def test_serializer_with_hcaptcha(self): class TestBlockSerializationAttachmentsLimit(unittest.TestCase): - layer = VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING def setUp(self): diff --git a/src/collective/volto/formsupport/tests/test_setup.py b/src/collective/volto/formsupport/tests/test_setup.py index fcf2ff68..47f8617a 100644 --- a/src/collective/volto/formsupport/tests/test_setup.py +++ b/src/collective/volto/formsupport/tests/test_setup.py @@ -31,9 +31,14 @@ def setUp(self): def test_product_installed(self): """Test if collective.volto.formsupport is installed.""" - self.assertTrue( - self.installer.isProductInstalled("collective.volto.formsupport") - ) + if hasattr(self.installer, "isProductInstalled"): + self.assertTrue( + self.installer.isProductInstalled("collective.volto.formsupport") + ) + else: # plone 6 + self.assertTrue( + self.installer.is_product_installed("collective.volto.formsupport") + ) def test_browserlayer(self): """Test that ICollectiveVoltoFormsupportLayer is registered.""" @@ -46,7 +51,6 @@ def test_browserlayer(self): class TestUninstall(unittest.TestCase): - layer = VOLTO_FORMSUPPORT_INTEGRATION_TESTING def setUp(self): @@ -57,14 +61,22 @@ def setUp(self): self.installer = api.portal.get_tool("portal_quickinstaller") roles_before = api.user.get_roles(TEST_USER_ID) setRoles(self.portal, TEST_USER_ID, ["Manager"]) - self.installer.uninstallProducts(["collective.volto.formsupport"]) + if hasattr(self.installer, "uninstallProducts"): + self.installer.uninstallProducts(["collective.volto.formsupport"]) + else: # plone6 + self.installer.uninstall_product("collective.volto.formsupport") setRoles(self.portal, TEST_USER_ID, roles_before) def test_product_uninstalled(self): """Test if collective.volto.formsupport is cleanly uninstalled.""" - self.assertFalse( - self.installer.isProductInstalled("collective.volto.formsupport") - ) + if hasattr(self.installer, "isProductInstalled"): + self.assertFalse( + self.installer.isProductInstalled("collective.volto.formsupport") + ) + else: # plone 6 + self.assertFalse( + self.installer.is_product_installed("collective.volto.formsupport") + ) def test_browserlayer_removed(self): """Test that ICollectiveVoltoFormsupportLayer is removed.""" 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 505992d0..fd0af723 100644 --- a/src/collective/volto/formsupport/tests/test_store_action_form.py +++ b/src/collective/volto/formsupport/tests/test_store_action_form.py @@ -19,7 +19,6 @@ class TestMailSend(unittest.TestCase): - layer = VOLTO_FORMSUPPORT_API_FUNCTIONAL_TESTING def setUp(self): diff --git a/src/collective/volto/formsupport/utils.py b/src/collective/volto/formsupport/utils.py index 3027f9ed..ed3f1d0a 100644 --- a/src/collective/volto/formsupport/utils.py +++ b/src/collective/volto/formsupport/utils.py @@ -6,7 +6,7 @@ def flatten_block_hierachy(blocks): - """ Given some blocks, return all contained blocks, including "subblocks" + """Given some blocks, return all contained blocks, including "subblocks" This allows embedding the form block into something like columns datastorage """ @@ -21,16 +21,14 @@ def flatten_block_hierachy(blocks): if "data" in block_value: if isinstance(block_value["data"], dict): if "blocks" in block_value["data"]: - queue.extend(list( - block_value["data"]["blocks"].items())) + queue.extend(list(block_value["data"]["blocks"].items())) if "blocks" in block_value: queue.extend(list(block_value["blocks"].items())) def get_blocks(context): - """ Returns all blocks from a context, including those coming from slots - """ + """Returns all blocks from a context, including those coming from slots""" blocks = copy.deepcopy(getattr(context, "blocks", {})) if isinstance(blocks, six.text_type): diff --git a/test_plone52.cfg b/test_plone52.cfg index 8432387a..1c1306ed 100644 --- a/test_plone52.cfg +++ b/test_plone52.cfg @@ -56,3 +56,6 @@ plone.formwidget.recaptcha = 2.3.0 # Added by buildout at 2022-06-08 10:07:55.395703 collective.z3cform.norobots = 2.0 + +# Added by buildout at 2023-09-18 12:59:01.353135 +collective.honeypot = 2.1 diff --git a/test_plone60.cfg b/test_plone60.cfg new file mode 100644 index 00000000..2f2e045e --- /dev/null +++ b/test_plone60.cfg @@ -0,0 +1,86 @@ +[buildout] + +extends = + https://raw.githubusercontent.com/collective/buildout.plonetest/master/test-6.0.x.cfg + https://raw.githubusercontent.com/collective/buildout.plonetest/master/qa.cfg + base.cfg + +update-versions-file = test_plone60.cfg + +[versions] + +# Added by buildout at 2023-09-18 11:12:41.176409 +bleach = 6.0.0 +build = 0.10.0 +collective.honeypot = 2.1 +collective.z3cform.norobots = 2.0 +coverage = 7.2.2 +createcoverage = 1.5 +flake8 = 6.1.0 +i18ndude = 5.5.0 +keyring = 23.13.1 +markdown-it-py = 2.2.0 +mccabe = 0.7.0 +mdurl = 0.1.2 +odict = 1.9.0 +pkginfo = 1.9.6 +plone.formwidget.hcaptcha = 1.0.0 +plone.formwidget.recaptcha = 2.3.0 +plone.recipe.codeanalysis = 3.0.1 +plumber = 1.7 +pycodestyle = 2.11.0 +pyflakes = 3.1.0 +pyproject-hooks = 1.0.0 +readme-renderer = 37.3 +repoze.catalog = 0.9.0 +requests-toolbelt = 0.10.1 +rfc3986 = 2.0.0 +rich = 13.3.4 +twine = 4.0.2 +zest.releaser = 7.3.0 +zope.index = 5.2.1 + +# Required by: +# plone.recipe.codeanalysis==3.0.1 +check-manifest = 0.49 + +# Required by: +# zest.releaser==7.3.0 +colorama = 0.4.6 + +# Required by: +# keyring==23.13.1 +jaraco.classes = 3.2.3 + +# Required by: +# jaraco.classes==3.2.3 +more-itertools = 9.1.0 + +# Required by: +# node.ext.zodb==1.4 +node = 1.2 + +# Required by: +# souper==1.1.1 +node.ext.zodb = 1.4 + +# Required by: +# souper.plone==1.3.1 +souper = 1.1.1 + +# Required by: +# collective.volto.formsupport==2.6.3.dev0 +souper.plone = 1.3.1 + +# Required by: +# check-manifest==0.49 +tomli = 2.0.1 + +# Required by: +# bleach==6.0.0 +webencodings = 0.5.1 + +# Required by: +# collective.honeypot==2.1 +# collective.volto.formsupport==2.6.3.dev0 +z3c.jbot = 1.1.1 From 7f781a1e46ac0a2dc1e96feda12fbd2da1a3b923 Mon Sep 17 00:00:00 2001 From: Jefferson Bledsoe Date: Mon, 25 Sep 2023 14:38:37 +0100 Subject: [PATCH 70/70] Align text in table formatting (#36) * Start align table contents * Use internal value for display if available * Revert "Use internal value for display if available" This reverts commit a572ed0be5ade539d2f205fd07ebde2f84dd389f. --- .../volto/formsupport/browser/send_mail_template_table.pt | 5 +++++ 1 file changed, 5 insertions(+) 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 6965440b..ac358390 100644 --- a/src/collective/volto/formsupport/browser/send_mail_template_table.pt +++ b/src/collective/volto/formsupport/browser/send_mail_template_table.pt @@ -3,6 +3,11 @@ define="parameters python:options.get('parameters', {}); url python:options.get('url', ''); title python:options.get('title', '');"> +
Form submission data for ${title}