Skip to content

datatable #46

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 33 commits into from
May 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
20807dd
feat!: Save attachments as blobfile in the storage adapater, add a vi…
mamico Feb 4, 2024
8fdb3b9
fix
mamico Feb 4, 2024
63d86a4
attachment fields in csv
mamico Feb 4, 2024
ebd8729
if there are multiple forms on a page, each csv button downloads the …
mamico Feb 4, 2024
7b0c7a7
tests
mamico Feb 6, 2024
73a1803
attachments
mamico Feb 6, 2024
88093ee
Merge branch 'main' into datatable
mamico Apr 10, 2024
df4b1fd
Merge branch 'main' into datatable
mamico Jun 7, 2024
5994142
flake8
mamico Jun 7, 2024
f2d3379
Merge branch 'main' into datatable
mamico Jun 13, 2024
28c429b
lint
mamico Jun 13, 2024
c86cec2
fix tests
mamico Jun 29, 2024
103613e
Merge branch 'main' into datatable
mamico Jun 29, 2024
23b556b
doc
mamico Jun 29, 2024
14be506
remove plone 5.2 and temporary disable deps check
mamico Jun 29, 2024
2fccad3
python_requires=
mamico Jun 29, 2024
2712a49
revert plone 52 support
mamico Jun 30, 2024
60d213a
isort
mamico Jun 30, 2024
9f394b0
Merge branch 'main' into datatable
mamico Jul 1, 2024
3d815d3
Merge branch 'main' into datatable
cekk Mar 28, 2025
8765bf3
some fixes
cekk Mar 28, 2025
5b219c5
isort
cekk Mar 28, 2025
49629b7
fix multivalue data
cekk Apr 4, 2025
b1f8e69
blacked
cekk Apr 4, 2025
3ca3346
fix test for new attribute field_type
cekk Apr 4, 2025
f7076fe
store field_type in right places
cekk Apr 4, 2025
d60a8b0
black
cekk Apr 4, 2025
0a07985
Merge branch 'main' into datatable
cekk May 12, 2025
60a3462
remove legacy plone 5.2 tests
cekk May 12, 2025
b5cbdcc
update README
cekk May 12, 2025
8c1f87b
add @@download info in readme
cekk May 12, 2025
f08d2b8
update changelog
cekk May 12, 2025
8cb3f73
fix typo
cekk May 12, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ include =
*/src/collective/*
omit =
*/test*
*/upgrades*
/home/*/.buildout/eggs/*
/home/travis/buildout-cache/eggs/*
/home/travis/virtualenv/*
Expand All @@ -16,4 +17,4 @@ omit =
*/lib/*
*.txt
*.rst
*/upgrades.py
*/upgrades.py
32 changes: 0 additions & 32 deletions .github/workflows/legacy.yml

This file was deleted.

8 changes: 8 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ Changelog
3.2.4 (unreleased)
------------------

- Save attachments as blobfile in the storage adapter, add a view to download them, returns
attachment info in the restapi @form-data endpoint.
[mamico]
- Fix: if there are multiple forms on a page, each csv button downloads the record of all the forms,
now if there is a block_id parameter, the csv is filtered on that.
[mamico]
- Subject templating
[folix-01]
- Do not set values in __init__ in *SubmitPost* because the user there is not already set and can lead to problems.
[cekk]

Expand Down
8 changes: 7 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ If block is set to store data, we store it into the content that has that block

The store is an adapter registered for *IFormDataStore* interface, so you can override it easily.

Only fields that are also in block settings are stored. Missing ones will be skipped.
Only fields that are also in block settings are stored (also attachments). Missing ones will be skipped.

Each Record stores also two *service* attributes:

Expand All @@ -155,6 +155,12 @@ 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.

When an attachment is stored, there is a view (@@download) that allow to download the file from the context, for example::

https://nohost/page/saved_data/@@download/record_id/field_id/filename

This view is accessible only for users that can edit the context (Modify portal content permission).

Data ID Mapping
^^^^^^^^^^^^^^^

