Skip to content

Commit ce19c20

Browse files
folix-01mamicoPlone su Server Crul
authored
Form Add Event (#73)
* Form add event * Form data adapter * Final changes * Formatting * Update to main * Changes * Formatting * add enhanced data adapter * bbb * fix adapters * get_fields_labels * get_fields_labels * change adapter * changelog * Update CHANGES.rst * Update CHANGES.rst --------- Co-authored-by: Mauro Amico <[email protected]> Co-authored-by: Plone su Server Crul <[email protected]>
1 parent 7841e1f commit ce19c20

File tree

12 files changed

+379
-247
lines changed

12 files changed

+379
-247
lines changed

CHANGES.rst

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
11
Changelog
22
=========
33

4-
3.1.6 (unreleased)
4+
3.2.0 (unreleased)
55
------------------
66

7-
- Nothing changed yet.
7+
- Added an adapter (`IDataAdapter`) to allow information to be added as a return value
8+
to the form-data expander. This allows addons that integrate information to be added
9+
rather than overwriting the expander each time.
10+
[mamico]
11+
12+
- Add FormSubmittedEvent to handle the new compiled forms.
13+
[folix-01]
14+
15+
- Add PostAdapter to predispose the customization of data handling by other add-ons.
16+
[folix-01]
817

918

1019
3.1.5 (2024-10-24)

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
setup(
1717
name="collective.volto.formsupport",
18-
version="3.1.6.dev0",
18+
version="3.2.0.dev0",
1919
description="Add support for customizable forms in Volto",
2020
long_description=long_description,
2121
# Get more from https://pypi.org/classifiers/

src/collective/volto/formsupport/adapters/__init__.py

Whitespace-only changes.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<configure
2+
xmlns="http://namespaces.zope.org/zope"
3+
xmlns:plone="http://namespaces.plone.org/plone"
4+
i18n_domain="collective.volto.formsupport"
5+
>
6+
7+
<adapter factory=".post.PostAdapter" />
8+
9+
</configure>
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
from collective.volto.formsupport import _
2+
from collective.volto.formsupport.interfaces import ICaptchaSupport
3+
from collective.volto.formsupport.interfaces import IPostAdapter
4+
from collective.volto.formsupport.utils import get_blocks
5+
from collective.volto.otp.utils import validate_email_token
6+
from copy import deepcopy
7+
from plone import api
8+
from plone.restapi.deserializer import json_body
9+
from plone.schema.email import _isemail
10+
from zExceptions import BadRequest
11+
from zope.component import adapter
12+
from zope.component import getMultiAdapter
13+
from zope.i18n import translate
14+
from zope.interface import implementer
15+
from zope.interface import Interface
16+
17+
import math
18+
import os
19+
20+
21+
@implementer(IPostAdapter)
22+
@adapter(Interface, Interface)
23+
class PostAdapter:
24+
block_id = None
25+
block = {}
26+
27+
def __init__(self, context, request):
28+
self.context = context
29+
self.request = request
30+
self.form_data = self.extract_data_from_request()
31+
self.block_id = self.form_data.get("block_id", "")
32+
if self.block_id:
33+
self.block = self.get_block_data(block_id=self.block_id)
34+
35+
def __call__(self):
36+
"""
37+
Avoid XSS injections and other attacks.
38+
39+
- cleanup HTML with plone transform
40+
- remove from data, fields not defined in form schema
41+
"""
42+
43+
self.validate_form()
44+
45+
return self.form_data
46+
47+
def extract_data_from_request(self):
48+
form_data = json_body(self.request)
49+
50+
fixed_fields = []
51+
transforms = api.portal.get_tool(name="portal_transforms")
52+
53+
block = self.get_block_data(block_id=form_data.get("block_id", ""))
54+
block_fields = [x.get("field_id", "") for x in block.get("subblocks", [])]
55+
56+
for form_field in form_data.get("data", []):
57+
if form_field.get("field_id", "") not in block_fields:
58+
# unknown field, skip it
59+
continue
60+
new_field = deepcopy(form_field)
61+
value = new_field.get("value", "")
62+
if isinstance(value, str):
63+
stream = transforms.convertTo("text/plain", value, mimetype="text/html")
64+
new_field["value"] = stream.getData().strip()
65+
fixed_fields.append(new_field)
66+
67+
form_data["data"] = fixed_fields
68+
69+
return form_data
70+
71+
def get_block_data(self, block_id):
72+
blocks = get_blocks(self.context)
73+
if not blocks:
74+
return {}
75+
for id, block in blocks.items():
76+
if id != block_id:
77+
continue
78+
block_type = block.get("@type", "")
79+
if block_type != "form":
80+
continue
81+
return block
82+
return {}
83+
84+
def validate_form(self):
85+
"""
86+
check all required fields and parameters
87+
"""
88+
if not self.block_id:
89+
raise BadRequest(
90+
translate(
91+
_("missing_blockid_label", default="Missing block_id"),
92+
context=self.request,
93+
)
94+
)
95+
if not self.block:
96+
raise BadRequest(
97+
translate(
98+
_(
99+
"block_form_not_found_label",
100+
default='Block with @type "form" and id "$block" not found in this context: $context',
101+
mapping={
102+
"block": self.block_id,
103+
"context": self.context.absolute_url(),
104+
},
105+
),
106+
context=self.request,
107+
),
108+
)
109+
110+
if not self.block.get("store", False) and not self.block.get("send", []):
111+
raise BadRequest(
112+
translate(
113+
_(
114+
"missing_action",
115+
default='You need to set at least one form action between "send" and "store".', # noqa
116+
),
117+
context=self.request,
118+
)
119+
)
120+
121+
if not self.form_data.get("data", []):
122+
raise BadRequest(
123+
translate(
124+
_(
125+
"empty_form_data",
126+
default="Empty form data.",
127+
),
128+
context=self.request,
129+
)
130+
)
131+
132+
self.validate_attachments()
133+
if self.block.get("captcha", False):
134+
getMultiAdapter(
135+
(self.context, self.request),
136+
ICaptchaSupport,
137+
name=self.block["captcha"],
138+
).verify(self.form_data.get("captcha"))
139+
140+
self.validate_email_fields()
141+
self.validate_bcc()
142+
143+
def validate_email_fields(self):
144+
email_fields = [
145+
x.get("field_id", "")
146+
for x in self.block.get("subblocks", [])
147+
if x.get("field_type", "") == "from"
148+
]
149+
for form_field in self.form_data.get("data", []):
150+
if form_field.get("field_id", "") not in email_fields:
151+
continue
152+
if _isemail(form_field.get("value", "")) is None:
153+
raise BadRequest(
154+
translate(
155+
_(
156+
"wrong_email",
157+
default='Email not valid in "${field}" field.',
158+
mapping={
159+
"field": form_field.get("label", ""),
160+
},
161+
),
162+
context=self.request,
163+
)
164+
)
165+
166+
def validate_bcc(self):
167+
"""
168+
If otp validation is enabled, check if is valid
169+
"""
170+
bcc_fields = []
171+
email_otp_verification = self.block.get("email_otp_verification", False)
172+
block_id = self.form_data.get("block_id", "")
173+
for field in self.block.get("subblocks", []):
174+
if field.get("use_as_bcc", False):
175+
field_id = field.get("field_id", "")
176+
if field_id not in bcc_fields:
177+
bcc_fields.append(field_id)
178+
if not bcc_fields:
179+
return
180+
if not email_otp_verification:
181+
return
182+
for data in self.form_data.get("data", []):
183+
value = data.get("value", "")
184+
if not value:
185+
continue
186+
if data.get("field_id", "") not in bcc_fields:
187+
continue
188+
otp = data.get("otp", "")
189+
if not otp:
190+
raise BadRequest(
191+
api.portal.translate(
192+
_(
193+
"otp_validation_missing_value",
194+
default="Missing OTP value. Unable to submit the form.",
195+
)
196+
)
197+
)
198+
if not validate_email_token(block_id, value, otp):
199+
raise BadRequest(
200+
api.portal.translate(
201+
_(
202+
"otp_validation_wrong_value",
203+
default="${email}'s OTP is wrong",
204+
mapping={"email": data["value"]},
205+
)
206+
)
207+
)
208+
209+
def validate_attachments(self):
210+
attachments_limit = os.environ.get("FORM_ATTACHMENTS_LIMIT", "")
211+
if not attachments_limit:
212+
return
213+
attachments = self.form_data.get("attachments", {})
214+
attachments_len = 0
215+
for attachment in attachments.values():
216+
data = attachment.get("data", "")
217+
attachments_len += (len(data) * 3) / 4 - data.count("=", -2)
218+
if attachments_len > float(attachments_limit) * pow(1024, 2):
219+
size_name = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB")
220+
i = int(math.floor(math.log(attachments_len, 1024)))
221+
p = math.pow(1024, i)
222+
s = round(attachments_len / p, 2)
223+
uploaded_str = f"{s} {size_name[i]}"
224+
raise BadRequest(
225+
translate(
226+
_(
227+
"attachments_too_big",
228+
default="Attachments too big. You uploaded ${uploaded_str},"
229+
" but limit is ${max} MB. Try to compress files.",
230+
mapping={
231+
"max": attachments_limit,
232+
"uploaded_str": uploaded_str,
233+
},
234+
),
235+
context=self.request,
236+
)
237+
)
238+
239+
def filter_parameters(self):
240+
"""
241+
do not send attachments fields.
242+
"""
243+
result = []
244+
245+
for field in self.block.get("subblocks", []):
246+
if field.get("field_type", "") == "attachment":
247+
continue
248+
249+
for item in self.form_data.get("data", []):
250+
if item.get("field_id", "") == field.get("field_id", ""):
251+
result.append(item)
252+
253+
return result
254+
255+
def format_fields(self):
256+
fields = self.filter_parameters()
257+
formatted_fields = []
258+
field_ids = [field.get("field_id") for field in self.block.get("subblocks", [])]
259+
260+
for field in fields:
261+
field_id = field.get("field_id", "")
262+
263+
if field_id:
264+
field_index = field_ids.index(field_id)
265+
266+
if self.block["subblocks"][field_index].get("field_type") == "date":
267+
field["value"] = api.portal.get_localized_time(field["value"])
268+
269+
formatted_fields.append(field)
270+
271+
return formatted_fields

src/collective/volto/formsupport/configure.zcml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
<include package=".datamanager" />
2222
<include package=".restapi" />
2323
<include package=".captcha" />
24+
<include package=".adapters" />
2425

2526
<include file="permissions.zcml" />
2627
<include file="upgrades.zcml" />
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from collective.volto.formsupport.interfaces import IFormSubmittedEvent
2+
from zope.interface import implementer
3+
from zope.interface.interfaces import ObjectEvent
4+
5+
6+
@implementer(IFormSubmittedEvent)
7+
class FormSubmittedEvent(ObjectEvent):
8+
def __init__(self, obj, form, form_data):
9+
super().__init__(obj)
10+
self.form = form
11+
self.form_data = form_data

src/collective/volto/formsupport/interfaces.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
from zope.interface import Attribute
12
from zope.interface import Interface
3+
from zope.interface.interfaces import IObjectEvent
24
from zope.publisher.interfaces.browser import IDefaultBrowserLayer
35

46

@@ -44,3 +46,26 @@ def verify(data):
4446
"""Verify the captcha
4547
@return: True if verified, Raise exception otherwise
4648
"""
49+
50+
51+
class IFormSubmittedEvent(IObjectEvent):
52+
"""An event that's fired upon a workflow transition."""
53+
54+
obj = Attribute("The context object")
55+
56+
form = Attribute("Form")
57+
form_data = Attribute("Form Data")
58+
59+
60+
class IPostAdapter(Interface):
61+
def data():
62+
pass
63+
64+
65+
# BBB
66+
IFormData = IPostAdapter
67+
68+
69+
class IDataAdapter(Interface):
70+
def __call__(result, block_id=None):
71+
pass

src/collective/volto/formsupport/restapi/services/form_data/csv.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ def render(self):
6161
data = data.encode("utf-8")
6262
self.request.response.write(data)
6363

64+
def get_fields_labels(self, item):
65+
return item.attrs.get("fields_labels", {})
66+
6467
def get_data(self):
6568
store = getMultiAdapter((self.context, self.request), IFormDataStore)
6669
sbuf = StringIO()
@@ -70,7 +73,7 @@ def get_data(self):
7073
rows = []
7174
for item in store.search():
7275
data = {}
73-
fields_labels = item.attrs.get("fields_labels", {})
76+
fields_labels = self.get_fields_labels(item)
7477
for k in self.get_ordered_keys(item):
7578
if k in SKIP_ATTRS:
7679
continue

0 commit comments

Comments
 (0)