Expand Down
4 changes: 2 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
"Framework :: Plone :: 5.2",
"Framework :: Plone :: 6.0",
"Programming Language :: Python",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
Expand All @@ -50,7 +49,7 @@
package_dir={"": "src"},
include_package_data=True,
zip_safe=False,
python_requires=">=3.7",
python_requires=">=3.8",
install_requires=[
"setuptools",
"z3c.jbot",
Expand All @@ -59,6 +58,7 @@
"plone.dexterity",
"plone.i18n",
"plone.memoize",
"plone.namedfile",
"plone.protect",
"plone.registry",
"plone.restapi>=8.36.0",
Expand Down
28 changes: 19 additions & 9 deletions src/collective/volto/formsupport/adapters/post.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from zope.interface import implementer
from zope.interface import Interface

import base64
import math
import os

Expand Down Expand Up @@ -118,7 +119,7 @@ def validate_form(self):
)
)

if not self.form_data.get("data", []):
if not self.form_data.get("data", []) and not self.form_data.get("attachments"):
raise BadRequest(
translate(
_(
Expand Down Expand Up @@ -207,14 +208,21 @@ def validate_bcc(self):
)

def validate_attachments(self):
"""
* validate attachments size (total size of all attachments must be less
than FORM_ATTACHMENTS_LIMIT)
"""
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)
for value in attachments.values():
data = value.get("data", "")
if value.get("encoding") == "base64":
attachments_len += len(base64.b64decode(data))
else:
attachments_len += len(data)
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)))
Expand All @@ -239,6 +247,7 @@ def validate_attachments(self):
def filter_parameters(self):
"""
do not send attachments fields.
Used for email message body, and xml attachment (?)
"""
result = []

Expand All @@ -258,16 +267,17 @@ def format_fields(self):
field_ids = [field.get("field_id") for field in self.block.get("subblocks", [])]

for field in fields:
field_id = field.get("field_id", "")
field_data = deepcopy(field)
field_id = field_data.get("field_id", "")

if field_id:
field_index = field_ids.index(field_id)
value = field.get("value", "")
value = field_data.get("value", "")
if isinstance(value, list):
field["value"] = ", ".join(value)
field_data["value"] = ", ".join(value)
if self.block["subblocks"][field_index].get("field_type") == "date":
field["value"] = api.portal.get_localized_time(value)
field_data["value"] = api.portal.get_localized_time(value)

formatted_fields.append(field)
formatted_fields.append(field_data)

return formatted_fields
14 changes: 14 additions & 0 deletions src/collective/volto/formsupport/browser/configure.zcml
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,18 @@
permission="zope2.View"
/>

<browser:page
name="saved_data"
for="plone.dexterity.interfaces.IDexterityContent"
class=".saved_data.SavedData"
permission="cmf.ModifyPortalContent"
/>

<browser:page
name="download"
for=".saved_data.ISavedDataTraverse"
class=".saved_data.AttachmentDownload"
permission="cmf.ModifyPortalContent"
/>

</configure>
55 changes: 55 additions & 0 deletions src/collective/volto/formsupport/browser/saved_data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from collective.volto.formsupport.interfaces import IFormDataStore
from plone.namedfile.utils import set_headers
from plone.namedfile.utils import stream_data
from Products.Five.browser import BrowserView
from zExceptions import NotFound
from zope.component import getMultiAdapter
from zope.interface import implementer
from zope.publisher.interfaces import IPublishTraverse


class ISavedDataTraverse(IPublishTraverse):
pass


@implementer(ISavedDataTraverse)
class SavedData(BrowserView):
pass


@implementer(IPublishTraverse)
class AttachmentDownload(BrowserView):
def __init__(self, context, request):
super().__init__(context, request)
self.record_id = None
self.field_id = None
self.filename = None

def publishTraverse(self, request, name):
"""
e.g.
https://nohost/page/saved_data/@@download/record_id/field_id/filename
"""
if self.record_id is None:
self.record_id = int(name)
elif self.field_id is None:
self.field_id = name
elif self.filename is None:
self.filename = name
else:
raise NotFound("Not found")
return self

def __call__(self):
store = getMultiAdapter((self.context.context, self.request), IFormDataStore)
# data = FormData(self.context, self.request)
try:
record = store.soup.get(self.record_id)
except KeyError:
raise NotFound("Record not found")
try:
field = record.attrs.get(self.field_id)
except KeyError:
raise NotFound("Field not found")
set_headers(field, self.request.response, filename=field.filename)
return stream_data(field)
33 changes: 29 additions & 4 deletions src/collective/volto/formsupport/datamanager/catalog.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from base64 import b64decode
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
from plone.namedfile import NamedBlobFile
from plone.restapi.deserializer import json_body
from repoze.catalog.catalog import Catalog
from repoze.catalog.indexes.field import CatalogFieldIndex
Expand Down Expand Up @@ -79,25 +81,48 @@ def add(self, data):
return None

fields = {
x["field_id"]: x.get("custom_field_id", x.get("label", x["field_id"]))
for x in form_fields
f["field_id"]: {
"label": f.get("custom_field_id", f.get("label", f["field_id"])),
"type": f.get("field_type", "text"),
}
for f in form_fields
}

record = Record()
fields_labels = {}
fields_order = []
fields_types = {}
for field_data in data:
field_id = field_data.get("field_id", "")
value = field_data.get("value", "")
if field_id in fields:
record.attrs[field_id] = value
fields_labels[field_id] = fields[field_id]
field = fields[field_id]
record.attrs[field_id] = self.storedValue(value, field["type"])
fields_types[field_id] = field.get("type", "")
fields_labels[field_id] = field["label"]
fields_order.append(field_id)
# else: skip the field
record.attrs["fields_labels"] = fields_labels
record.attrs["fields_order"] = fields_order
record.attrs["fields_types"] = fields_types
record.attrs["date"] = datetime.now()
record.attrs["block_id"] = self.block_id
return self.soup.add(record)

def storedValue(self, value, type):
if type == "attachment":
if value:
if value.get("encoding") == "base64":
data = b64decode(value["data"])
else:
data = value["data"]
return NamedBlobFile(
data=data,
filename=value.get("filename"),
contentType=value.get("content-type", "application/octet-stream"),
)
return value

def length(self):
return len([x for x in self.soup.data.values()])

Expand Down
16 changes: 13 additions & 3 deletions src/collective/volto/formsupport/restapi/services/form_data/csv.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
from collective.volto.formsupport.utils import get_blocks
from copy import deepcopy
from io import StringIO
from plone.namedfile import NamedBlobFile
from plone.restapi.serializer.converters import json_compatible
from plone.restapi.services import Service
from zExceptions import NotFound
from zope.component import getMultiAdapter

import csv
Expand All @@ -16,12 +18,15 @@ class FormDataExportGet(Service):
def __init__(self, context, request):
super().__init__(context, request)
self.form_fields_order = []
self.form_block = {}
self.form_block = None
self.block_id = self.request.get("block_id")

blocks = getattr(context, "blocks", {})
if not blocks:
return
raise NotFound("No blocks found")
for id, block in blocks.items():
if self.block_id and id != self.block_id:
continue
block_type = block.get("@type", "")
if block_type == "form":
self.form_block = block
Expand Down Expand Up @@ -105,6 +110,8 @@ def get_data(self):
fields_labels = self.get_fields_labels()

for item in store.search():
if self.block_id and item.attrs.get("block_id") != self.block_id:
continue
data = {}

for k in self.get_ordered_keys(item):
Expand All @@ -119,7 +126,10 @@ def get_data(self):
if k not in self.form_fields_order and label not in legacy_columns:
legacy_columns.append(label)

data[label] = json_compatible(value)
if isinstance(value, NamedBlobFile):
data[label] = value.filename
else:
data[label] = json_compatible(value)

for k in fixed_columns:
# add fixed columns values
Expand Down
Loading